144 lines
5.2 KiB
TypeScript
144 lines
5.2 KiB
TypeScript
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 });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|