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
@@ -0,0 +1,153 @@
import { NextResponse } from "next/server";
import supabaseAdmin from "@/lib/supabase/admin";
export const dynamic = "force-dynamic";
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
type OnlyOfficeCallbackBody = {
status?: number;
url?: string;
key?: string;
};
const normalizeSecret = (raw: string) => {
const trimmed = String(raw || "").trim();
if (!trimmed) return "";
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
if (
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
};
const isUuid = (value: string) =>
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
const resolveAssetObjectLocation = async (params: {
bucket?: string | null;
storagePath?: string | null;
workspaceId?: string | null;
fileName?: string | null;
}) => {
const { bucket, storagePath, workspaceId, fileName } = params;
const ws = (workspaceId ?? "").trim();
const fn = (fileName ?? "").trim();
if (!ws || !isUuid(ws) || !fn) return null;
try {
const storage = supabaseAdmin.schema("storage");
const b = (bucket ?? "").trim();
const p = (storagePath ?? "").trim();
if (b && p) {
const { data: exact, error: exactError } = await storage
.from("objects")
.select("bucket_id,name")
.eq("bucket_id", b)
.eq("name", p)
.limit(1);
if (!exactError && exact && exact.length > 0) {
return { bucket: b, path: p };
}
}
const pattern = `${ws}/%/${fn}`;
const { data: candidates, error: candError } = await storage
.from("objects")
.select("bucket_id,name,created_at")
.like("name", pattern)
.order("created_at", { ascending: false })
.limit(20);
if (candError || !candidates || candidates.length === 0) return null;
const exactCandidate =
candidates.find((o) => String(o?.name || "").endsWith(`/${fn}`)) ?? candidates[0];
if (!exactCandidate?.bucket_id || !exactCandidate?.name) return null;
return { bucket: String(exactCandidate.bucket_id), path: String(exactCandidate.name) };
} catch {
return null;
}
};
export async function POST(request: Request) {
const { searchParams } = new URL(request.url);
const assetId = searchParams.get("assetId") || "";
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
if (ONLYOFFICE_CALLBACK_SECRET) {
const got = normalizeSecret(searchParams.get("token") || "");
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
if (!got || got !== expected) {
return NextResponse.json({ error: 1 });
}
}
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
if (!body) {
return NextResponse.json({ error: 0 });
}
const status = Number(body.status ?? -1);
// 说明:仅在文档需要保存时处理(2=ready for saving6=force save)。
if (status !== 2 && status !== 6) {
return NextResponse.json({ error: 0 });
}
if (!assetId) {
return NextResponse.json({ error: 1 });
}
if (!body.url) {
return NextResponse.json({ error: 1 });
}
try {
const { data: asset, error: assetError } = await supabaseAdmin
.from("media_assets")
.select("*")
.eq("id", assetId)
.single();
if (assetError || !asset) {
return NextResponse.json({ error: 1 });
}
const loc = await resolveAssetObjectLocation({
bucket: (asset.bucket as string | null) ?? null,
storagePath: (asset.storage_path as string | null) ?? null,
workspaceId: (asset.workspace_id as string | null) ?? null,
fileName: (asset.file_name as string | null) ?? null,
});
if (!loc) {
return NextResponse.json({ error: 1 });
}
const upstream = await fetch(body.url, { method: "GET", redirect: "follow" });
if (!upstream.ok) {
return NextResponse.json({ error: 1 });
}
const buf = Buffer.from(await upstream.arrayBuffer());
const contentType = String(asset.mime_type || "application/octet-stream");
const { error: uploadError } = await supabaseAdmin.storage.from(loc.bucket).upload(loc.path, buf, {
contentType,
upsert: true,
});
if (uploadError) {
return NextResponse.json({ error: 1 });
}
// 说明:桌面端(Supabase)模式下,文件 URL 通常是短期 signedUrl
// 覆盖同一 storage_path 的对象即可让后续刷新/下载拿到最新文件。
return NextResponse.json({ error: 0 });
} catch (error) {
console.error("[onlyoffice/callback] supabase writeback failed:", error);
return NextResponse.json({ error: 1 });
}
}
@@ -110,7 +110,7 @@ export default function OnlyOfficePage() {
} catch {
return base;
}
}, [fileUrl, storageHostOverride]);
}, [fileUrl, proxyOrigin, runtimeConfig.supabaseUrl, storageHostOverride]);
useEffect(() => {
if (!baseUrl) {
@@ -144,6 +144,16 @@ export default function OnlyOfficePage() {
editorConfig: {
mode: mode === "view" ? "view" : "edit",
lang: "zh-CN",
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件;
// 桌面端(standalone)这里必须配置,否则“看似已保存”但不会写回原文件。
callbackUrl: assetId
? (() => {
const base = proxyOrigin || window.location.origin;
const cb = new URL("/api/onlyoffice/callback", base);
cb.searchParams.set("assetId", assetId);
return cb.toString();
})()
: undefined,
customization: {
feedback: { visible: false },
},
@@ -192,7 +202,7 @@ export default function OnlyOfficePage() {
.catch((err: Error) => {
setError(err.message);
});
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
}, [assetId, baseUrl, fileName, fileType, mode, proxyOrigin, resolvedFileUrl, targetDocType]);
if (error) {
return (