2026-01-10 23:08:56 +08:00
|
|
|
|
import { NextResponse } from "next/server";
|
|
|
|
|
|
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";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
|
|
|
|
|
import { getConvexHttpClient } from "@/lib/convex/server";
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
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 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");
|
|
|
|
|
|
|
|
|
|
|
|
// 1) 若已给出 bucket/path,先校验是否存在
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2) 兼容历史数据:按 workspace_id/%/file_name 查找
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
export async function GET(request: Request) {
|
2026-01-17 10:12:53 +08:00
|
|
|
|
if (isConvexEnabled()) {
|
|
|
|
|
|
let auth;
|
|
|
|
|
|
try {
|
2026-01-18 05:13:53 +08:00
|
|
|
|
auth = await requireAuthContext();
|
2026-01-17 10:12:53 +08:00
|
|
|
|
} 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 = searchParams.get("assetId");
|
|
|
|
|
|
|
|
|
|
|
|
if (!assetId) {
|
|
|
|
|
|
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const client = getConvexHttpClient();
|
|
|
|
|
|
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
|
|
|
|
|
|
if (!asset) {
|
|
|
|
|
|
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId });
|
|
|
|
|
|
const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null;
|
|
|
|
|
|
if (!signedUrl) {
|
|
|
|
|
|
return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
|
signedUrl,
|
|
|
|
|
|
asset: {
|
|
|
|
|
|
id: asset.id,
|
|
|
|
|
|
file_name: asset.file_name,
|
|
|
|
|
|
mime_type: asset.mime_type,
|
|
|
|
|
|
file_size: asset.file_size,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const supabase = await createSupabaseRouteClient();
|
|
|
|
|
|
const {
|
|
|
|
|
|
data: { user },
|
|
|
|
|
|
error: authError,
|
|
|
|
|
|
} = await supabase.auth.getUser();
|
|
|
|
|
|
|
|
|
|
|
|
if (authError || !user) {
|
|
|
|
|
|
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
|
|
|
|
const assetId = searchParams.get("assetId");
|
|
|
|
|
|
|
|
|
|
|
|
if (!assetId) {
|
|
|
|
|
|
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取资源信息
|
|
|
|
|
|
const { data: asset, error: assetError } = await supabase
|
|
|
|
|
|
.from("media_assets")
|
|
|
|
|
|
.select("*")
|
|
|
|
|
|
.eq("id", assetId)
|
|
|
|
|
|
.single();
|
|
|
|
|
|
|
|
|
|
|
|
if (assetError || !asset) {
|
|
|
|
|
|
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
// 兼容:早期/异常写入的 media_assets 可能缺少 bucket/storage_path,
|
|
|
|
|
|
// 导致生成的签名链接指向不存在的对象(ONLYOFFICE 会报“下载失败”)。
|
|
|
|
|
|
let bucket = (asset.bucket as string | null) ?? null;
|
|
|
|
|
|
let storagePath = (asset.storage_path as string | null) ?? null;
|
|
|
|
|
|
if (!bucket || !storagePath) {
|
|
|
|
|
|
const resolved = await resolveAssetObjectLocation({
|
|
|
|
|
|
bucket,
|
|
|
|
|
|
storagePath,
|
|
|
|
|
|
workspaceId: asset.workspace_id ?? null,
|
|
|
|
|
|
fileName: asset.file_name ?? null,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
|
return NextResponse.json({ error: "未找到存储对象,无法生成签名链接" }, { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
bucket = resolved.bucket;
|
|
|
|
|
|
storagePath = resolved.path;
|
|
|
|
|
|
|
|
|
|
|
|
// 尽量回填,避免后续重复修复
|
|
|
|
|
|
await supabase
|
|
|
|
|
|
.from("media_assets")
|
|
|
|
|
|
.update({ bucket, storage_path: storagePath })
|
|
|
|
|
|
.eq("id", asset.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 对于图片,添加高质量参数以获得更好的显示效果
|
|
|
|
|
|
// Supabase Storage 支持通过 URL 参数控制图片质量和尺寸
|
|
|
|
|
|
const isImage = asset.mime_type?.startsWith("image/");
|
|
|
|
|
|
let signedUrl: string;
|
|
|
|
|
|
|
|
|
|
|
|
if (isImage) {
|
|
|
|
|
|
// 使用 createSignedUrl 并添加高质量参数
|
|
|
|
|
|
// 注意:transform 参数需要在签名时指定
|
|
|
|
|
|
const { data: signedUrlData, error: signError } = await supabase.storage
|
2026-01-15 20:54:21 +08:00
|
|
|
|
.from(bucket)
|
|
|
|
|
|
.createSignedUrl(storagePath, 60 * 60, {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 添加转换参数以获得高质量图片
|
|
|
|
|
|
transform: {
|
|
|
|
|
|
quality: 95, // 高质量
|
|
|
|
|
|
format: "origin", // 保持原始格式
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (signError || !signedUrlData) {
|
|
|
|
|
|
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
|
|
|
|
|
}
|
|
|
|
|
|
signedUrl = signedUrlData.signedUrl;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 非图片文件,直接生成签名 URL
|
|
|
|
|
|
const { data: signedUrlData, error: signError } = await supabase.storage
|
2026-01-15 20:54:21 +08:00
|
|
|
|
.from(bucket)
|
|
|
|
|
|
.createSignedUrl(storagePath, 60 * 60);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
if (signError || !signedUrlData) {
|
|
|
|
|
|
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
|
|
|
|
|
}
|
|
|
|
|
|
signedUrl = signedUrlData.signedUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:54:21 +08:00
|
|
|
|
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
return NextResponse.json({
|
|
|
|
|
|
signedUrl,
|
|
|
|
|
|
asset: {
|
|
|
|
|
|
id: asset.id,
|
|
|
|
|
|
file_name: asset.file_name,
|
|
|
|
|
|
mime_type: asset.mime_type,
|
|
|
|
|
|
file_size: asset.file_size,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|