0.2.1 onlyoffice修复

This commit is contained in:
liaibo
2026-01-17 10:12:53 +08:00
parent 94957dc361
commit 19907bccdc
102 changed files with 7188 additions and 186 deletions
@@ -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-serverNext 代理到 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 (