0.2.1 onlyoffice修复
This commit is contained in:
@@ -15,6 +15,7 @@ declare global {
|
||||
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
|
||||
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
|
||||
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
|
||||
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +133,97 @@ const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
};
|
||||
|
||||
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
||||
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
||||
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
||||
// 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
||||
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
|
||||
if (typeof window === "undefined") return;
|
||||
if (!baseUrl) return;
|
||||
|
||||
const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server");
|
||||
if (!isProxyMode) return;
|
||||
|
||||
const proxyPrefix = (() => {
|
||||
if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase;
|
||||
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
|
||||
})();
|
||||
|
||||
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
|
||||
try {
|
||||
if (onlyofficeBaseUrlDesktop) {
|
||||
const u = new URL(onlyofficeBaseUrlDesktop);
|
||||
internalOrigins.add(`${u.protocol}//${u.host}`);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const patchWindow = (win: Window) => {
|
||||
try {
|
||||
if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return;
|
||||
const rewriteUrl = (input: string) => {
|
||||
try {
|
||||
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
|
||||
const origin = `${u.protocol}//${u.host}`;
|
||||
if (!internalOrigins.has(origin)) return input;
|
||||
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||
if (typeof origOpen !== "function") return;
|
||||
// eslint-disable-next-line no-extend-native
|
||||
(win as any).XMLHttpRequest.prototype.open = function openPatched(
|
||||
method: string,
|
||||
url: string,
|
||||
async?: boolean,
|
||||
user?: string | null,
|
||||
password?: string | null,
|
||||
) {
|
||||
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
|
||||
};
|
||||
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
patchWindow(window);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
try {
|
||||
const frames = Array.from(document.querySelectorAll("iframe"));
|
||||
for (const f of frames) {
|
||||
try {
|
||||
const w = (f as HTMLIFrameElement).contentWindow;
|
||||
if (!w) continue;
|
||||
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
w.location?.origin;
|
||||
patchWindow(w);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (Date.now() - start > 120_000) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const docTypeFromExt = (ext: string) => {
|
||||
const word = ["doc", "docx", "odt", "rtf"];
|
||||
const slide = ["ppt", "pptx", "odp"];
|
||||
@@ -167,9 +259,53 @@ export default function OnlyOfficePage() {
|
||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||
const assetId = params.get("assetId") ?? "";
|
||||
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
|
||||
const baseUrlCandidates = useMemo(() => {
|
||||
const uniq: string[] = [];
|
||||
const push = (v?: string | null) => {
|
||||
const s = String(v || "").trim().replace(/\/+$/, "");
|
||||
if (!s) return;
|
||||
if (!uniq.includes(s)) uniq.push(s);
|
||||
};
|
||||
|
||||
// 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL),
|
||||
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
|
||||
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
|
||||
try {
|
||||
push("/onlyoffice-server");
|
||||
push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl;
|
||||
// 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。
|
||||
if (channel === "web") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
return uniq;
|
||||
}
|
||||
if (channel === "desktop") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
return uniq;
|
||||
}
|
||||
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
if (runtimeConfig.isDesktop) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
} else {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
return uniq;
|
||||
}, [channel, runtimeConfig]);
|
||||
|
||||
const [baseUrlIndex, setBaseUrlIndex] = useState(0);
|
||||
const baseUrl = baseUrlCandidates[baseUrlIndex] || "";
|
||||
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
||||
@@ -293,15 +429,33 @@ export default function OnlyOfficePage() {
|
||||
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
|
||||
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000,
|
||||
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
|
||||
let base = storageHostOverride
|
||||
? fileUrl
|
||||
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
||||
const isConvexStorageUrl = (() => {
|
||||
// 说明:Convex Files 的直链通常形如:
|
||||
// - http://127.0.0.1:3210/api/storage/<id>
|
||||
// - https://<convex-host>/api/storage/<id>
|
||||
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
|
||||
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
|
||||
try {
|
||||
const u = new URL(fileUrl);
|
||||
return u.pathname.startsWith("/api/storage/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
let base =
|
||||
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
||||
? fileUrl
|
||||
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
|
||||
try {
|
||||
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
|
||||
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
|
||||
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
|
||||
const raw = new URL(base);
|
||||
const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||
|
||||
const isLocalHost =
|
||||
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
||||
|
||||
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
||||
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
||||
@@ -312,11 +466,23 @@ export default function OnlyOfficePage() {
|
||||
raw.host = po.host;
|
||||
base = raw.toString();
|
||||
}
|
||||
|
||||
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
|
||||
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
|
||||
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
|
||||
if (!alreadyProxy && proxyOrigin && isLocalHost) {
|
||||
const proxyBase = proxyOrigin || window.location.origin;
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
if (!alreadyProxy && raw.searchParams.has("token")) {
|
||||
const proxyBase = proxyOrigin || window.location.origin;
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
|
||||
const u = new URL(base);
|
||||
@@ -337,6 +503,25 @@ export default function OnlyOfficePage() {
|
||||
}
|
||||
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
|
||||
pageOrigin: window.location.origin,
|
||||
baseUrl,
|
||||
proxyOrigin,
|
||||
callbackOrigin,
|
||||
fileUrlInput: fileUrl,
|
||||
resolvedFileUrl,
|
||||
fileName,
|
||||
fileType,
|
||||
mode,
|
||||
assetId,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl) {
|
||||
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
||||
@@ -346,6 +531,28 @@ export default function OnlyOfficePage() {
|
||||
setError("缺少 fileUrl 参数。");
|
||||
return;
|
||||
}
|
||||
|
||||
setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
|
||||
// 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问
|
||||
// document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin
|
||||
//(onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。
|
||||
try {
|
||||
const pageHost = window.location.hostname;
|
||||
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
|
||||
const isPageRemote = !isPageLocal;
|
||||
const u = new URL(fileUrl);
|
||||
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
|
||||
if (isPageRemote && isFileLocal && !proxyOrigin) {
|
||||
setError(
|
||||
"外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||
loadScript(scriptUrl)
|
||||
.then(async () => {
|
||||
@@ -462,9 +669,15 @@ export default function OnlyOfficePage() {
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
|
||||
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
|
||||
if (hasNext) {
|
||||
setBaseUrlIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
});
|
||||
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
|
||||
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user