0.2 在线版本打通

This commit is contained in:
liaibo
2026-01-15 20:54:21 +08:00
parent 725a60d3aa
commit 94957dc361
1596 changed files with 153254 additions and 309 deletions
+15
View File
@@ -1,6 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// 供桌面端打包使用(Electron 内置 Next server.js + 最小依赖)。
// 说明:`pnpm run build:desktop:next` 会依赖该产物。
output: "standalone",
turbopack: {
// 避免 monorepo/多 lockfile 场景下 root 误判,减少构建与热更新的不确定性
root: __dirname,
@@ -9,6 +12,18 @@ const nextConfig: NextConfig = {
// 说明:ONLYOFFICE 文档服务器会在其 iframe 内跨域拉取插件 manifest/js/html。
// 这里对插件资源放开 CORS,避免 pluginsData 加载失败。
return [
// 说明:远程通过 Cloudflare Tunnel 访问时,Next.jsTurbopack dev)默认对
// `/_next/static/chunks/*` 返回 `no-store`,导致 Cloudflare 无法缓存大体积
// bundle,网络稍慢时会出现页面长期停留在“正在载入编辑器...”的现象。
// 这些资源带 hash 文件名,设置 immutable 不会导致更新不一致。
{
source: "/_next/static/chunks/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
{
source: "/_next/static/media/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
{
source: "/onlyoffice/plugins/:path*",
headers: [
+14
View File
@@ -0,0 +1,14 @@
{
"cloudflareAppOrigin": "https://frp-dry.com:16630",
"supabaseUrl": "https://frp-dry.com:38877",
"supabaseInternalUrl": "http://127.0.0.1:18000",
"supabaseAnonKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlLWRldiIsImlhdCI6MTc2NDExNTA0OSwiZXhwIjoyMDc5NDc1MDQ5fQ.18ohcQZXVkoR1TIF56QWJxHyoVnA9aarH-XfTyBJn1Y",
"backendUrl": "https://frp-dry.com:44399",
"onlyofficeBaseUrlWeb": "https://frp-dry.com:16630/onlyoffice-server",
"onlyofficeBaseUrlDesktop": "http://localhost:8081",
"onlyofficeStorageHostOverrideWeb": "",
"onlyofficeStorageHostOverrideDesktop": "",
"onlyofficeProxyOriginWeb": "http://172.31.224.1:3000",
"onlyofficeCallbackOriginWeb": "http://172.31.224.1:3000",
"onlyofficeCallbackOriginDesktop": "http://127.0.0.1:3000"
}
@@ -0,0 +1,43 @@
import { promises as fs } from "fs";
import path from "path";
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
export const dynamic = "force-dynamic";
function contentTypeByExt(file: string): string {
const ext = path.extname(file).toLowerCase();
if (ext === ".md") return "text/markdown; charset=utf-8";
if (ext === ".json") return "application/json; charset=utf-8";
if (ext === ".txt") return "text/plain; charset=utf-8";
return "application/octet-stream";
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string; filePath: string[] }> },
) {
const { id, filePath } = await params;
if (!id || !Array.isArray(filePath) || filePath.length === 0) {
return new Response("Not Found", { status: 404 });
}
const baseDir = path.resolve(getDocumentsBaseDir(), id);
const resolved = path.resolve(baseDir, ...filePath);
if (!resolved.startsWith(baseDir + path.sep)) {
return new Response("Bad Request", { status: 400 });
}
try {
const buf = await fs.readFile(resolved);
return new Response(buf, {
headers: {
"Content-Type": contentTypeByExt(resolved),
"Cache-Control": "no-store",
},
});
} catch {
return new Response("Not Found", { status: 404 });
}
}
@@ -53,15 +53,19 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
};
return (
<DocumentShell
documentId={document.id}
workspaceId={document.workspace_id}
title={document.title}
updatedAt={document.updated_at}
initialContent={null}
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId}
/>
<div className="flex h-screen flex-col">
<div className="min-h-0 flex-1">
<DocumentShell
documentId={document.id}
workspaceId={document.workspace_id}
title={document.title}
updatedAt={document.updated_at}
initialContent={null}
initialOptions={initialOptions}
initialStats={initialStats}
openTableId={openTableId}
/>
</div>
</div>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspace
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
import type { SidebarInitialData } from "@/components/sidebar/types";
import { SearchPalette } from "@/components/search/search-palette";
import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/mindmap-files";
import { detectLocalMindmapDocs, detectLocalTrashedMindmapAssets } from "@/lib/server/mindmap-files";
export default async function AppLayout({ children }: { children: ReactNode }) {
const supabase = await createSupabaseServerClient();
+33 -6
View File
@@ -2,7 +2,7 @@
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabase/client";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -19,22 +19,49 @@ export default function LoginPage() {
const handleLogin = useCallback(async () => {
setLoading(true);
setMessage("");
const { data, error } = await supabaseBrowser.auth.signInWithPassword({
email,
password,
});
const supabaseBrowser = getSupabaseBrowserClient();
let data: unknown = null;
let error: { message?: string } | null = null;
try {
const result = await supabaseBrowser.auth.signInWithPassword({
email,
password,
});
data = result.data;
error = result.error as unknown as { message?: string } | null;
} catch (err) {
const runtimeCfg = (() => {
try {
return (window as unknown as { __MNOTE_RUNTIME_CONFIG__?: { supabaseUrl?: string } }).__MNOTE_RUNTIME_CONFIG__;
} catch {
return undefined;
}
})();
const supabaseUrlHint = runtimeCfg?.supabaseUrl ? `Supabase${runtimeCfg.supabaseUrl}` : "";
setLoading(false);
setMessage(`登录失败:网络或证书异常 ${supabaseUrlHint}\n${String(err)}`);
return;
}
setLoading(false);
if (error) {
setMessage(`登录失败:${error.message}`);
return;
}
setMessage("登录成功,准备跳转...");
setSessionInfo(JSON.stringify(data.session, null, 2));
const session = (data as { session?: unknown } | null)?.session ?? null;
setSessionInfo(JSON.stringify(session, null, 2));
// 说明:曾出现“需要二次登录”的现象(服务端仍读到旧 cookie/session)。
// 这里用整页跳转强制刷新,确保 App Router 的服务端组件读取到最新会话。
if (typeof window !== "undefined") {
window.location.href = "/";
return;
}
router.replace("/");
}, [email, password, router]);
const handleGetSession = useCallback(async () => {
setLoading(true);
const supabaseBrowser = getSupabaseBrowserClient();
const {
data: { session },
error,
@@ -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",
+85 -5
View File
@@ -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 或完整 originhttps://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:不要强制 downloadContent-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:不要强制 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() });
}
@@ -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 saving6=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 });
}
+1 -1
View File
@@ -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";
+86
View File
@@ -0,0 +1,86 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// 说明:ONLYOFFICE 文档服务器在编辑过程中会从 `/cache/*` 拉取二进制缓存(例如 Editor.bin)。
// 当我们通过 `/onlyoffice-server/*` 反代文档服务器时,这些 `/cache/*` 请求会落到 Next 上,
// 若未额外反代,会导致 404,进而触发 ONLYOFFICE “下载失败(-4)/无法打开文档”。
// 因此这里把 `/cache/*` 同样反代到本机 ONLYOFFICE。
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(
/\/+$/,
"",
);
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:Hop-by-hop headers 不应被代理转发/透传
const hopByHop = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
for (const key of hopByHop) {
headers.delete(key);
}
};
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
const incomingUrl = new URL(request.url);
const target = new URL(
`${ONLYOFFICE_INTERNAL_URL}/cache/${(pathParts ?? []).map(encodeURIComponent).join("/")}`,
);
target.search = incomingUrl.search;
const headers = new Headers(request.headers);
headers.delete("host");
// 说明:避免上游 gzip 后被自动解压但仍带 content-encoding,导致浏览器二次解压错误
headers.set("accept-encoding", "identity");
stripHopByHopHeaders(headers);
const method = request.method.toUpperCase();
const hasBody = !["GET", "HEAD"].includes(method);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const init: any = {
method,
headers,
body: hasBody ? request.body : undefined,
duplex: "half",
redirect: "follow",
};
const upstream = await fetch(target, init);
const outHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(outHeaders);
outHeaders.delete("content-encoding");
outHeaders.delete("content-length");
return new NextResponse(upstream.body, {
status: upstream.status,
headers: outHeaders,
});
};
type RouteCtx = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxyCache(request, path ?? []);
}
export async function HEAD(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxyCache(request, path ?? []);
}
export async function OPTIONS(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxyCache(request, path ?? []);
}
+22 -4
View File
@@ -4,6 +4,7 @@ import "./globals.css";
import { SupabaseProvider } from "@/components/providers/supabase-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const inter = Inter({
subsets: ["latin"],
@@ -20,13 +21,30 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
const isDesktop = process.env.MNOTE_DESKTOP === "1";
let session = null;
try {
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
} catch {
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
session = null;
}
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop };
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
return (
<html lang="zh-CN">
<head>
<script
// 说明:桌面端需要“运行期可覆盖”的配置(避免 build 时把 NEXT_PUBLIC_* 写死)。
// 这里将服务端读取到的配置注入到 window,客户端再优先读取它。
dangerouslySetInnerHTML={{
__html: `window.__MNOTE_RUNTIME_CONFIG__=${runtimeConfigJson};`,
}}
/>
</head>
<body className={`${inter.variable} antialiased`}>
<SupabaseProvider session={session}>
<QueryProvider>{children}</QueryProvider>
@@ -0,0 +1,37 @@
import { promises as fs } from "fs";
import path from "path";
import { getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
export const dynamic = "force-dynamic";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ docId: string; filePath: string[] }> },
) {
const { docId, filePath } = await params;
if (!docId || !Array.isArray(filePath) || filePath.length === 0) {
return new Response("Not Found", { status: 404 });
}
const baseDir = path.resolve(getLegacyMindmapsBaseDir(), docId);
const resolved = path.resolve(baseDir, ...filePath);
if (!resolved.startsWith(baseDir + path.sep)) {
return new Response("Bad Request", { status: 400 });
}
try {
const buf = await fs.readFile(resolved);
const ext = path.extname(resolved).toLowerCase();
const contentType =
ext === ".json"
? "application/json; charset=utf-8"
: "application/octet-stream";
return new Response(buf, {
headers: { "Content-Type": contentType, "Cache-Control": "no-store" },
});
} catch {
return new Response("Not Found", { status: 404 });
}
}
@@ -0,0 +1,142 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
const DISABLE_SERVICE_WORKER_SNIPPET = `
<script>
// 说明:在部分隧道/证书环境下,浏览器会阻止注册 ServiceWorker(报 SecurityError/SSL 证书错误)。
// ONLYOFFICE 默认会尝试注册 document_editor_service_worker.js 用于静态资源缓存,失败后会在控制台打印错误,
// 甚至可能触发上层框架的全局错误捕获导致白屏。
// 这里在编辑器 HTML 里提前兜底,把 register 改成“静默成功”,不影响编辑器核心功能。
window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
(function () {
try {
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) return;
var fakeReg = {
scope: location.origin + "/",
update: function () { return Promise.resolve(); },
unregister: function () { return Promise.resolve(true); }
};
var sw = navigator.serviceWorker;
var noopRegister = function () { return Promise.resolve(fakeReg); };
try { sw.register = noopRegister; } catch (e) {}
try {
var proto = Object.getPrototypeOf(sw);
if (proto && proto.register) proto.register = noopRegister;
} catch (e) {}
} catch (e) {}
})();
</script>
`.trim();
const stripHopByHopHeaders = (headers: Headers) => {
// 说明:Hop-by-hop headers 不应被代理转发/透传
const hopByHop = [
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
for (const key of hopByHop) {
headers.delete(key);
}
};
const injectDisableServiceWorker = (html: string) => {
// 说明:只注入一次,避免重复拼接
if (html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__")) return html;
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${DISABLE_SERVICE_WORKER_SNIPPET}\n`);
};
const proxy = async (request: NextRequest, pathParts: string[]) => {
const incomingUrl = new URL(request.url);
const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`);
target.search = incomingUrl.search;
const headers = new Headers(request.headers);
// 说明:避免把外部 Host 传给上游
headers.delete("host");
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
headers.set("accept-encoding", "identity");
stripHopByHopHeaders(headers);
const method = request.method.toUpperCase();
const hasBody = !["GET", "HEAD"].includes(method);
// 说明:在 Node 的 fetchUndici)里,ReadableStream body 需要设置 duplex: "half"。
// 但 TS 的 RequestInit 类型不包含 duplex,这里用 any 兜底。
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const init: any = {
method,
headers,
body: hasBody ? request.body : undefined,
duplex: "half",
redirect: "follow",
};
const upstream = await fetch(target, init);
const outHeaders = new Headers(upstream.headers);
stripHopByHopHeaders(outHeaders);
outHeaders.delete("content-encoding");
outHeaders.delete("content-length");
const contentType = upstream.headers.get("content-type") || "";
if (contentType.includes("text/html")) {
const html = await upstream.text();
const injected = injectDisableServiceWorker(html);
return new NextResponse(injected, {
status: upstream.status,
headers: outHeaders,
});
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: outHeaders,
});
};
type RouteCtx = { params: Promise<{ path: string[] }> };
export async function GET(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function POST(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function PUT(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function PATCH(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function DELETE(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function HEAD(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
export async function OPTIONS(request: NextRequest, ctx: RouteCtx) {
const { path } = await ctx.params;
return proxy(request, path ?? []);
}
@@ -0,0 +1,491 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
type EditorMode = "view" | "edit";
const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}";
declare global {
interface Window {
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
}
}
const setupOnlyOfficeGlobalErrorCapture = () => {
if (typeof window === "undefined") return;
if (window.__MNOTE_ONLYOFFICE_ERR_HOOKED__) return;
window.__MNOTE_ONLYOFFICE_ERR_HOOKED__ = true;
const push = (payload: Record<string, unknown>) => {
try {
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
window.__MNOTE_ONLYOFFICE_ERRLOG__.push(payload);
// 说明:写入 localStorage,便于“发生崩溃导致整页 reload”时仍能回溯最近错误。
window.localStorage.setItem("mnote_onlyoffice_last_error", JSON.stringify(payload));
} catch {
// ignore
}
};
window.addEventListener(
"error",
(e) => {
try {
push({
kind: "error",
message: String((e as ErrorEvent).message || ""),
filename: String((e as ErrorEvent).filename || ""),
lineno: Number((e as ErrorEvent).lineno || 0),
colno: Number((e as ErrorEvent).colno || 0),
name: String(((e as any)?.error as any)?.name || ""),
});
} catch {
// ignore
}
},
true,
);
window.addEventListener(
"unhandledrejection",
(e) => {
try {
const r = (e as PromiseRejectionEvent).reason as any;
push({
kind: "rejection",
message: String(r?.message ?? r ?? ""),
name: String(r?.name ?? ""),
});
} catch {
// ignore
}
},
true,
);
// 说明:ONLYOFFICE 内部偶发触发 removeChild 的 NotFoundError(不同环境下 message 可能为空)。
// 该异常会导致 Next.js 直接显示“客户端异常”白屏,因此这里在 ONLYOFFICE 页面内对 removeChild 做兜底补丁。
// 仅在 /onlyoffice 页面生效,不影响其它页面。
if (!window.__MNOTE_ONLYOFFICE_DOMPATCHED__) {
window.__MNOTE_ONLYOFFICE_DOMPATCHED__ = true;
try {
const orig = Node.prototype.removeChild;
// eslint-disable-next-line no-extend-native
(Node.prototype as any).removeChild = function removeChildPatched<T extends Node>(child: T): T {
try {
return orig.call(this, child) as T;
} catch (e) {
const name = (e as any)?.name ? String((e as any).name) : "";
if (name === "NotFoundError") {
return child;
}
throw e;
}
};
} catch {
// ignore
}
}
};
setupOnlyOfficeGlobalErrorCapture();
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
const hashKey = (input: string) => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString();
};
const base64UrlEncodeUtf8 = (input: string) => {
// 说明:浏览器端 base64urlUTF-8)编码,用于把带 `?token=...` 的 URL 藏到 `u=...` 里。
const bytes = new TextEncoder().encode(input);
let binary = "";
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] as number);
}
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
};
const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"];
const pdf = ["pdf"];
// 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
if (word.includes(ext)) return "word";
if (slide.includes(ext)) return "slide";
if (sheet.includes(ext)) return "cell";
if (pdf.includes(ext)) return "pdf";
return "word";
};
const waitForDocEditorReady = async (timeoutMs = 120_000) => {
const start = Date.now();
// eslint-disable-next-line no-constant-condition
while (true) {
// @ts-expect-error ONLYOFFICE 全局对象
const ok = Boolean(window.DocsAPI && window.DocsAPI.DocEditor);
if (ok) return;
if (Date.now() - start > timeoutMs) {
throw new Error("等待 ONLYOFFICE DocEditor 初始化超时");
}
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => setTimeout(r, 250));
}
};
export default function OnlyOfficePage() {
const params = useSearchParams();
const fileUrl = params.get("fileUrl") ?? "";
const fileName = params.get("fileName") ?? "未命名文档";
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const [error, setError] = useState<string | null>(null);
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
useEffect(() => {
// 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild)
// 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。
// 这里仅对该特定错误做兜底拦截,避免误伤其它真实错误。
const onError = (event: ErrorEvent) => {
const err = event.error as unknown;
const msgFromError = (() => {
try {
return typeof err === "string" ? err : String((err as any)?.message ?? err);
} catch {
return "";
}
})();
const msg = msgFromError || event.message || "";
const name = (err as any)?.name ? String((err as any).name) : "";
try {
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({
kind: "error",
name,
message: msg,
filename: event.filename || "",
lineno: event.lineno || 0,
colno: event.colno || 0,
});
} catch {
// ignore
}
// 说明:ONLYOFFICE 内部偶发抛出 DOMException(NotFoundError),在部分版本下 message 可能为空,
// 但会被 Next.js 捕获后直接显示“客户端异常”白屏;该错误通常不影响文档继续使用。
if (name === "NotFoundError") {
event.preventDefault();
try {
event.stopImmediatePropagation();
event.stopPropagation();
} catch {
// ignore
}
return;
}
// 说明:部分隧道/证书环境下,ONLYOFFICE 会尝试注册 ServiceWorker(用于缓存静态资源)。
// 但浏览器会以 SecurityError 失败,并触发全局错误,导致 Next.js 显示“客户端异常”白屏。
// 该错误不影响文档实际编辑能力,因此这里对其进行兜底拦截。
if (
name === "SecurityError" ||
msg.includes("Failed to register a ServiceWorker") ||
msg.includes("An SSL certificate error occurred when fetching the script")
) {
event.preventDefault();
try {
event.stopImmediatePropagation();
event.stopPropagation();
} catch {
// ignore
}
}
};
const onRejection = (event: PromiseRejectionEvent) => {
const reason = event.reason as unknown;
const msg = (() => {
try {
return typeof reason === "string" ? reason : String((reason as any)?.message ?? reason);
} catch {
return "";
}
})();
const name = (reason as any)?.name ? String((reason as any).name) : "";
try {
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
window.__MNOTE_ONLYOFFICE_ERRLOG__.push({
kind: "rejection",
name,
message: msg,
});
} catch {
// ignore
}
if (name === "NotFoundError") {
event.preventDefault();
try {
event.stopImmediatePropagation();
event.stopPropagation();
} catch {
// ignore
}
return;
}
if (
name === "SecurityError" ||
msg.includes("Failed to register a ServiceWorker") ||
msg.includes("An SSL certificate error occurred when fetching the script")
) {
event.preventDefault();
try {
event.stopImmediatePropagation();
event.stopPropagation();
} catch {
// ignore
}
}
};
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, []);
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
const resolvedFileUrl = useMemo(() => {
if (!fileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
let base = storageHostOverride
? fileUrl
: rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl);
try {
// 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数
// 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报
// “文档安全令牌格式不正确 / invalid compact jws / invalid signature”。
const raw = new URL(base);
const alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
// 因此这里强制把 proxy 的 origin 改写成我们显式配置的回源(通常是容器可达的 http://172.31.224.1:3000)。
if (alreadyProxy && proxyOrigin) {
const po = new URL(proxyOrigin);
raw.protocol = po.protocol;
raw.host = po.host;
base = raw.toString();
}
if (!alreadyProxy && raw.searchParams.has("token")) {
const proxyBase = proxyOrigin || window.location.origin;
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
base = proxy.toString();
}
const u = new URL(base);
if (!storageHostOverride || alreadyProxy) return u.toString();
// 兼容两种写法:hostname 或完整 originhttps://xxx
if (/^https?:\/\//i.test(storageHostOverride)) {
const ov = new URL(storageHostOverride);
u.protocol = ov.protocol;
u.host = ov.host;
return u.toString();
}
u.hostname = storageHostOverride;
return u.toString();
} catch {
return base;
}
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
useEffect(() => {
if (!baseUrl) {
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
return;
}
if (!fileUrl) {
setError("缺少 fileUrl 参数。");
return;
}
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
loadScript(scriptUrl)
.then(async () => {
// 说明:api.js 的 onload 并不代表 DocsAPI/DocEditor 已完全就绪(在慢网/高负载时会出现空白页)。
// 因此这里额外等待 DocEditor 挂载,避免偶发“白屏但无错误”的体验。
await waitForDocEditorReady(120_000);
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
const config: any = {
width: "100%",
height: "100%",
document: {
fileType,
title: fileName,
url: resolvedFileUrl,
// 说明:key 用于 ONLYOFFICE 内部区分文档实例;应随 URL/文件名变化,避免缓存/冲突。
key: hashKey(`${resolvedFileUrl}-${fileName}`),
},
documentType: targetDocType,
events: {
// 说明:用于 E2E 判定“文档已真正打开”,避免仅靠 iframe/canvas 误判。
// 注意:部分版本回调名是 onDocumentReady,也有文档提到 onAppReady;两者都注册。
onDocumentReady: () => {
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
},
onAppReady: () => {
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
},
onError: (e: unknown) => {
const msg = (() => {
try {
if (typeof e === "string") return e;
return JSON.stringify(e);
} catch {
return String(e);
}
})();
setError(msg);
},
},
editorConfig: {
mode: mode === "view" ? "view" : "edit",
lang: "zh-CN",
// 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件,
// 我们在 /api/onlyoffice/callback 中接收并回写到 Supabase Storage。
callbackUrl: (() => {
const base = callbackOrigin || proxyOrigin || window.location.origin;
const cb = new URL("/api/onlyoffice/callback", base);
if (assetId) cb.searchParams.set("assetId", assetId);
return cb.toString();
})(),
customization: {
feedback: { visible: false },
},
plugins: {
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [pluginConfigUrl],
},
},
};
// 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现”
// 来设置 ready flag,保证远程 E2E 判定稳定。
(window as any).__MNOTE_ONLYOFFICE_READY__ = false;
const readyDeadline = Date.now() + 120_000;
const timer = window.setInterval(() => {
const root = document.querySelector("#onlyoffice-frame") as HTMLElement | null;
const body = document.body as HTMLElement | null;
const count =
(root ? root.querySelectorAll("iframe,canvas").length : 0) +
(body ? body.querySelectorAll("iframe,canvas").length : 0);
if (count > 0) {
(window as any).__MNOTE_ONLYOFFICE_READY__ = true;
window.clearInterval(timer);
} else if (Date.now() > readyDeadline) {
window.clearInterval(timer);
}
}, 500);
fetch("/api/onlyoffice/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
})
.then(async (r) => {
if (!r.ok) {
const payload = await r.json().catch(() => null);
throw new Error(payload?.error ?? "OnlyOffice 签名失败");
}
const { token, documentToken, editorConfigToken } = (await r.json()) as {
token: string | null;
documentToken: string | null;
editorConfigToken: string | null;
};
// 兼容不同 ONLYOFFICE 配置:有些版本/配置会校验 document.token/editorConfig.token。
if (token) config.token = token;
if (documentToken) {
config.document = config.document || {};
config.document.token = documentToken;
}
if (editorConfigToken) {
config.editorConfig = config.editorConfig || {};
config.editorConfig.token = editorConfigToken;
}
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", config);
})
.catch((err: Error) => {
// 说明:如果服务端强制 JWT 且你未配置 ONLYOFFICE_JWT_SECRET,会在这里失败或在编辑器内报错。
setError(err.message);
});
})
.catch((err: Error) => {
setError(err.message);
});
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
<p className="text-base font-semibold text-red-600">ONLYOFFICE </p>
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,
}}
/>
</div>
);
}
+104 -147
View File
@@ -1,159 +1,116 @@
"use client";
import { Suspense } from "react";
import Script from "next/script";
import OnlyOfficeClientPage from "./OnlyOfficeClientPage";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
export const dynamic = "force-dynamic";
type EditorMode = "view" | "edit";
const ONLYOFFICE_EARLY_PATCH = `
(function () {
try {
if (window.__MNOTE_ONLYOFFICE_EARLYPATCHED__) return;
window.__MNOTE_ONLYOFFICE_EARLYPATCHED__ = true;
const MNOTE_AGENT_PLUGIN_GUID = "asc.{F2B9A7B4-3A22-4E0E-8B32-3B0DE7AE8A60}";
// 说明:ONLYOFFICE 在部分环境下会触发 DOMException(NotFoundError)
// 常见于 removeChild/insertBefore/replaceChild 等 DOM 操作。
// 该异常会被 Next.js 视为“客户端异常”并白屏,但通常不影响编辑器继续使用。
// 这里尽可能早地打补丁,避免异常冒泡导致整页崩溃。
try {
var origRemoveChild = Node.prototype.removeChild;
Node.prototype.removeChild = function (child) {
try {
return origRemoveChild.call(this, child);
} catch (e) {
if (e && e.name === "NotFoundError") return child;
throw e;
}
};
} catch (e) {}
const loadScript = (src: string) =>
new Promise<void>((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true });
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载 ONLYOFFICE 脚本失败: ${src}`));
document.body.appendChild(script);
});
try {
var origInsertBefore = Node.prototype.insertBefore;
Node.prototype.insertBefore = function (newNode, referenceNode) {
try {
return origInsertBefore.call(this, newNode, referenceNode);
} catch (e) {
if (e && e.name === "NotFoundError") return newNode;
throw e;
}
};
} catch (e) {}
const hashKey = (input: string) => {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString();
};
try {
var origReplaceChild = Node.prototype.replaceChild;
Node.prototype.replaceChild = function (newChild, oldChild) {
try {
return origReplaceChild.call(this, newChild, oldChild);
} catch (e) {
if (e && e.name === "NotFoundError") return oldChild;
throw e;
}
};
} catch (e) {}
const docTypeFromExt = (ext: string) => {
const word = ["doc", "docx", "odt", "rtf"];
const slide = ["ppt", "pptx", "odp"];
const sheet = ["xls", "xlsx", "ods", "csv"];
const pdf = ["pdf"];
// 说明:ONLYOFFICE 文档类型使用 word/cell/slide/pdf(旧的 text/spreadsheet/presentation 已逐步弃用)
if (word.includes(ext)) return "word";
if (slide.includes(ext)) return "slide";
if (sheet.includes(ext)) return "cell";
if (pdf.includes(ext)) return "pdf";
return "word";
};
// 说明:记录错误,便于远程白屏时回溯(不会阻断页面)。
try {
window.__MNOTE_ONLYOFFICE_ERRLOG__ = window.__MNOTE_ONLYOFFICE_ERRLOG__ || [];
var push = function (payload) {
try {
window.__MNOTE_ONLYOFFICE_ERRLOG__.push(payload);
window.localStorage.setItem("mnote_onlyoffice_last_error", JSON.stringify(payload));
} catch (e) {}
};
window.addEventListener(
"error",
function (e) {
try {
push({
kind: "error",
message: String(e.message || ""),
filename: String(e.filename || ""),
lineno: Number(e.lineno || 0),
colno: Number(e.colno || 0),
name: String((e.error && e.error.name) || ""),
});
} catch (err) {}
},
true
);
window.addEventListener(
"unhandledrejection",
function (e) {
try {
var r = e.reason;
push({
kind: "rejection",
message: String((r && (r.message || r)) || ""),
name: String((r && r.name) || ""),
});
} catch (err) {}
},
true
);
} catch (e) {}
} catch (e) {}
})();
`.trim();
export default function OnlyOfficePage() {
const params = useSearchParams();
const fileUrl = params.get("fileUrl") ?? "";
const fileName = params.get("fileName") ?? "未命名文档";
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const [error, setError] = useState<string | null>(null);
const baseUrl = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
const storageHostOverride =
process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE;
const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]);
const resolvedFileUrl = useMemo(() => {
if (!fileUrl) return "";
// 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。
// 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网,
// 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000
// 导致 ONLYOFFICE 报 “下载失败(EPROTO wrong version number)”。
const base = storageHostOverride
? fileUrl
: rewriteToPublicOrigin(fileUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
try {
const u = new URL(base);
if (!storageHostOverride) return u.toString();
// 兼容两种写法:hostname 或完整 originhttps://xxx
if (/^https?:\/\//i.test(storageHostOverride)) {
const ov = new URL(storageHostOverride);
u.protocol = ov.protocol;
u.host = ov.host;
return u.toString();
}
u.hostname = storageHostOverride;
return u.toString();
} catch {
return base;
}
}, [fileUrl, storageHostOverride]);
useEffect(() => {
if (!baseUrl) {
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
return;
}
if (!fileUrl) {
setError("缺少 fileUrl 参数。");
return;
}
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
loadScript(scriptUrl)
.then(() => {
// @ts-expect-error ONLYOFFICE 全局对象
if (!window.DocsAPI) {
throw new Error("未检测到 DocsAPI,请检查 ONLYOFFICE 版本。");
}
// eslint-disable-next-line new-cap,@typescript-eslint/no-explicit-any
const pluginConfigUrl = `${window.location.origin}/onlyoffice/plugins/agent-tools/config.json`;
new (window as any).DocsAPI.DocEditor("onlyoffice-frame", {
width: "100%",
height: "100%",
document: {
fileType,
title: fileName,
url: resolvedFileUrl,
key: hashKey(`${resolvedFileUrl}-${fileName}`),
},
documentType: targetDocType,
editorConfig: {
mode: mode === "view" ? "view" : "edit",
lang: "zh-CN",
customization: {
feedback: { visible: false },
},
plugins: {
autostart: [MNOTE_AGENT_PLUGIN_GUID],
pluginsData: [pluginConfigUrl],
},
},
});
})
.catch((err: Error) => {
setError(err.message);
});
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
if (error) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 bg-slate-50">
<p className="text-base font-semibold text-red-600">ONLYOFFICE </p>
<p className="text-sm text-gray-600">{error}</p>
</div>
);
}
return (
<div className="relative h-screen w-screen bg-slate-50">
<div id="onlyoffice-frame" className="h-full w-full" />
<OnlyOfficeAiAgentPanel
openFile={{
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
title: fileName,
fileUrl: resolvedFileUrl,
mimeType: null,
}}
<Suspense
fallback={
<div className="flex h-screen items-center justify-center bg-slate-50 text-sm text-gray-600">
ONLYOFFICE
</div>
}
>
<Script
id="mnote-onlyoffice-earlypatch"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{ __html: ONLYOFFICE_EARLY_PATCH }}
/>
</div>
<OnlyOfficeClientPage />
</Suspense>
);
}
+1 -3
View File
@@ -4,9 +4,7 @@ import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspac
export default async function Home() {
const supabase = await createSupabaseServerClient();
const {
data: { session },
} = await supabase.auth.getSession();
const session = (await supabase.auth.getSession()).data.session;
if (!session) {
redirect("/login");
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSessionContext } from "@supabase/auth-helpers-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
interface TaskResponse {
task_id: string;
@@ -20,7 +21,7 @@ export function DocumentTaskPanel({ documentId }: Props) {
const { session } = useSessionContext();
const [task, setTask] = useState<TaskResponse | null>(null);
const [pending, setPending] = useState(false);
const backendUrl = useMemo(() => process.env.NEXT_PUBLIC_BACKEND_URL, []);
const backendUrl = useMemo(() => getMnoteRuntimeConfig().backendUrl, []);
const triggerTask = async () => {
if (!backendUrl || !session?.access_token) return;
@@ -17,6 +17,7 @@ import { cn } from "@/lib/utils";
import { useImagePicker } from "@/components/media/image-picker-context";
import type { MediaKind } from "@/types/media";
import { emitAssetsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
DropdownMenu,
DropdownMenuContent,
@@ -97,7 +98,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
const captionRef = useRef<HTMLInputElement | null>(null);
const [captionEditing, setCaptionEditing] = useState(false);
const shouldShowCaption = captionEditing || Boolean(block.props.caption);
const officeBase = process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL;
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
const resolveDocumentId = useCallback(() => {
if (typeof window !== "undefined") {
const [, tail] = window.location.pathname.split("/documents/");
@@ -240,11 +241,14 @@ const MediaBlockContent = ({ block, editor }: any) => {
return;
}
try {
const res = await fetch(
`/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent(
displayFileName,
)}&for=onlyoffice`,
);
const assetId = (block.props as { assetId?: string })?.assetId;
const res = assetId
? await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`)
: await fetch(
`/api/media/signed-url?fileUrl=${encodeURIComponent(fileUrl)}&fileName=${encodeURIComponent(
displayFileName,
)}&for=onlyoffice`,
);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation";
import { supabaseBrowser } from "@/lib/supabase/client";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) {
@@ -15,6 +15,7 @@ const normalizeTitle = (value?: string | null) => {
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const router = useRouter();
const supabaseBrowser = getSupabaseBrowserClient();
const fallbackTitle = normalizeTitle(title);
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
@@ -39,7 +40,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
.from("documents")
.select("title")
.eq("id", pageId)
.single();
.single<{ title: string | null }>();
if (data) {
applyTitle(data.title);
}
@@ -14,7 +14,7 @@ import {
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils";
import { supabaseBrowser } from "@/lib/supabase/client";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
type LuckysheetSelection =
| {
@@ -52,6 +52,7 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null);
@@ -1,7 +1,6 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState, type ReactNode } from "react";
interface QueryProviderProps {
@@ -25,7 +24,6 @@ export function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
@@ -3,7 +3,7 @@
import { useEffect } from "react";
import type { Session } from "@supabase/supabase-js";
import { SessionContextProvider } from "@supabase/auth-helpers-react";
import { supabaseBrowser } from "@/lib/supabase/client";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
interface SupabaseProviderProps {
session: Session | null;
@@ -12,6 +12,7 @@ interface SupabaseProviderProps {
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
useEffect(() => {
const supabaseBrowser = getSupabaseBrowserClient();
const {
data: { subscription },
} = supabaseBrowser.auth.onAuthStateChange((_event, newSession) => {
@@ -40,7 +41,10 @@ export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
}, []);
return (
<SessionContextProvider supabaseClient={supabaseBrowser} initialSession={session}>
<SessionContextProvider
supabaseClient={getSupabaseBrowserClient()}
initialSession={session}
>
{children}
</SessionContextProvider>
);
@@ -37,7 +37,7 @@ import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/
import { useSidebarData } from "@/hooks/use-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { supabaseBrowser } from "@/lib/supabase/client";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge";
import { FileTree } from "@/components/sidebar/file-tree";
@@ -57,6 +57,7 @@ import {
import type { MediaAsset } from "@/types/media";
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const TOP_BUTTONS = [
{ id: "search", icon: SearchIcon, label: "搜索" },
@@ -78,6 +79,21 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
const normalizeStoragePath = (storagePath: string): string => storagePath.replaceAll("\\", "/");
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : null;
if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext && ["pdf"].includes(ext)) return ext;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};
const extractMindmapIdFromStoragePath = (
storagePath: string | null | undefined,
): string | null => {
@@ -112,6 +128,7 @@ interface ContextMenuState {
export function Sidebar({ initialData }: SidebarProps) {
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore();
const supabaseBrowser = useMemo(() => getSupabaseBrowserClient(), []);
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
@@ -530,6 +547,36 @@ export function Sidebar({ initialData }: SidebarProps) {
setOpen(false);
return;
}
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
return;
}
void (async () => {
try {
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(asset.id)}`);
if (!res.ok) {
const payload = await res.json().catch(() => null);
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin);
target.searchParams.set("fileUrl", signedUrl);
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
target.searchParams.set("fileType", officeFileType);
target.searchParams.set("assetId", asset.id);
window.open(target.toString(), "_blank", "noopener,noreferrer");
setOpen(false);
} catch (error) {
window.alert((error as Error).message);
}
})();
return;
}
const url = asset.signed_url ?? asset.file_url;
if (!url) {
window.alert("暂无可用的文件链接");
@@ -2,7 +2,7 @@ import "server-only";
import path from "path";
import { promises as fs } from "fs";
import { preferredBaseDir, legacyBaseDir } from "@/lib/mindmap-files";
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
export function resolveMindmapFileName(mindmapId: string) {
if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
@@ -38,6 +38,8 @@ export type ReadMindmapResult =
| { ok: false; data: null; source: null };
export async function readMindmapLocal(docId: string, mindmapId: string): Promise<ReadMindmapResult> {
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
const preferredFolder = path.join(preferredBaseDir, docId);
const preferredFile = path.join(preferredFolder, resolveMindmapFileName(mindmapId));
const preferredLegacy = path.join(preferredFolder, "mindmap.json");
@@ -61,11 +63,10 @@ export async function writeMindmapLocal(
data: unknown,
docTitle: string,
) {
const folder = path.join(preferredBaseDir, docId);
const folder = path.join(getDocumentsBaseDir(), docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
await ensureDir(folder);
await ensureIndexFile(folder, docTitle || "无标题");
await fs.writeFile(file, JSON.stringify(data ?? { data: { text: "中心主题" }, children: [] }, null, 2), "utf8");
return { folder, file };
}
+179
View File
@@ -0,0 +1,179 @@
export type MnoteRuntimeConfig = {
supabaseUrl?: string;
/**
* / Supabase HTTP FRP/ Node TLS
* public/mnote-env.json 使
*/
supabaseInternalUrl?: string;
supabaseAnonKey?: string;
backendUrl?: string;
onlyofficeBaseUrl?: string;
onlyofficeBaseUrlWeb?: string;
onlyofficeBaseUrlDesktop?: string;
onlyofficeStorageHostOverride?: string;
onlyofficeStorageHostOverrideWeb?: string;
onlyofficeStorageHostOverrideDesktop?: string;
cloudflareAppOrigin?: string;
onlyofficeProxyOriginWeb?: string;
onlyofficeCallbackOriginWeb?: string;
onlyofficeCallbackOriginDesktop?: string;
/**
* Electron
*/
isDesktop?: boolean;
/**
* ONLYOFFICE document.url 使 /api/onlyoffice/proxy
* signedUrl token 访 Origin
*
* - window.location.origin
* - Cloudflare Tunnel 使 ONLYOFFICE https://app.<你的域名>
*/
onlyofficeProxyOrigin?: string;
onlyofficeCallbackOrigin?: string;
};
declare global {
interface Window {
__MNOTE_RUNTIME_CONFIG__?: MnoteRuntimeConfig;
}
}
const readFromEnv = (): MnoteRuntimeConfig => ({
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL,
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
onlyofficeCallbackOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_CALLBACK_ORIGIN,
cloudflareAppOrigin: process.env.NEXT_PUBLIC_CLOUDFLARE_APP_ORIGIN,
});
const readFromPublicJson = (): Partial<MnoteRuntimeConfig> => {
if (typeof window !== "undefined") return {};
try {
// 说明:桌面端与网页端共用 public/mnote-env.json 作为“公共环境文件”。
// 桌面端运行时 process.cwd() 会被 Electron 切到 desktop-next 根目录。
// 网页端运行时 process.cwd() 通常为 wolai-frontend。
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require("fs") as typeof import("fs");
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require("path") as typeof import("path");
// 说明:Next standalone 产物的 server.js 会执行 `process.chdir(__dirname)`
// 导致 process.cwd() 变成 `.next/standalone`,此时 public/mnote-env.json 位于上层目录。
// 这里向上查找多级目录,确保能读取到真正的 public/mnote-env.json。
const candidates: string[] = [];
let dir = process.cwd();
for (let i = 0; i < 6; i += 1) {
candidates.push(path.join(dir, "public", "mnote-env.json"));
const next = path.dirname(dir);
if (next === dir) break;
dir = next;
}
const existing = candidates.filter((p) => fs.existsSync(p));
if (existing.length === 0) return {};
// 说明:standalone 产物可能包含 `.next/standalone/public/mnote-env.json`,但它通常是构建时拷贝,
// 用户更希望修改“项目目录下”的 public/mnote-env.json 即可生效。
// 因此这里优先选择不在 `.next` 目录下的配置文件。
const isInNextDir = (p: string) => p.split(path.sep).includes(".next");
const filePath = existing.find((p) => !isInNextDir(p)) ?? existing[0];
const raw = fs.readFileSync(filePath, { encoding: "utf8" });
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return {};
return parsed as Partial<MnoteRuntimeConfig>;
} catch {
return {};
}
};
const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig => {
const isDesktop =
cfg.isDesktop ??
(typeof window === "undefined" ? process.env.MNOTE_DESKTOP === "1" : false);
// 说明:`onlyofficeBaseUrl` / `onlyofficeStorageHostOverride` 属于“通用默认值”。
// 但我们在 `public/mnote-env.json` 里同时提供了 Web/Desktop 两套配置,
// 因此这里应当优先选择与平台匹配的字段,避免被环境变量中的默认值覆盖。
//
// 典型场景:开发机环境变量仍是 `http://localhost:8081`,但网页端需要走
// `https://onlyoffice.<域名>`。若不调整优先级,远程浏览器会尝试访问它自己
// 的 localhost,从而导致 docx 打不开。
const onlyofficeBaseUrl = isDesktop
? cfg.onlyofficeBaseUrlDesktop ||
cfg.onlyofficeBaseUrl ||
cfg.onlyofficeBaseUrlWeb ||
""
: cfg.onlyofficeBaseUrlWeb ||
cfg.onlyofficeBaseUrl ||
cfg.onlyofficeBaseUrlDesktop ||
"";
// 说明:这里需要用 `??` 而不是 `||`,允许通过配置显式传入空字符串来“关闭 override”。
// 否则 Web 端配置为 "" 时,会被环境变量里的默认值(例如 host.docker.internal)误覆盖,
// 进而导致 ONLYOFFICE 文档服务器无法访问真实的存储地址。
const onlyofficeStorageHostOverride = isDesktop
? (cfg.onlyofficeStorageHostOverrideDesktop ??
cfg.onlyofficeStorageHostOverride ??
cfg.onlyofficeStorageHostOverrideWeb ??
"")
: (cfg.onlyofficeStorageHostOverrideWeb ??
cfg.onlyofficeStorageHostOverride ??
cfg.onlyofficeStorageHostOverrideDesktop ??
"");
// 说明:`onlyofficeProxyOrigin` 也属于“通用默认值”。在 Web 端需要优先使用
// onlyofficeProxyOriginWeb,避免被旧的通用值(例如 Cloudflare 域名)覆盖,
// 否则会导致 ONLYOFFICE 文档服务器回源到错误的公网入口。
const onlyofficeProxyOrigin = isDesktop
? cfg.onlyofficeProxyOrigin || ""
: cfg.onlyofficeProxyOriginWeb ||
cfg.onlyofficeProxyOrigin ||
"";
// 说明:ONLYOFFICE 回调(保存)必须是“文档服务器可访问”的地址。
// Web 端优先用 onlyofficeCallbackOriginWeb(通常是 http://host.docker.internal:3000)。
const onlyofficeCallbackOrigin = isDesktop
? cfg.onlyofficeCallbackOriginDesktop ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginWeb ||
""
: cfg.onlyofficeCallbackOriginWeb ||
cfg.onlyofficeCallbackOrigin ||
cfg.onlyofficeCallbackOriginDesktop ||
"";
return {
...cfg,
isDesktop,
onlyofficeBaseUrl,
onlyofficeStorageHostOverride,
onlyofficeProxyOrigin,
onlyofficeCallbackOrigin,
};
};
export const getMnoteRuntimeConfig = (): MnoteRuntimeConfig => {
if (typeof window !== "undefined") {
return normalizeRuntimeConfig(window.__MNOTE_RUNTIME_CONFIG__ ?? readFromEnv());
}
const isDesktop = process.env.MNOTE_DESKTOP === "1";
// 说明:桌面端需要优先使用 public/mnote-env.json 来覆盖 build 时注入的 NEXT_PUBLIC_*。
// Web 端开发时则应优先使用环境变量(例如本机 http://127.0.0.1:18000),避免被
// public/mnote-env.json 中的远程/自签地址覆盖导致浏览器登录请求失败。
const merged: MnoteRuntimeConfig = isDesktop
? {
...readFromEnv(),
...readFromPublicJson(),
isDesktop,
}
: {
...readFromPublicJson(),
...readFromEnv(),
isDesktop,
};
return normalizeRuntimeConfig(merged);
};
+12 -1
View File
@@ -6,7 +6,18 @@ type RequestCookies = Awaited<ReturnType<typeof cookies>>;
const decodeValue = (value?: string) => {
if (!value) return value;
return value.startsWith("base64-") ? Buffer.from(value.slice(7), "base64").toString("utf8") : value;
let v = value;
// 说明:部分环境下 cookies() 读取到的值仍是 URL 编码(例如 %5B%22...%22%5D),
// Supabase Auth Helpers 期望拿到可直接 JSON.parse 的字符串,因此这里做一次解码。
// 若不是合法的 URL 编码字符串,decodeURIComponent 会抛错,我们直接兜底返回原值。
if (v.includes("%")) {
try {
v = decodeURIComponent(v);
} catch {
// ignore
}
}
return v.startsWith("base64-") ? Buffer.from(v.slice(7), "base64").toString("utf8") : v;
};
const encodeValue = (value: string) => {
@@ -0,0 +1,31 @@
import "server-only";
import path from "path";
/**
*
*
*
* - /使 `process.cwd()/public/*`便访
* - Electron `MNOTE_DATA_DIR=<安装目录>\\data`
* resources/app.asar
*/
function cleanEnvValue(value: unknown): string | null {
if (typeof value !== "string") return null;
const v = value.trim();
return v.length > 0 ? v : null;
}
export function getDocumentsBaseDir(): string {
const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
if (dataDir) return path.join(dataDir, "documents");
return path.join(process.cwd(), "public", "documents");
}
export function getLegacyMindmapsBaseDir(): string {
const dataDir = cleanEnvValue(process.env.MNOTE_DATA_DIR);
if (dataDir) return path.join(dataDir, "mindmaps");
return path.join(process.cwd(), "public", "mindmaps");
}
@@ -1,10 +1,9 @@
import "server-only";
import path from "path";
import { promises as fs } from "fs";
import type { MediaAsset } from "@/types/media";
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
const tryAccess = async (file: string) => {
try {
@@ -17,6 +16,8 @@ const tryAccess = async (file: string) => {
export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]> {
const results: string[] = [];
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
for (const id of docIds) {
const folder = path.join(preferredBaseDir, id);
const preferredLegacy = path.join(folder, "mindmap.json");
@@ -39,8 +40,6 @@ export async function detectLocalMindmapDocs(docIds: string[]): Promise<string[]
return results;
}
export { preferredBaseDir, legacyBaseDir };
export type LocalMindmapFile = {
documentId: string;
mindmapId: string;
@@ -50,6 +49,8 @@ export type LocalMindmapFile = {
export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMindmapFile[]> {
const results: LocalMindmapFile[] = [];
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
for (const id of docIds) {
const folder = path.join(preferredBaseDir, id);
try {
@@ -91,8 +92,9 @@ export async function detectLocalMindmapFiles(docIds: string[]): Promise<LocalMi
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
const root = (() => {
if (!input || typeof input !== "object") return input;
const record = input as Record<string, unknown>;
return "root" in record ? record.root : input;
const record = input;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (record && typeof record === "object" && "root" in (record as any)) ? (record as any).root : input;
})();
const ids: string[] = [];
@@ -163,10 +165,12 @@ export async function detectLocalMindmapImageAssetIdsByMindmapId(
files: LocalMindmapFile[],
): Promise<Record<string, string[]>> {
const mapping: Record<string, string[]> = {};
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
for (const item of files) {
const baseDir = item.source === "legacy" ? legacyBaseDir : preferredBaseDir;
const filePath = path.join(baseDir, item.documentId, item.fileName);
const filePath = path.join(baseDir, item.documentId, item.fileName);
const data = await tryReadJsonFile<unknown>(filePath);
if (!data) continue;
const ids = extractMindmapImageAssetIdsFromData(data);
@@ -211,6 +215,8 @@ export async function detectLocalTrashedMindmapAssets(
docIds: string[],
): Promise<MediaAsset[]> {
const results: MediaAsset[] = [];
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
for (const docId of docIds) {
const preferredFolder = path.join(preferredBaseDir, docId);
@@ -250,3 +256,4 @@ export async function detectLocalTrashedMindmapAssets(
results.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""));
return results;
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { createClient } from "@supabase/supabase-js";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const runtime = getMnoteRuntimeConfig();
// 说明:服务端优先走内网/本机(HTTP),避免 FRP/证书环境导致 Node fetch TLS 校验失败。
// 若未配置 SUPABASE_INTERNAL_URL,再回退到公网 supabaseUrl。
const supabaseAdminUrl =
runtime.supabaseInternalUrl ||
process.env.SUPABASE_INTERNAL_URL ||
runtime.supabaseUrl ||
process.env.SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
"";
const supabaseAdmin = createClient(
process.env.SUPABASE_INTERNAL_URL ?? process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
supabaseAdminUrl,
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
{
auth: {
+25 -9
View File
@@ -1,13 +1,29 @@
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/types/supabase";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
let cachedClient: SupabaseClient<Database> | null = null;
let cachedKey = "";
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("缺少 Supabase 环境变量,请在 .env.local 配置 NEXT_PUBLIC_SUPABASE_URL 与 NEXT_PUBLIC_SUPABASE_ANON_KEY");
export function getSupabaseBrowserClient(): SupabaseClient<Database> {
const runtimeConfig = getMnoteRuntimeConfig();
const supabaseUrl = runtimeConfig.supabaseUrl;
const supabaseAnonKey = runtimeConfig.supabaseAnonKey;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 运行期配置:请检查 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 是否已注入",
);
}
const key = `${supabaseUrl}::${supabaseAnonKey}`;
if (cachedClient && cachedKey === key) return cachedClient;
cachedKey = key;
cachedClient = createClientComponentClient<Database>({
supabaseUrl,
supabaseKey: supabaseAnonKey,
});
return cachedClient;
}
export const supabaseBrowser = createClientComponentClient({
supabaseUrl,
supabaseKey: supabaseAnonKey,
});
+51 -16
View File
@@ -1,36 +1,71 @@
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { getDecodedCookies } from "@/lib/server-cookies";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const getAuthStorageKey = (supabaseUrl: string) => {
// 说明:supabase-js 默认用 “项目 refhostname 第一个片段)” 作为 storageKey
// 同时 auth-helpers 会把该 storageKey 作为 cookie 名(sb-<ref>-auth-token)。
// 我们服务端为了绕过 FRP 自签证书,会把 supabaseUrl 指向内网/本机(例如 127.0.0.1),
// 但 cookie 名必须仍然按“公网 supabaseUrl”计算,否则会读不到浏览器写入的 cookie。
const hostname = new URL(supabaseUrl).hostname;
const ref = hostname.split(".")[0] || hostname;
return `sb-${ref}-auth-token`;
};
export const createSupabaseServerClient = async () => {
const cookieStore = await getDecodedCookies();
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const cfg = getMnoteRuntimeConfig();
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const internalSupabaseUrl =
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
const supabaseAnonKey =
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
);
}
return createServerComponentClient({
cookies: () => cookieStore as any,
supabaseUrl,
supabaseKey: supabaseAnonKey,
} as any);
// 关键:服务端请求尽量走内网/本机(HTTP),避免 FRP Auto HTTPS 的证书导致 Node 侧校验失败。
// 但 storageKey/cookie 名称必须与浏览器端一致(使用 publicSupabaseUrl 计算),否则会读不到会话。
const storageKey = getAuthStorageKey(publicSupabaseUrl);
// 注意:@supabase/auth-helpers-nextjs 的 createServerComponentClient 签名是 (context, options)。
// 如果把 supabaseUrl/supabaseKey 放进第一个参数,会被当成 context 字段而忽略,导致仍使用默认
// NEXT_PUBLIC_SUPABASE_URLHTTPS),从而触发 Node 端自签证书报错。
return createServerComponentClient(
{ cookies: () => cookieStore as any },
{
supabaseUrl: internalSupabaseUrl,
supabaseKey: supabaseAnonKey,
// 关键:显式覆盖 storageKey,让 supabase-js 读取 sb-<公网 ref>-auth-token
// 而不是 sb-<127>-auth-token。
options: { auth: { storageKey } } as any,
} as any,
);
};
export const createSupabaseRouteClient = async () => {
const cookieStore = await getDecodedCookies();
const supabaseUrl = process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
const cfg = getMnoteRuntimeConfig();
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
const internalSupabaseUrl =
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
const supabaseAnonKey =
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
throw new Error(
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
);
}
return createRouteHandlerClient({
cookies: () => cookieStore as any,
supabaseUrl,
supabaseKey: supabaseAnonKey,
} as any);
const storageKey = getAuthStorageKey(publicSupabaseUrl);
return createRouteHandlerClient(
{ cookies: () => cookieStore as any },
{
supabaseUrl: internalSupabaseUrl,
supabaseKey: supabaseAnonKey,
options: { auth: { storageKey } } as any,
} as any,
);
};
@@ -7,6 +7,20 @@ export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) =>
const u = new URL(input);
const pub = new URL(publicBaseUrl);
// 说明:如果是 Supabase 的典型路径(storage/auth/rest/functions/realtime),即使来源域名不同
// 也统一改为当前“公网可达”的 Supabase origin,避免历史/旧隧道域名导致不可访问。
const isSupabasePath =
u.pathname.startsWith("/storage/v1/") ||
u.pathname.startsWith("/auth/v1/") ||
u.pathname.startsWith("/rest/v1/") ||
u.pathname.startsWith("/functions/v1/") ||
u.pathname.startsWith("/realtime/v1/");
if (isSupabasePath && u.host !== pub.host) {
u.protocol = pub.protocol;
u.host = pub.host;
return u.toString();
}
const isLocalHost =
u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
const isLikelyInternalPort = u.port === "18000";
@@ -24,4 +38,3 @@ export const rewriteToPublicOrigin = (rawUrl: string, publicBaseUrl?: string) =>
return input;
}
};