2025-12-26 07:52:40 +08:00
|
|
|
|
import { NextResponse } from "next/server";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
2026-01-11 12:35:53 +08:00
|
|
|
|
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
2026-01-15 20:54:21 +08:00
|
|
|
|
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
|
|
|
|
|
import supabaseAdmin from "@/lib/supabase/admin";
|
2026-01-17 10:12:53 +08:00
|
|
|
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
|
|
|
|
|
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
2025-12-26 07:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const base64Url = (input: string) =>
|
|
|
|
|
|
Buffer.from(input)
|
|
|
|
|
|
.toString("base64")
|
|
|
|
|
|
.replace(/=/g, "")
|
|
|
|
|
|
.replace(/\+/g, "-")
|
|
|
|
|
|
.replace(/\//g, "_");
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
2025-12-26 07:52:40 +08:00
|
|
|
|
const parseStoragePath = (fileUrl: string) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const url = new URL(fileUrl);
|
|
|
|
|
|
const segments = url.pathname.split("/").filter(Boolean);
|
|
|
|
|
|
const objectIdx = segments.findIndex((seg) => seg === "object");
|
|
|
|
|
|
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
|
|
|
|
|
// pattern 1: /storage/v1/object/public/<bucket>/<path...>
|
|
|
|
|
|
if (segments[objectIdx + 1] === "public") {
|
|
|
|
|
|
const bucket = segments[objectIdx + 2];
|
|
|
|
|
|
const path = segments.slice(objectIdx + 3).join("/");
|
|
|
|
|
|
return { bucket, path };
|
|
|
|
|
|
}
|
|
|
|
|
|
// pattern 2: /storage/v1/object/sign/<bucket>/<path...> (token in query)
|
|
|
|
|
|
if (segments[objectIdx + 1] === "sign") {
|
|
|
|
|
|
const bucket = segments[objectIdx + 2];
|
|
|
|
|
|
const path = segments.slice(objectIdx + 3).join("/");
|
|
|
|
|
|
return { bucket, path };
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
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 tryResolveExistingObjectPath = async (params: {
|
|
|
|
|
|
bucket: string;
|
|
|
|
|
|
path: string;
|
|
|
|
|
|
fileName?: string;
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const { bucket, path, fileName } = params;
|
|
|
|
|
|
if (!bucket || !path) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const storage = supabaseAdmin.schema("storage");
|
|
|
|
|
|
|
|
|
|
|
|
// 1) 先尝试精确匹配(最快且最准确)
|
|
|
|
|
|
const { data: exact, error: exactError } = await storage
|
|
|
|
|
|
.from("objects")
|
|
|
|
|
|
.select("name")
|
|
|
|
|
|
.eq("bucket_id", bucket)
|
|
|
|
|
|
.eq("name", path)
|
|
|
|
|
|
.limit(1);
|
|
|
|
|
|
|
|
|
|
|
|
if (!exactError && exact && exact.length > 0 && exact[0]?.name) {
|
|
|
|
|
|
return String(exact[0].name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 兼容历史数据:老链接中间 UUID 可能用的是 document_id,
|
|
|
|
|
|
// 但实际对象通常是 workspace_id/<其他uuid>/<file_name>。
|
|
|
|
|
|
const segments = path.split("/").filter(Boolean);
|
|
|
|
|
|
const workspaceId = segments[0] ?? "";
|
|
|
|
|
|
const wantedName = (fileName ?? segments[segments.length - 1] ?? "").trim();
|
|
|
|
|
|
if (!workspaceId || !isUuid(workspaceId) || !wantedName) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const pattern = `${workspaceId}/%/${wantedName}`;
|
|
|
|
|
|
const { data: candidates, error: candError } = await storage
|
|
|
|
|
|
.from("objects")
|
|
|
|
|
|
.select("name,created_at")
|
|
|
|
|
|
.eq("bucket_id", bucket)
|
|
|
|
|
|
.like("name", pattern)
|
|
|
|
|
|
.order("created_at", { ascending: false })
|
|
|
|
|
|
.limit(20);
|
|
|
|
|
|
|
|
|
|
|
|
if (candError || !candidates || candidates.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
|
|
// 说明:LIKE 会把 `_` 当作通配符,这里用 endsWith 再做一次精确过滤。
|
|
|
|
|
|
const exactCandidate =
|
|
|
|
|
|
candidates.find((o) => String(o?.name || "").endsWith(`/${wantedName}`)) ??
|
|
|
|
|
|
candidates[0];
|
|
|
|
|
|
if (!exactCandidate?.name) return null;
|
|
|
|
|
|
return String(exactCandidate.name);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-26 07:52:40 +08:00
|
|
|
|
export async function GET(request: Request) {
|
2026-01-17 10:12:53 +08:00
|
|
|
|
if (isConvexEnabled()) {
|
|
|
|
|
|
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
|
|
|
|
|
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
|
|
|
|
|
try {
|
|
|
|
|
|
requireAuthContext();
|
|
|
|
|
|
} 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 fileUrl = searchParams.get("fileUrl");
|
|
|
|
|
|
if (!fileUrl) {
|
|
|
|
|
|
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
|
|
|
|
|
}
|
|
|
|
|
|
return NextResponse.json({ signedUrl: fileUrl });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
|
|
|
|
|
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
|
|
|
|
|
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
|
|
|
|
|
// 再回退到 request.url 解析出的 origin。
|
|
|
|
|
|
const runtimeCfg = getMnoteRuntimeConfig();
|
|
|
|
|
|
|
|
|
|
|
|
const supabase = await createSupabaseRouteClient();
|
|
|
|
|
|
const {
|
|
|
|
|
|
data: { session },
|
|
|
|
|
|
} = await supabase.auth.getSession();
|
|
|
|
|
|
|
|
|
|
|
|
if (!session) {
|
|
|
|
|
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 07:52:40 +08:00
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
|
|
const fileUrl = searchParams.get("fileUrl");
|
|
|
|
|
|
const fileName = searchParams.get("fileName") ?? undefined;
|
|
|
|
|
|
const forOnlyOffice = searchParams.get("for") === "onlyoffice";
|
|
|
|
|
|
if (searchParams.get("debug") === "1") {
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
|
keyLen: (process.env.SUPABASE_SERVICE_ROLE_KEY || "").length,
|
|
|
|
|
|
url: process.env.SUPABASE_URL,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!fileUrl) {
|
|
|
|
|
|
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
// 说明:
|
|
|
|
|
|
// - 非 ONLYOFFICE:必须能解析为 Supabase Storage 路径,便于生成带 download 的临时签名 URL。
|
|
|
|
|
|
// - ONLYOFFICE:允许传入任意可访问 URL(包括外链或已签名 URL),并尽量“刷新一次签名”,刷新失败则回退到原 URL。
|
2025-12-26 07:52:40 +08:00
|
|
|
|
const parsed = parseStoragePath(fileUrl);
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
if (!forOnlyOffice) {
|
|
|
|
|
|
if (!parsed) {
|
|
|
|
|
|
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
|
|
|
|
|
}
|
2025-12-26 07:52:40 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const { bucket, path } = parsed;
|
|
|
|
|
|
const resolvedPath =
|
|
|
|
|
|
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
2025-12-26 07:52:40 +08:00
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
if (!resolvedPath) {
|
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
|
{ error: "存储对象不存在,无法生成签名链接" },
|
|
|
|
|
|
{ status: 404 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
|
|
|
|
|
resolvedPath,
|
|
|
|
|
|
60 * 60,
|
|
|
|
|
|
{ download: fileName },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (error || !data?.signedUrl) {
|
|
|
|
|
|
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
|
|
|
|
|
|
}
|
2025-12-26 07:52:40 +08:00
|
|
|
|
|
2026-01-11 12:35:53 +08:00
|
|
|
|
// 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000)
|
2026-01-15 20:54:21 +08:00
|
|
|
|
const signedUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
|
|
|
|
|
return NextResponse.json({ signedUrl });
|
2025-12-26 07:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
// ONLYOFFICE 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。
|
|
|
|
|
|
// 这里优先尝试重新生成 1 小时签名 URL;如果失败(例如策略限制/路径无法解析),则回退到原 URL。
|
|
|
|
|
|
let upstreamUrl = rewriteToPublicOrigin(fileUrl, runtimeCfg.supabaseUrl);
|
|
|
|
|
|
if (parsed) {
|
|
|
|
|
|
const { bucket, path } = parsed;
|
|
|
|
|
|
const resolvedPath =
|
|
|
|
|
|
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
|
|
|
|
|
if (!resolvedPath) {
|
|
|
|
|
|
return NextResponse.json(
|
|
|
|
|
|
{ error: "存储对象不存在,无法生成签名链接" },
|
|
|
|
|
|
{ status: 404 },
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
|
|
|
|
|
resolvedPath,
|
|
|
|
|
|
60 * 60,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!error && data?.signedUrl) {
|
|
|
|
|
|
upstreamUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 关键:Supabase Storage 的 signedUrl 自带 `?token=...`,ONLYOFFICE 会把 URL 上的
|
|
|
|
|
|
// `token` 参数当作自己的 JWT token 去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
|
|
|
|
|
//
|
|
|
|
|
|
// 解决:对 ONLYOFFICE 返回一个“代理 URL”,把真正的 URL 编码到 `u=...` 中,避免
|
|
|
|
|
|
// `token` 参数出现在 document.url 上。
|
|
|
|
|
|
const defaultOrigin = new URL(request.url).origin;
|
|
|
|
|
|
const proxyBase = (runtimeCfg.onlyofficeProxyOrigin || runtimeCfg.cloudflareAppOrigin || defaultOrigin).replace(/\/+$/, "");
|
|
|
|
|
|
const proxyUrl = new URL("/api/onlyoffice/proxy", proxyBase);
|
|
|
|
|
|
proxyUrl.searchParams.set("u", base64Url(upstreamUrl));
|
|
|
|
|
|
return NextResponse.json({ signedUrl: proxyUrl.toString() });
|
2025-12-26 07:52:40 +08:00
|
|
|
|
}
|