0.2 在线版本打通
This commit is contained in:
@@ -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.js(Turbopack 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: [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,9 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const isUuid = (value: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
|
||||
const resolveAssetObjectLocation = async (params: {
|
||||
bucket?: string | null;
|
||||
storagePath?: string | null;
|
||||
workspaceId?: string | null;
|
||||
fileName?: string | null;
|
||||
}) => {
|
||||
const { bucket, storagePath, workspaceId, fileName } = params;
|
||||
const ws = (workspaceId ?? "").trim();
|
||||
const fn = (fileName ?? "").trim();
|
||||
|
||||
if (!ws || !isUuid(ws) || !fn) return null;
|
||||
|
||||
try {
|
||||
const storage = supabaseAdmin.schema("storage");
|
||||
|
||||
// 1) 若已给出 bucket/path,先校验是否存在
|
||||
const b = (bucket ?? "").trim();
|
||||
const p = (storagePath ?? "").trim();
|
||||
if (b && p) {
|
||||
const { data: exact, error: exactError } = await storage
|
||||
.from("objects")
|
||||
.select("bucket_id,name")
|
||||
.eq("bucket_id", b)
|
||||
.eq("name", p)
|
||||
.limit(1);
|
||||
if (!exactError && exact && exact.length > 0) {
|
||||
return { bucket: b, path: p };
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 兼容历史数据:按 workspace_id/%/file_name 查找
|
||||
const pattern = `${ws}/%/${fn}`;
|
||||
const { data: candidates, error: candError } = await storage
|
||||
.from("objects")
|
||||
.select("bucket_id,name,created_at")
|
||||
.like("name", pattern)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (candError || !candidates || candidates.length === 0) return null;
|
||||
|
||||
const exactCandidate =
|
||||
candidates.find((o) => String(o?.name || "").endsWith(`/${fn}`)) ??
|
||||
candidates[0];
|
||||
if (!exactCandidate?.bucket_id || !exactCandidate?.name) return null;
|
||||
return { bucket: String(exactCandidate.bucket_id), path: String(exactCandidate.name) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
@@ -33,6 +89,30 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 兼容:早期/异常写入的 media_assets 可能缺少 bucket/storage_path,
|
||||
// 导致生成的签名链接指向不存在的对象(ONLYOFFICE 会报“下载失败”)。
|
||||
let bucket = (asset.bucket as string | null) ?? null;
|
||||
let storagePath = (asset.storage_path as string | null) ?? null;
|
||||
if (!bucket || !storagePath) {
|
||||
const resolved = await resolveAssetObjectLocation({
|
||||
bucket,
|
||||
storagePath,
|
||||
workspaceId: asset.workspace_id ?? null,
|
||||
fileName: asset.file_name ?? null,
|
||||
});
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "未找到存储对象,无法生成签名链接" }, { status: 404 });
|
||||
}
|
||||
bucket = resolved.bucket;
|
||||
storagePath = resolved.path;
|
||||
|
||||
// 尽量回填,避免后续重复修复
|
||||
await supabase
|
||||
.from("media_assets")
|
||||
.update({ bucket, storage_path: storagePath })
|
||||
.eq("id", asset.id);
|
||||
}
|
||||
|
||||
// 对于图片,添加高质量参数以获得更好的显示效果
|
||||
// Supabase Storage 支持通过 URL 参数控制图片质量和尺寸
|
||||
const isImage = asset.mime_type?.startsWith("image/");
|
||||
@@ -42,8 +122,8 @@ export async function GET(request: Request) {
|
||||
// 使用 createSignedUrl 并添加高质量参数
|
||||
// 注意:transform 参数需要在签名时指定
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(asset.bucket)
|
||||
.createSignedUrl(asset.storage_path, 60 * 60, {
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60, {
|
||||
// 添加转换参数以获得高质量图片
|
||||
transform: {
|
||||
quality: 95, // 高质量
|
||||
@@ -58,8 +138,8 @@ export async function GET(request: Request) {
|
||||
} else {
|
||||
// 非图片文件,直接生成签名 URL
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(asset.bucket)
|
||||
.createSignedUrl(asset.storage_path, 60 * 60);
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60);
|
||||
|
||||
if (signError || !signedUrlData) {
|
||||
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
||||
@@ -67,7 +147,7 @@ export async function GET(request: Request) {
|
||||
signedUrl = signedUrlData.signedUrl;
|
||||
}
|
||||
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const applyHostOverride = (rawUrl: string, hostOverride: string) => {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
|
||||
// 支持两种写法:hostname 或完整 origin(https://xxx:port)
|
||||
if (/^https?:\/\//i.test(hostOverride)) {
|
||||
const ov = new URL(hostOverride);
|
||||
u.protocol = ov.protocol;
|
||||
u.host = ov.host;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
u.hostname = hostOverride;
|
||||
return u.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
};
|
||||
const base64Url = (input: string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const parseStoragePath = (fileUrl: string) => {
|
||||
try {
|
||||
@@ -47,12 +37,80 @@ const parseStoragePath = (fileUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const isUuid = (value: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
|
||||
const tryResolveExistingObjectPath = async (params: {
|
||||
bucket: string;
|
||||
path: string;
|
||||
fileName?: string;
|
||||
}) => {
|
||||
const { bucket, path, fileName } = params;
|
||||
if (!bucket || !path) return null;
|
||||
try {
|
||||
const storage = supabaseAdmin.schema("storage");
|
||||
|
||||
// 1) 先尝试精确匹配(最快且最准确)
|
||||
const { data: exact, error: exactError } = await storage
|
||||
.from("objects")
|
||||
.select("name")
|
||||
.eq("bucket_id", bucket)
|
||||
.eq("name", path)
|
||||
.limit(1);
|
||||
|
||||
if (!exactError && exact && exact.length > 0 && exact[0]?.name) {
|
||||
return String(exact[0].name);
|
||||
}
|
||||
|
||||
// 2) 兼容历史数据:老链接中间 UUID 可能用的是 document_id,
|
||||
// 但实际对象通常是 workspace_id/<其他uuid>/<file_name>。
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const workspaceId = segments[0] ?? "";
|
||||
const wantedName = (fileName ?? segments[segments.length - 1] ?? "").trim();
|
||||
if (!workspaceId || !isUuid(workspaceId) || !wantedName) return null;
|
||||
|
||||
const pattern = `${workspaceId}/%/${wantedName}`;
|
||||
const { data: candidates, error: candError } = await storage
|
||||
.from("objects")
|
||||
.select("name,created_at")
|
||||
.eq("bucket_id", bucket)
|
||||
.like("name", pattern)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (candError || !candidates || candidates.length === 0) return null;
|
||||
|
||||
// 说明:LIKE 会把 `_` 当作通配符,这里用 endsWith 再做一次精确过滤。
|
||||
const exactCandidate =
|
||||
candidates.find((o) => String(o?.name || "").endsWith(`/${wantedName}`)) ??
|
||||
candidates[0];
|
||||
if (!exactCandidate?.name) return null;
|
||||
return String(exactCandidate.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
||||
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
||||
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
||||
// 再回退到 request.url 解析出的 origin。
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get("fileUrl");
|
||||
const fileName = searchParams.get("fileName") ?? undefined;
|
||||
const forOnlyOffice = searchParams.get("for") === "onlyoffice";
|
||||
const hostOverride = process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE;
|
||||
if (searchParams.get("debug") === "1") {
|
||||
return NextResponse.json({
|
||||
keyLen: (process.env.SUPABASE_SERVICE_ROLE_KEY || "").length,
|
||||
@@ -64,30 +122,72 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - 非 ONLYOFFICE:必须能解析为 Supabase Storage 路径,便于生成带 download 的临时签名 URL。
|
||||
// - ONLYOFFICE:允许传入任意可访问 URL(包括外链或已签名 URL),并尽量“刷新一次签名”,刷新失败则回退到原 URL。
|
||||
const parsed = parseStoragePath(fileUrl);
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { bucket, path } = parsed;
|
||||
if (!forOnlyOffice) {
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
// OnlyOffice 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。
|
||||
const { data, error } = forOnlyOffice
|
||||
? await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60)
|
||||
: await supabaseAdmin.storage.from(bucket).createSignedUrl(path, 60 * 60, { download: fileName });
|
||||
const { bucket, path } = parsed;
|
||||
const resolvedPath =
|
||||
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
if (!resolvedPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "存储对象不存在,无法生成签名链接" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
||||
resolvedPath,
|
||||
60 * 60,
|
||||
{ download: fileName },
|
||||
);
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
let signedUrl = data.signedUrl;
|
||||
if (forOnlyOffice && hostOverride) {
|
||||
// 优先按 OnlyOffice 专用回源地址改写(可设置为 http://host.docker.internal:18000 以降低延迟)
|
||||
signedUrl = applyHostOverride(signedUrl, hostOverride);
|
||||
} else {
|
||||
// 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000)
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
const signedUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
||||
return NextResponse.json({ signedUrl });
|
||||
}
|
||||
|
||||
return NextResponse.json({ signedUrl });
|
||||
// ONLYOFFICE 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。
|
||||
// 这里优先尝试重新生成 1 小时签名 URL;如果失败(例如策略限制/路径无法解析),则回退到原 URL。
|
||||
let upstreamUrl = rewriteToPublicOrigin(fileUrl, runtimeCfg.supabaseUrl);
|
||||
if (parsed) {
|
||||
const { bucket, path } = parsed;
|
||||
const resolvedPath =
|
||||
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
||||
if (!resolvedPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "存储对象不存在,无法生成签名链接" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
||||
resolvedPath,
|
||||
60 * 60,
|
||||
);
|
||||
if (!error && data?.signedUrl) {
|
||||
upstreamUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 关键:Supabase Storage 的 signedUrl 自带 `?token=...`,ONLYOFFICE 会把 URL 上的
|
||||
// `token` 参数当作自己的 JWT token 去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
||||
//
|
||||
// 解决:对 ONLYOFFICE 返回一个“代理 URL”,把真正的 URL 编码到 `u=...` 中,避免
|
||||
// `token` 参数出现在 document.url 上。
|
||||
const defaultOrigin = new URL(request.url).origin;
|
||||
const proxyBase = (runtimeCfg.onlyofficeProxyOrigin || runtimeCfg.cloudflareAppOrigin || defaultOrigin).replace(/\/+$/, "");
|
||||
const proxyUrl = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxyUrl.searchParams.set("u", base64Url(upstreamUrl));
|
||||
return NextResponse.json({ signedUrl: proxyUrl.toString() });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -72,7 +73,7 @@ export async function POST(request: Request) {
|
||||
signedUrl = signed?.signedUrl ?? "";
|
||||
}
|
||||
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, process.env.NEXT_PUBLIC_SUPABASE_URL);
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
|
||||
const { data: asset, error } = await supabase
|
||||
.from("media_assets")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { detectLocalMindmapFiles } from "@/lib/mindmap-files";
|
||||
import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -116,4 +116,3 @@ export async function GET(request: Request) {
|
||||
items,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -803,7 +804,7 @@ const safeFetchableUrl = (raw: string) => {
|
||||
const host = u.hostname;
|
||||
// 基础防护:只允许本机或 supabase(同域/存储域名变化时可扩展)
|
||||
if (host === "127.0.0.1" || host === "localhost") return u.toString();
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const supabaseUrl = getMnoteRuntimeConfig().supabaseUrl ?? "";
|
||||
if (supabaseUrl) {
|
||||
const supaHost = new URL(supabaseUrl).hostname;
|
||||
if (host === supaHost) return u.toString();
|
||||
|
||||
@@ -2,11 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
@@ -77,4 +78,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true, removed });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -10,8 +11,8 @@ const defaultMindmapData = {
|
||||
|
||||
// 新版:与页面文件夹(index.md 所在处)对齐,放在 public/documents/<docId>/mindmap.json
|
||||
// 旧版遗留:public/mindmaps/<docId>/mindmap.json
|
||||
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
|
||||
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
|
||||
type OnlyOfficeCallbackBody = {
|
||||
status?: number;
|
||||
url?: string;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
|
||||
// 说明:如果回传的是通过 /onlyoffice-server 访问的地址,
|
||||
// 服务端下载时优先改写为本机 ONLYOFFICE_INTERNAL_URL(避免绕公网/证书问题)。
|
||||
const prefix = "/onlyoffice-server";
|
||||
if (u.pathname.startsWith(prefix)) {
|
||||
const nextPath = u.pathname.slice(prefix.length).replace(/^\/+/, "");
|
||||
return `${ONLYOFFICE_INTERNAL_URL}/${nextPath}${u.search}`;
|
||||
}
|
||||
|
||||
return raw;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
const status = Number(body.status ?? -1);
|
||||
// 说明:仅在文档需要保存时处理(2=ready for saving;6=force save)。
|
||||
if (status !== 2 && status !== 6) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
if (!assetId) {
|
||||
// 说明:缺少 assetId 无法定位存储路径,返回非 0 让 ONLYOFFICE 显示保存失败。
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
if (!body.url) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabaseAdmin
|
||||
.from("media_assets")
|
||||
.select("id,bucket,storage_path,mime_type")
|
||||
.eq("id", assetId)
|
||||
.single();
|
||||
|
||||
if (assetError || !asset) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
const { error: uploadError } = await supabaseAdmin.storage
|
||||
.from(asset.bucket)
|
||||
.upload(asset.storage_path, buf, {
|
||||
upsert: true,
|
||||
contentType: asset.mime_type || "application/octet-stream",
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const isPrivateIpv4 = (hostname: string) => {
|
||||
if (isIP(hostname) !== 4) return false;
|
||||
const parts = hostname.split(".").map((v) => Number(v));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return true;
|
||||
const [a, b] = parts;
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) return true;
|
||||
// 127.0.0.0/8
|
||||
if (a === 127) return true;
|
||||
// 169.254.0.0/16
|
||||
if (a === 169 && b === 254) return true;
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return true;
|
||||
// 0.0.0.0/8
|
||||
if (a === 0) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryParseOriginHost = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) {
|
||||
const u = new URL(value);
|
||||
return { hostname: u.hostname, port: u.port || "" };
|
||||
}
|
||||
return { hostname: value, port: "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const decodeBase64UrlToUtf8 = (input: string) => {
|
||||
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padLen = (4 - (normalized.length % 4)) % 4;
|
||||
const padded = normalized + "=".repeat(padLen);
|
||||
return Buffer.from(padded, "base64").toString("utf8");
|
||||
};
|
||||
|
||||
const tryParseOriginUrl = (raw?: string) => {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
if (/^https?:\/\//i.test(value)) return new URL(value);
|
||||
// 说明:仅给 host:port 的写法一个默认协议(http)
|
||||
return new URL(`http://${value}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const encoded = searchParams.get("u");
|
||||
|
||||
if (!encoded) {
|
||||
return NextResponse.json({ error: "缺少 u" }, { status: 400 });
|
||||
}
|
||||
|
||||
let targetUrl: string;
|
||||
try {
|
||||
targetUrl = decodeBase64UrlToUtf8(encoded);
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 base64url URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(targetUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "u 不是有效的 URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
return NextResponse.json({ error: "仅支持 http/https URL" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 安全:避免把该接口变成通用 SSRF 代理。
|
||||
// 当前仅用于代理 Supabase Storage 的签名 URL(以及可能的 storage 回源 host override)。
|
||||
const supa = tryParseOriginHost(runtimeCfg.supabaseUrl);
|
||||
const supaInternalOrigin = tryParseOriginUrl(
|
||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
||||
);
|
||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
||||
|
||||
const isSupabasePath =
|
||||
target.pathname.startsWith("/storage/v1/") ||
|
||||
target.pathname.startsWith("/auth/v1/") ||
|
||||
target.pathname.startsWith("/rest/v1/") ||
|
||||
target.pathname.startsWith("/functions/v1/") ||
|
||||
target.pathname.startsWith("/realtime/v1/");
|
||||
|
||||
// 说明:历史数据里可能残留旧的 Supabase 域名(例如 supabase.aichem.dpdns.org)。
|
||||
// 为了保证 ONLYOFFICE 拉取文件稳定,这里统一把 Supabase 典型路径的 host 改为当前配置的 Supabase,
|
||||
// 并优先使用“内部 HTTP”回源,避免 Node fetch 因自签证书失败。
|
||||
if (isSupabasePath) {
|
||||
if (supaInternalOrigin) {
|
||||
target.protocol = supaInternalOrigin.protocol;
|
||||
target.host = supaInternalOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
} else {
|
||||
const supaPublicOrigin = tryParseOriginUrl(runtimeCfg.supabaseUrl);
|
||||
if (supaPublicOrigin) {
|
||||
target.protocol = supaPublicOrigin.protocol;
|
||||
target.host = supaPublicOrigin.host;
|
||||
targetUrl = target.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allowedHostnames = new Set<string>();
|
||||
const allowedPortsByHostname = new Map<string, Set<string>>();
|
||||
|
||||
const addAllowed = (hostname: string, port: string) => {
|
||||
allowedHostnames.add(hostname);
|
||||
if (!allowedPortsByHostname.has(hostname)) allowedPortsByHostname.set(hostname, new Set<string>());
|
||||
if (port) allowedPortsByHostname.get(hostname)!.add(port);
|
||||
};
|
||||
|
||||
if (supa?.hostname) {
|
||||
addAllowed(supa.hostname, supa.port);
|
||||
// 如果 Supabase 配置就是本地/内网回源(例如 host.docker.internal/127.0.0.1),则允许这些 hostname,
|
||||
// 但仍然限制端口只能是 Supabase URL 的端口,避免误用。
|
||||
if (isLocalHostname(supa.hostname)) {
|
||||
addAllowed("127.0.0.1", supa.port);
|
||||
addAllowed("localhost", supa.port);
|
||||
addAllowed("host.docker.internal", supa.port);
|
||||
}
|
||||
}
|
||||
|
||||
if (supaInternalOrigin?.hostname) {
|
||||
addAllowed(supaInternalOrigin.hostname, supaInternalOrigin.port || "");
|
||||
if (isLocalHostname(supaInternalOrigin.hostname)) {
|
||||
addAllowed("127.0.0.1", supaInternalOrigin.port || "");
|
||||
addAllowed("localhost", supaInternalOrigin.port || "");
|
||||
addAllowed("host.docker.internal", supaInternalOrigin.port || "");
|
||||
}
|
||||
}
|
||||
|
||||
if (storageOverride?.hostname) {
|
||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
||||
}
|
||||
|
||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (allowedHostnames.size > 0 && !allowedHostnames.has(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止代理到非允许的主机" }, { status: 403 });
|
||||
}
|
||||
|
||||
const allowedPorts = allowedPortsByHostname.get(target.hostname);
|
||||
if (allowedPorts && allowedPorts.size > 0) {
|
||||
const port = target.port || (target.protocol === "https:" ? "443" : "80");
|
||||
if (!allowedPorts.has(port)) {
|
||||
return NextResponse.json({ error: "禁止代理到该端口" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - Supabase Storage 的 signedUrl 会携带 `?token=...`,而 ONLYOFFICE 会把它当作
|
||||
// 自己的 JWT token 参数去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
||||
// - 这里用反向代理把 signedUrl 藏在 `u=...` 里(参数名不叫 token),ONLYOFFICE 拉取的
|
||||
// URL 就不会出现 `token` 参数,从而避免冲突。
|
||||
const forwardHeaders = new Headers();
|
||||
const range = request.headers.get("range");
|
||||
if (range) forwardHeaders.set("range", range);
|
||||
|
||||
// 兼容部分 Supabase/Kong 配置:某些环境下下载 Storage 资源仍要求 apikey header。
|
||||
if (
|
||||
runtimeCfg.supabaseAnonKey &&
|
||||
((supa?.hostname && target.hostname === supa.hostname) ||
|
||||
(supaInternalOrigin?.hostname && target.hostname === supaInternalOrigin.hostname))
|
||||
) {
|
||||
forwardHeaders.set("apikey", runtimeCfg.supabaseAnonKey);
|
||||
}
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method,
|
||||
headers: forwardHeaders,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
const headers = new Headers(upstream.headers);
|
||||
// 避免把上游 cookie 透传给文档服务器
|
||||
headers.delete("set-cookie");
|
||||
|
||||
// 说明:ONLYOFFICE 可能会对 document.url 发起 HEAD 预检(获取 content-length/etag)。
|
||||
// 若不支持 HEAD,会导致编辑器报 “下载失败”。
|
||||
if (method === "HEAD") {
|
||||
return new Response(null, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return handle(request, "GET");
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request) {
|
||||
return handle(request, "HEAD");
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64Url = (input: Buffer | string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const signHs256 = (payload: unknown, secret: string) => {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const headerPart = base64Url(JSON.stringify(header));
|
||||
const payloadPart = base64Url(JSON.stringify(payload));
|
||||
const signingInput = `${headerPart}.${payloadPart}`;
|
||||
const signature = crypto.createHmac("sha256", secret).update(signingInput).digest();
|
||||
return `${signingInput}.${base64Url(signature)}`;
|
||||
};
|
||||
|
||||
const normalizeSecret = (raw: string) => {
|
||||
const trimmed = String(raw || "").trim();
|
||||
if (!trimmed) return "";
|
||||
// 兼容用户把 .env 的值写成 "xxx" / 'xxx'
|
||||
if (
|
||||
(trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim();
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || "");
|
||||
const body = (await request.json().catch(() => null)) as { config?: any } | null;
|
||||
if (!body?.config) {
|
||||
return NextResponse.json({ error: "缺少 config" }, { status: 400 });
|
||||
}
|
||||
if (!secret) {
|
||||
// 兼容:如果 ONLYOFFICE 没开启 JWT,可以不需要 token。
|
||||
return NextResponse.json({ token: null, documentToken: null, editorConfigToken: null });
|
||||
}
|
||||
|
||||
const config = body.config;
|
||||
const token = signHs256(config, secret);
|
||||
const documentToken = config?.document ? signHs256(config.document, secret) : null;
|
||||
const editorConfigToken = config?.editorConfig ? signHs256(config.editorConfig, secret) : null;
|
||||
|
||||
return NextResponse.json({ token, documentToken, editorConfigToken });
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
detectLocalMindmapDocs,
|
||||
detectLocalMindmapImageAssetIdsByMindmapId,
|
||||
detectLocalTrashedMindmapAssets,
|
||||
} from "@/lib/mindmap-files";
|
||||
} from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
+86
@@ -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 ?? []);
|
||||
}
|
||||
|
||||
@@ -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 的 fetch(Undici)里,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) => {
|
||||
// 说明:浏览器端 base64url(UTF-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 或完整 origin(https://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>
|
||||
);
|
||||
}
|
||||
@@ -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 或完整 origin(https://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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
+15
-8
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 默认用 “项目 ref(hostname 第一个片段)” 作为 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_URL(HTTPS),从而触发 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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user