feat: 完成 rust cutover phase 8 收口

This commit is contained in:
lix-2026
2026-04-15 20:01:12 +08:00
parent 822c730cd6
commit 98db79b301
104 changed files with 18777 additions and 3025 deletions
@@ -1,41 +1,13 @@
import { NextResponse } from "next/server";
import crypto from "crypto";
import { api } from "@/lib/convex/api";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveOnlyOfficeInternalUrl } from "@/lib/onlyoffice/internal-url";
import { documentBridgeErrorResponse } from "@/lib/documents/bridge";
import { prepareOnlyOfficeForcesave } from "@/lib/onlyoffice/rust-adapter";
export const dynamic = "force-dynamic";
const base64Url = (input: Buffer | string) =>
Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
const signHs256 = (payload: unknown, secret: string) => {
const header = { alg: "HS256", typ: "JWT" };
const headerPart = base64Url(JSON.stringify(header));
const payloadPart = base64Url(JSON.stringify(payload));
const signingInput = `${headerPart}.${payloadPart}`;
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
return `${signingInput}.${base64Url(signature)}`;
};
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;
};
export async function POST(request: Request) {
const onlyofficeInternalUrl = await resolveOnlyOfficeInternalUrl();
let auth;
@@ -72,70 +44,43 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 });
}
const payload = { c: "forcesave", key, userdata: `asset:${assetId}` };
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any;
try {
// 优先按文档推荐:使用 /command + token
if (secret) {
const token = signHs256(payload, secret);
const r = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const j = await tryJson(r);
if (r.ok && Number(j?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "command", result: j });
}
// 兜底:部分环境可能暴露 /forcesave 直连接口
const r2 = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j2 = await tryJson(r2);
if (r2.ok && Number(j2?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "forcesave", result: j2 });
}
return NextResponse.json(
{ error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } },
{ status: 502 },
);
}
// JWT 未启用:尝试 /forcesave 直连
const r = await fetch(`${onlyofficeInternalUrl}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
const prepared = await prepareOnlyOfficeForcesave({
request,
assetId,
key,
onlyofficeInternalUrl,
secret: process.env.ONLYOFFICE_JWT_SECRET || "",
actorId: auth.userId,
documentId: docId,
workspaceId: String((asset as any).workspace_id || "").trim() || null,
});
const j = await tryJson(r);
if (r.ok && Number(j?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "forcesave", result: j });
}
// 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证)
const r2 = await fetch(`${onlyofficeInternalUrl}/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const j2 = await tryJson(r2);
if (r2.ok && Number(j2?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: "command_no_token", result: j2 });
const errors: Record<string, unknown> = {};
for (const outbound of prepared.requests) {
const response = await fetch(outbound.url, {
method: outbound.method,
headers: Object.fromEntries(outbound.headers.map((item) => [item.name, item.value])),
body: outbound.bodyJson,
});
const payload = await tryJson(response);
if (response.ok && Number(payload?.error ?? 0) === 0) {
return NextResponse.json({ ok: true, via: outbound.via, result: payload });
}
errors[outbound.via] = payload;
}
return NextResponse.json(
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
{ error: "触发 forcesave 失败", detail: errors },
{ status: 502 },
);
} catch (error) {
if (error instanceof Error && error.name === "DocumentBridgeError") {
return documentBridgeErrorResponse(error);
}
console.error("[onlyoffice/forcesave] failed:", error);
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
}