0.3.5 共享功能修复

This commit is contained in:
liaibo
2026-01-24 12:32:51 +08:00
parent 25923f308c
commit 3c3f407f4b
44 changed files with 3754 additions and 420 deletions
@@ -5,6 +5,8 @@ import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { useConvex } from "convex/react";
import { api } from "@/lib/convex/api";
type EditorMode = "view" | "edit";
@@ -136,7 +138,8 @@ const base64UrlEncodeUtf8 = (input: string) => {
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 页面内对 XHR/fetch/window.open/location.assign 等做 URL 重写:
// 把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
if (typeof window === "undefined") return;
if (!baseUrl) return;
@@ -193,20 +196,87 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
return input;
}
};
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen !== "function") return;
(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;
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
// 说明:修复 “下载为 xlsx/docx” 等场景:这类下载常通过 window.open/location 跳转,
// 如果 URL 指向 127.0.0.1:8081,会导致浏览器无法下载(因为 8081 只在 Docker 内部/代理后可用)。
try {
const origWinOpen = (win as any).open;
if (typeof origWinOpen === "function") {
(win as any).open = function openPatched(
url?: string | URL,
target?: string,
features?: string,
) {
const nextUrl =
typeof url === "string"
? rewriteUrl(url)
: url instanceof URL
? new URL(rewriteUrl(url.toString()))
: url;
return origWinOpen.call(this, nextUrl as any, target as any, features as any);
};
}
} catch {
// ignore
}
try {
const loc = (win as any).location as Location | undefined;
if (loc && typeof loc.assign === "function") {
const origAssign = loc.assign.bind(loc);
loc.assign = ((url: string) => origAssign(rewriteUrl(url))) as any;
}
if (loc && typeof loc.replace === "function") {
const origReplace = loc.replace.bind(loc);
loc.replace = ((url: string) => origReplace(rewriteUrl(url))) as any;
}
} catch {
// ignore
}
try {
const origFetch = (win as any).fetch;
if (typeof origFetch === "function") {
(win as any).fetch = function fetchPatched(input: any, init?: any) {
try {
if (typeof input === "string") {
return origFetch.call(this, rewriteUrl(input), init);
}
if (input instanceof URL) {
return origFetch.call(this, new URL(rewriteUrl(input.toString())), init);
}
if (input && typeof input === "object" && typeof input.url === "string") {
const next = new Request(rewriteUrl(input.url), input);
return origFetch.call(this, next, init);
}
} catch {
// ignore
}
return origFetch.call(this, input, init);
};
}
} catch {
// ignore
}
try {
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen === "function") {
(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;
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
}
} catch {
// ignore
}
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
} catch {
// ignore
@@ -279,10 +349,22 @@ 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 documentId = params.get("documentId") ?? "";
const initialUserId = params.get("userId") ?? "";
const channel = (params.get("channel") ?? "").trim().toLowerCase();
const convex = useConvex();
const [error, setError] = useState<string | null>(null);
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
const [assetSignedUrl, setAssetSignedUrl] = useState<string>("");
const [assetStorageId, setAssetStorageId] = useState<string>("");
const [forceSaveState, setForceSaveState] = useState<{
busy: boolean;
message: string | null;
ok: boolean | null;
}>({ busy: false, message: null, ok: null });
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrlCandidates = useMemo(() => {
const uniq: string[] = [];
@@ -332,6 +414,33 @@ export default function OnlyOfficePage() {
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
useEffect(() => {
// 说明:为了让 document.key 在同一附件的多次打开之间保持稳定(提升缓存命中与加载速度),
// 这里在有 assetId 时拉取一次附件元信息(storage_id)。
if (!assetId) return;
let canceled = false;
fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`, { method: "GET" })
.then(async (r) => {
if (!r.ok) return null;
return (await r.json().catch(() => null)) as
| { signedUrl?: string; asset?: { storage_id?: string | null } | null }
| null;
})
.then((payload) => {
if (!payload || canceled) return;
const signed = String(payload.signedUrl || "").trim();
const sid = String(payload.asset?.storage_id || "").trim();
if (signed) setAssetSignedUrl(signed);
if (sid) setAssetStorageId(sid);
})
.catch(() => {
// ignore
});
return () => {
canceled = true;
};
}, [assetId]);
useEffect(() => {
// 说明:ONLYOFFICE 回调由文档服务器触发,不携带用户 Cookie。
// 为了让 /api/onlyoffice/callback 能以真实用户身份写回存储,
@@ -357,6 +466,52 @@ export default function OnlyOfficePage() {
};
}, [authedUserId]);
useEffect(() => {
// 只读权限:强制以 view 模式打开(避免分享页面只读但 ONLYOFFICE 仍可编辑)。
if (!documentId) {
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
return;
}
if (!authedUserId) return;
let canceled = false;
void (async () => {
try {
const perm = await convex.query(api.documents.getPermissionForUser, {
userId: authedUserId,
id: documentId,
});
if (canceled) return;
const disableDownload = Boolean((perm as any)?.disableDownload);
const disableCopy = Boolean((perm as any)?.disableCopy);
setResolvedDisableDownload(disableDownload);
setResolvedDisableCopy(disableCopy);
if (mode === "view") {
setResolvedMode("view");
return;
}
if (!perm || (perm as any).permission !== "edit") {
setResolvedMode("view");
} else {
setResolvedMode("edit");
}
} catch {
if (canceled) return;
setResolvedMode(mode === "view" ? "view" : mode);
setResolvedDisableDownload(false);
setResolvedDisableCopy(false);
}
})();
return () => {
canceled = true;
};
}, [authedUserId, convex, documentId, mode]);
useEffect(() => {
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild)
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
@@ -470,8 +625,22 @@ export default function OnlyOfficePage() {
}, []);
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
const effectiveFileUrl = assetSignedUrl || fileUrl;
const docKey = useMemo(() => {
// 说明:ONLYOFFICE 的 document.key 会参与其内部缓存/分片路由(部分版本会把它拼进请求参数),
// 如果 key 每次打开都变化,会导致浏览器缓存命中率极低。
// - 有 assetId:优先用 assetId + storage_id(保存写回后 storage_id 会变化,自然失效)
// - 无 assetId:退回到现有 hash 逻辑
if (assetId) {
const sid = String(assetStorageId || "").trim();
// 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符;
// 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。
return sid ? `${assetId}_${hashKey(sid)}` : assetId;
}
return hashKey(`${effectiveFileUrl}-${fileName}`);
}, [assetId, assetStorageId, effectiveFileUrl, fileName]);
const resolvedFileUrl = useMemo(() => {
if (!fileUrl) return "";
if (!effectiveFileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
@@ -483,7 +652,7 @@ export default function OnlyOfficePage() {
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
try {
const u = new URL(fileUrl);
const u = new URL(effectiveFileUrl);
return u.pathname.startsWith("/api/storage/");
} catch {
return false;
@@ -492,8 +661,8 @@ export default function OnlyOfficePage() {
let base =
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
? fileUrl
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
? effectiveFileUrl
: rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl);
try {
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
@@ -548,7 +717,7 @@ export default function OnlyOfficePage() {
} catch {
return base;
}
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
}, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]);
useEffect(() => {
try {
@@ -557,24 +726,43 @@ export default function OnlyOfficePage() {
baseUrl,
proxyOrigin,
callbackOrigin,
fileUrlInput: fileUrl,
fileUrlInput: effectiveFileUrl,
resolvedFileUrl,
fileName,
fileType,
mode,
assetId,
docKey,
};
} catch {
// ignore
}
}, [assetId, authedUserId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
}, [
assetId,
authedUserId,
baseUrl,
callbackOrigin,
docKey,
effectiveFileUrl,
fileName,
fileType,
resolvedMode,
proxyOrigin,
resolvedFileUrl,
]);
useEffect(() => {
// 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让
// /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。
// 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。
if (mode !== "view" && assetId && !authedUserId) {
return;
}
if (!baseUrl) {
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
return;
}
if (!fileUrl) {
if (!effectiveFileUrl) {
setError("缺少 fileUrl 参数。");
return;
}
@@ -588,7 +776,7 @@ export default function OnlyOfficePage() {
const pageHost = window.location.hostname;
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
const isPageRemote = !isPageLocal;
const u = new URL(fileUrl);
const u = new URL(effectiveFileUrl);
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
if (isPageRemote && isFileLocal && !proxyOrigin) {
setError(
@@ -600,6 +788,9 @@ export default function OnlyOfficePage() {
// ignore
}
if (!resolvedMode) return;
if (resolvedMode !== "view" && assetId && !authedUserId) return;
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
loadScript(scriptUrl)
.then(async () => {
@@ -617,7 +808,8 @@ export default function OnlyOfficePage() {
title: fileName,
url: resolvedFileUrl,
// 说明:key 用于 ONLYOFFICE 内部区分文档实例;应随 URL/文件名变化,避免缓存/冲突。
key: hashKey(`${resolvedFileUrl}-${fileName}`),
// 这里改为“对同一附件尽量稳定”的 key,以提升远端场景的缓存命中与加载速度。
key: docKey,
},
documentType: targetDocType,
events: {
@@ -655,6 +847,9 @@ export default function OnlyOfficePage() {
})(),
customization: {
feedback: { visible: false },
// 说明:启用 forcesave 后,用户点击 ONLYOFFICE 的“保存”会触发 status=6 回调,
// 从而立即把最新版本写回主存储(无需等待关闭文档触发 status=2)。
forcesave: true,
},
plugins: {
autostart: [MNOTE_AGENT_PLUGIN_GUID],
@@ -665,6 +860,24 @@ export default function OnlyOfficePage() {
// 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现”
// 来设置 ready flag,保证远程 E2E 判定稳定。
// 只读权限兜底:即使外部传了 mode=edit,也强制改为 view,且禁用编辑写回。
try {
config.document = config.document || {};
config.document.permissions = {
...(config.document.permissions || {}),
edit: resolvedMode !== "view",
download: !resolvedDisableDownload,
print: !resolvedDisableDownload,
copy: !resolvedDisableCopy,
};
config.editorConfig = config.editorConfig || {};
config.editorConfig.mode = resolvedMode === "view" ? "view" : "edit";
config.editorConfig.customization = config.editorConfig.customization || {};
config.editorConfig.customization.forcesave = resolvedMode !== "view";
} catch {
// ignore
}
(window as any).__MNOTE_ONLYOFFICE_READY__ = false;
const readyDeadline = Date.now() + 120_000;
const timer = window.setInterval(() => {
@@ -725,7 +938,58 @@ export default function OnlyOfficePage() {
}
setError(err.message);
});
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
}, [
assetId,
authedUserId,
baseUrl,
baseUrlCandidates.length,
baseUrlIndex,
callbackOrigin,
docKey,
effectiveFileUrl,
fileName,
fileType,
mode,
proxyOrigin,
resolvedFileUrl,
targetDocType,
runtimeConfig.onlyofficeBaseUrlDesktop,
]);
const canForceSave = resolvedMode === "edit" && Boolean(assetId) && Boolean(docKey);
const triggerForceSave = async () => {
if (!canForceSave) return;
if (forceSaveState.busy) return;
setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null });
try {
const url = new URL("/api/onlyoffice/forcesave", window.location.origin);
url.searchParams.set("assetId", assetId);
url.searchParams.set("key", docKey);
const r = await fetch(url.toString(), { method: "POST" });
if (!r.ok) {
const payload = (await r.json().catch(() => null)) as { error?: string } | null;
throw new Error(payload?.error ?? "触发失败");
}
setForceSaveState({ busy: false, message: "已触发同步保存:请稍等 1~3 秒后再下载/刷新。", ok: true });
window.setTimeout(() => {
setForceSaveState((s) => (s.ok ? { ...s, message: null } : s));
}, 4000);
} catch (e) {
setForceSaveState({
busy: false,
message: `同步保存失败:${(e as Error).message}`,
ok: false,
});
}
};
if (!error && mode !== "view" && assetId && !authedUserId) {
return (
<div className="flex h-screen items-center justify-center bg-slate-50 text-sm text-gray-600">
</div>
);
}
if (error) {
return (
@@ -739,9 +1003,27 @@ export default function OnlyOfficePage() {
return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />
{canForceSave && (
<div className="pointer-events-none absolute right-3 top-3 z-50 flex flex-col items-end gap-2">
<button
type="button"
className="pointer-events-auto rounded-md bg-white/90 px-3 py-2 text-xs text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={forceSaveState.busy}
onClick={() => void triggerForceSave()}
title="不依赖 ONLYOFFICE 内置保存按钮,直接触发 forcesave 写回主存储"
>
{forceSaveState.busy ? "同步保存中…" : "同步保存"}
</button>
{forceSaveState.message && (
<div className="pointer-events-none max-w-[320px] rounded-md bg-black/70 px-3 py-2 text-xs text-white">
{forceSaveState.message}
</div>
)}
</div>
)}
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,