Files
mnote/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts
T

144 lines
5.2 KiB
TypeScript
Raw Normal View History

2026-01-24 12:32:51 +08:00
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";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
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) {
let auth;
let client;
try {
const authed = await getAuthedConvexClient();
auth = authed.auth;
client = authed.client;
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
}
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const assetId = String(searchParams.get("assetId") || "").trim();
const key = String(searchParams.get("key") || "").trim();
if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
if (!key) return NextResponse.json({ error: "缺少 key" }, { status: 400 });
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
if (!asset) {
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
}
// 只读权限:不允许触发 forcesave(避免只读用户间接写回存储)
const docId = String((asset as any).document_id || "").trim();
if (!docId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
}
const doc = await client.query(api.documents.getMeta, { id: docId });
if (!doc || (doc as any).can_edit === false) {
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(`${ONLYOFFICE_INTERNAL_URL}/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(`${ONLYOFFICE_INTERNAL_URL}/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(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
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(`${ONLYOFFICE_INTERNAL_URL}/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 });
}
return NextResponse.json(
{ error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } },
{ status: 502 },
);
} catch (error) {
console.error("[onlyoffice/forcesave] failed:", error);
return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 });
}
}