0.2 在线版本打通
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
// 说明:浏览器端需要走公网(FRP),但 Next 服务端在本机时应优先走内网/本机(HTTP),
|
||||
// 否则会遇到 FRP Auto HTTPS 的自签证书导致 Node fetch 失败。
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const backendUrl =
|
||||
process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
|
||||
if (!backendUrl) {
|
||||
return NextResponse.json({ status: "disabled" }, { status: 200 });
|
||||
}
|
||||
@@ -22,4 +27,3 @@ export async function GET() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { makeUniqueTitle } from "@/lib/file-tree/naming";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -42,7 +43,7 @@ type AssetRow = {
|
||||
storage_path: string | null;
|
||||
};
|
||||
|
||||
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
|
||||
@@ -6,8 +6,9 @@ import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
|
||||
@@ -2,12 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
interface DuplicatePayload {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
const folder = path.join(documentsBaseDir, id);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { extname } from "path";
|
||||
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -144,7 +145,7 @@ export async function POST(request: Request) {
|
||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
||||
let signedUrl = signed?.signedUrl ?? null;
|
||||
if (signedUrl) {
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
}
|
||||
const { error } = await supabase
|
||||
.from("media_assets")
|
||||
@@ -205,7 +206,7 @@ export async function POST(request: Request) {
|
||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
||||
let signedUrl = signed?.signedUrl ?? null;
|
||||
if (signedUrl) {
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
}
|
||||
const { data: inserted, error } = await supabase
|
||||
.from("media_assets")
|
||||
@@ -232,7 +233,7 @@ export async function POST(request: Request) {
|
||||
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
|
||||
let signedUrl = signed?.signedUrl ?? null;
|
||||
if (signedUrl) {
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
}
|
||||
const { data: updated, error } = await supabase
|
||||
.from("media_assets")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
@@ -27,7 +28,10 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL ?? process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||
// 说明:同 /api/backend/health,优先走内网/本机后端,避免 Node fetch 被 FRP 自签证书拦截。
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const backendUrl =
|
||||
process.env.BACKEND_INTERNAL_URL || process.env.BACKEND_URL || cfg.backendUrl;
|
||||
if (backendUrl) {
|
||||
void fetch(`${backendUrl}/api/v1/tasks/media-ocr`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
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";
|
||||
|
||||
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) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
@@ -33,6 +89,30 @@ export async function GET(request: Request) {
|
||||
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/");
|
||||
@@ -42,8 +122,8 @@ export async function GET(request: Request) {
|
||||
// 使用 createSignedUrl 并添加高质量参数
|
||||
// 注意:transform 参数需要在签名时指定
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(asset.bucket)
|
||||
.createSignedUrl(asset.storage_path, 60 * 60, {
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60, {
|
||||
// 添加转换参数以获得高质量图片
|
||||
transform: {
|
||||
quality: 95, // 高质量
|
||||
@@ -58,8 +138,8 @@ export async function GET(request: Request) {
|
||||
} else {
|
||||
// 非图片文件,直接生成签名 URL
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(asset.bucket)
|
||||
.createSignedUrl(asset.storage_path, 60 * 60);
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60);
|
||||
|
||||
if (signError || !signedUrlData) {
|
||||
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
||||
@@ -67,7 +147,7 @@ export async function GET(request: Request) {
|
||||
signedUrl = signedUrlData.signedUrl;
|
||||
}
|
||||
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const applyHostOverride = (rawUrl: string, hostOverride: string) => {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
|
||||
// 支持两种写法:hostname 或完整 origin(https://xxx:port)
|
||||
if (/^https?:\/\//i.test(hostOverride)) {
|
||||
const ov = new URL(hostOverride);
|
||||
u.protocol = ov.protocol;
|
||||
u.host = ov.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
u.hostname = hostOverride;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
};
|
||||
const base64Url = (input: string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const parseStoragePath = (fileUrl: string) => {
|
||||
try {
|
||||
@@ -47,12 +37,80 @@ const parseStoragePath = (fileUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
// 说明:在 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";
|
||||
const hostOverride = process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE;
|
||||
if (searchParams.get("debug") === "1") {
|
||||
return NextResponse.json({
|
||||
keyLen: (process.env.SUPABASE_SERVICE_ROLE_KEY || "").length,
|
||||
@@ -64,30 +122,72 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - 非 ONLYOFFICE:必须能解析为 Supabase Storage 路径,便于生成带 download 的临时签名 URL。
|
||||
// - ONLYOFFICE:允许传入任意可访问 URL(包括外链或已签名 URL),并尽量“刷新一次签名”,刷新失败则回退到原 URL。
|
||||
const parsed = parseStoragePath(fileUrl);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { bucket, path } = parsed;
|
||||
if (!forOnlyOffice) {
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
// OnlyOffice 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。
|
||||
const { data, error } = forOnlyOffice
|
||||
? await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60)
|
||||
: await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60, { download: fileName });
|
||||
const { bucket, path } = parsed;
|
||||
const resolvedPath =
|
||||
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
let signedUrl = data.signedUrl;
|
||||
if (forOnlyOffice && hostOverride) {
|
||||
// 优先按 OnlyOffice 专用回源地址改写(可设置为 http://host.docker.internal:18000 以降低延迟)
|
||||
signedUrl = applyHostOverride(signedUrl, hostOverride);
|
||||
} else {
|
||||
// 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000)
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
const signedUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
||||
return NextResponse.json({ signedUrl });
|
||||
}
|
||||
|
||||
return NextResponse.json({ signedUrl });
|
||||
// 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() });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -72,7 +73,7 @@ export async function POST(request: Request) {
|
||||
signedUrl = signed?.signedUrl ?? "";
|
||||
}
|
||||
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
|
||||
const { data: asset, error } = await supabase
|
||||
.from("media_assets")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { detectLocalMindmapFiles } from "@/lib/mindmap-files";
|
||||
import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -116,4 +116,3 @@ export async function GET(request: Request) {
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -803,7 +804,7 @@ const safeFetchableUrl = (raw: string) => {
|
||||
const host = u.hostname;
|
||||
// 基础防护:只允许本机或 supabase(同域/存储域名变化时可扩展)
|
||||
if (host === "127.0.0.1" || host === "localhost") return u.toString();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const supabaseUrl = getMnoteRuntimeConfig().supabaseUrl ?? "";
|
||||
if (supabaseUrl) {
|
||||
const supaHost = new URL(supabaseUrl).hostname;
|
||||
if (host === supaHost) return u.toString();
|
||||
|
||||
@@ -2,11 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
@@ -77,4 +78,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true, removed });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -10,8 +11,8 @@ const defaultMindmapData = {
|
||||
|
||||
// 新版:与页面文件夹(index.md 所在处)对齐,放在 public/documents/<docId>/mindmap.json
|
||||
// 旧版遗留:public/mindmaps/<docId>/mindmap.json
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
|
||||
type OnlyOfficeCallbackBody = {
|
||||
status?: number;
|
||||
url?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
|
||||
// 说明:如果回传的是通过 /onlyoffice-server 访问的地址,
|
||||
// 服务端下载时优先改写为本机 ONLYOFFICE_INTERNAL_URL(避免绕公网/证书问题)。
|
||||
const prefix = "/onlyoffice-server";
|
||||
if (u.pathname.startsWith(prefix)) {
|
||||
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
|
||||
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
const status = Number(body.status ?? -1);
|
||||
// 说明:仅在文档需要保存时处理(2=ready for saving;6=force save)。
|
||||
if (status !== 2 && status !== 6) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
if (!assetId) {
|
||||
// 说明:缺少 assetId 无法定位存储路径,返回非 0 让 ONLYOFFICE 显示保存失败。
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
if (!body.url) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabaseAdmin
|
||||
.from("media_assets")
|
||||
.select("id,bucket,storage_path,mime_type")
|
||||
.eq("id", assetId)
|
||||
.single();
|
||||
|
||||
if (assetError || !asset) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
const { error: uploadError } = await supabaseAdmin.storage
|
||||
.from(asset.bucket)
|
||||
.upload(asset.storage_path, buf, {
|
||||
upsert: true,
|
||||
contentType: asset.mime_type || "application/octet-stream",
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const isPrivateIpv4 = (hostname: string) => {
|
||||
if (isIP(hostname) !== 4) return false;
|
||||
const parts = hostname.split(".").map((v) => Number(v));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
||||
const [a, b] = parts;
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 127.0.0.0/8
|
||||
if (a === 127) return true;
|
||||
// 169.254.0.0/16
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryParseOriginHost = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
const u = new URL(value);
|
||||
return { hostname: u.hostname, port: u.port || "" };
|
||||
}
|
||||
return { hostname: value, port: "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const decodeBase64UrlToUtf8 = (input: string) => {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
||||
const padded = normalized + "=".repeat(padLen);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
};
|
||||
|
||||
const tryParseOriginUrl = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) return new URL(value);
|
||||
// 说明:仅给 host:port 的写法一个默认协议(http)
|
||||
return new URL(`http://${value}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const encoded = searchParams.get("u");
|
||||
|
||||
if (!encoded) {
|
||||
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
||||
}
|
||||
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
return NextResponse.json({ error: "仅支持 http/https URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 安全:避免把该接口变成通用 SSRF 代理。
|
||||
// 当前仅用于代理 Supabase Storage 的签名 URL(以及可能的 storage 回源 host override)。
|
||||
const supa = tryParseOriginHost(runtimeCfg.supabaseUrl);
|
||||
const supaInternalOrigin = tryParseOriginUrl(
|
||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
||||
);
|
||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
||||
|
||||
const isSupabasePath =
|
||||
target.pathname.startsWith("/storage/v1/") ||
|
||||
target.pathname.startsWith("/auth/v1/") ||
|
||||
target.pathname.startsWith("/rest/v1/") ||
|
||||
target.pathname.startsWith("/functions/v1/") ||
|
||||
target.pathname.startsWith("/realtime/v1/");
|
||||
|
||||
// 说明:历史数据里可能残留旧的 Supabase 域名(例如 supabase.aichem.dpdns.org)。
|
||||
// 为了保证 ONLYOFFICE 拉取文件稳定,这里统一把 Supabase 典型路径的 host 改为当前配置的 Supabase,
|
||||
// 并优先使用“内部 HTTP”回源,避免 Node fetch 因自签证书失败。
|
||||
if (isSupabasePath) {
|
||||
if (supaInternalOrigin) {
|
||||
target.protocol = supaInternalOrigin.protocol;
|
||||
target.host = supaInternalOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
} else {
|
||||
const supaPublicOrigin = tryParseOriginUrl(runtimeCfg.supabaseUrl);
|
||||
if (supaPublicOrigin) {
|
||||
target.protocol = supaPublicOrigin.protocol;
|
||||
target.host = supaPublicOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedHostnames = new Set<string>();
|
||||
const allowedPortsByHostname = new Map<string, Set<string>>();
|
||||
|
||||
const addAllowed = (hostname: string, port: string) => {
|
||||
allowedHostnames.add(hostname);
|
||||
if (!allowedPortsByHostname.has(hostname)) allowedPortsByHostname.set(hostname, new Set<string>());
|
||||
if (port) allowedPortsByHostname.get(hostname)!.add(port);
|
||||
};
|
||||
|
||||
if (supa?.hostname) {
|
||||
addAllowed(supa.hostname, supa.port);
|
||||
// 如果 Supabase 配置就是本地/内网回源(例如 host.docker.internal/127.0.0.1),则允许这些 hostname,
|
||||
// 但仍然限制端口只能是 Supabase URL 的端口,避免误用。
|
||||
if (isLocalHostname(supa.hostname)) {
|
||||
addAllowed("127.0.0.1", supa.port);
|
||||
addAllowed("localhost", supa.port);
|
||||
addAllowed("host.docker.internal", supa.port);
|
||||
}
|
||||
}
|
||||
|
||||
if (supaInternalOrigin?.hostname) {
|
||||
addAllowed(supaInternalOrigin.hostname, supaInternalOrigin.port || "");
|
||||
if (isLocalHostname(supaInternalOrigin.hostname)) {
|
||||
addAllowed("127.0.0.1", supaInternalOrigin.port || "");
|
||||
addAllowed("localhost", supaInternalOrigin.port || "");
|
||||
addAllowed("host.docker.internal", supaInternalOrigin.port || "");
|
||||
}
|
||||
}
|
||||
|
||||
if (storageOverride?.hostname) {
|
||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
||||
}
|
||||
|
||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (allowedHostnames.size > 0 && !allowedHostnames.has(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止代理到非允许的主机" }, { status: 403 });
|
||||
}
|
||||
|
||||
const allowedPorts = allowedPortsByHostname.get(target.hostname);
|
||||
if (allowedPorts && allowedPorts.size > 0) {
|
||||
const port = target.port || (target.protocol === "https:" ? "443" : "80");
|
||||
if (!allowedPorts.has(port)) {
|
||||
return NextResponse.json({ error: "禁止代理到该端口" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - Supabase Storage 的 signedUrl 会携带 `?token=...`,而 ONLYOFFICE 会把它当作
|
||||
// 自己的 JWT token 参数去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
||||
// - 这里用反向代理把 signedUrl 藏在 `u=...` 里(参数名不叫 token),ONLYOFFICE 拉取的
|
||||
// URL 就不会出现 `token` 参数,从而避免冲突。
|
||||
const forwardHeaders = new Headers();
|
||||
const range = request.headers.get("range");
|
||||
if (range) forwardHeaders.set("range", range);
|
||||
|
||||
// 兼容部分 Supabase/Kong 配置:某些环境下下载 Storage 资源仍要求 apikey header。
|
||||
if (
|
||||
runtimeCfg.supabaseAnonKey &&
|
||||
((supa?.hostname && target.hostname === supa.hostname) ||
|
||||
(supaInternalOrigin?.hostname && target.hostname === supaInternalOrigin.hostname))
|
||||
) {
|
||||
forwardHeaders.set("apikey", runtimeCfg.supabaseAnonKey);
|
||||
}
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: forwardHeaders,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const headers = new Headers(upstream.headers);
|
||||
// 避免把上游 cookie 透传给文档服务器
|
||||
headers.delete("set-cookie");
|
||||
|
||||
// 说明:ONLYOFFICE 可能会对 document.url 发起 HEAD 预检(获取 content-length/etag)。
|
||||
// 若不支持 HEAD,会导致编辑器报 “下载失败”。
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return handle(request, "GET");
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request) {
|
||||
return handle(request, "HEAD");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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) {
|
||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
||||
const body = (await request.json().catch(() => null)) as { config?: any } | null;
|
||||
if (!body?.config) {
|
||||
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
||||
}
|
||||
if (!secret) {
|
||||
// 兼容:如果 ONLYOFFICE 没开启 JWT,可以不需要 token。
|
||||
return NextResponse.json({ token: null, documentToken: null, editorConfigToken: null });
|
||||
}
|
||||
|
||||
const config = body.config;
|
||||
const token = signHs256(config, secret);
|
||||
const documentToken = config?.document ? signHs256(config.document, secret) : null;
|
||||
const editorConfigToken = config?.editorConfig ? signHs256(config.editorConfig, secret) : null;
|
||||
|
||||
return NextResponse.json({ token, documentToken, editorConfigToken });
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
detectLocalMindmapDocs,
|
||||
detectLocalMindmapImageAssetIdsByMindmapId,
|
||||
detectLocalTrashedMindmapAssets,
|
||||
} from "@/lib/mindmap-files";
|
||||
} from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
Reference in New Issue
Block a user