0.3.1 UI修复

This commit is contained in:
liaibo
2026-01-18 19:01:31 +08:00
parent 90a38b48cf
commit 8a5b915002
86 changed files with 1160 additions and 2867 deletions
@@ -1,10 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
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";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -29,7 +28,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
const items = await client.query(api.mediaAssets.listByWorkspace, {
userId: auth.userId,
workspaceId,
@@ -41,7 +40,9 @@ export async function GET(request: Request) {
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
}
const supabase = await createSupabaseRouteClient();
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
const supabase = null as any;
const {
data: { user },
error: authError,
@@ -130,7 +131,7 @@ export async function POST(request: Request) {
updated_at: now,
};
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
await client.mutation(api.mediaAssets.create, {
userId: auth.userId,
asset: {
@@ -151,7 +152,9 @@ export async function POST(request: Request) {
return NextResponse.json({ asset });
}
const supabase = await createSupabaseRouteClient();
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
const supabase = null as any;
const {
data: { user },
error: authError,
@@ -1,12 +1,11 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
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";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -84,7 +83,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
}
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
const assets = (await client.query(api.mediaAssets.listByIds, {
userId: auth.userId,
ids: payload.assetIds,
@@ -142,7 +141,6 @@ export async function POST(request: Request) {
}
const targetDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: payload.targetDocumentId,
});
@@ -213,6 +211,9 @@ export async function POST(request: Request) {
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -410,4 +411,5 @@ export async function POST(request: Request) {
const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 });
}
*/
}
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
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";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
userId: auth.userId,
workspaceId,
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, ...(res as any) });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -114,4 +116,5 @@ export async function POST(request: Request) {
}
return NextResponse.json({ success: true, updated: assetIds.length });
*/
}
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
@@ -9,6 +8,9 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -51,4 +53,5 @@ export async function POST(request: Request) {
}
return NextResponse.json({ ok: true });
*/
}
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
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";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
}
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
const res = await client.mutation(api.mediaAssets.purgeById, {
userId: auth.userId,
id: assetId,
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, ...(res as any) });
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -115,4 +117,5 @@ export async function POST(request: Request) {
}
return NextResponse.json({ success: true });
*/
}
+7 -159
View File
@@ -1,74 +1,19 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
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";
import { HttpError } from "@/lib/auth/authContext";
import { getAuthedConvexClient } from "@/lib/convex/route";
export const dynamic = "force-dynamic";
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;
}
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
let client;
try {
auth = await requireAuthContext();
const authed = await getAuthedConvexClient();
auth = authed.auth;
client = authed.client;
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -83,7 +28,6 @@ export async function GET(request: Request) {
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 });
@@ -106,101 +50,5 @@ export async function GET(request: Request) {
});
}
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 });
}
// 兼容:早期/异常写入的 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);
}
// 对于图片,添加高质量参数以获得更好的显示效果
// 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
.from(bucket)
.createSignedUrl(storagePath, 60 * 60, {
// 添加转换参数以获得高质量图片
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
.from(bucket)
.createSignedUrl(storagePath, 60 * 60);
if (signError || !signedUrlData) {
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
}
signedUrl = signedUrlData.signedUrl;
}
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
return NextResponse.json({
signedUrl,
asset: {
id: asset.id,
file_name: asset.file_name,
mime_type: asset.mime_type,
file_size: asset.file_size,
},
});
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -1,102 +1,11 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic";
const base64Url = (input: string) =>
Buffer.from(input)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
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;
}
};
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;
}
};
export async function GET(request: Request) {
if (isConvexEnabled()) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
try {
await requireAuthContext();
} catch (err) {
@@ -111,105 +20,12 @@ export async function GET(request: Request) {
if (!fileUrl) {
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
}
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
return NextResponse.json({ signedUrl: fileUrl });
}
// 说明:在 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 });
}
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 });
}
// 说明:
// - 非 ONLYOFFICE:必须能解析为 Supabase Storage 路径,便于生成带 download 的临时签名 URL。
// - ONLYOFFICE:允许传入任意可访问 URL(包括外链或已签名 URL),并尽量“刷新一次签名”,刷新失败则回退到原 URL。
const parsed = parseStoragePath(fileUrl);
if (!forOnlyOffice) {
if (!parsed) {
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
}
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,
{ download: fileName },
);
if (error || !data?.signedUrl) {
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
}
// 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000
const signedUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
return NextResponse.json({ signedUrl });
}
// ONLYOFFICE 需要“可被文档服务器拉取”的 URL:不要强制 downloadContent-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() });
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media";
import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
@@ -7,7 +6,7 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
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";
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic";
@@ -50,7 +49,7 @@ export async function POST(request: Request) {
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || "");
const client = getConvexHttpClient();
const client = await getConvexAuthedHttpClient();
// 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
@@ -101,6 +100,9 @@ export async function POST(request: Request) {
}
}
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient();
const {
data: { session },
@@ -190,4 +192,5 @@ export async function POST(request: Request) {
console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 });
}
*/
}