0.2.1 onlyoffice修复
This commit is contained in:
@@ -2,6 +2,10 @@ import { notFound, redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { DocumentShell } from "@/components/editor/document-shell";
|
||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -14,6 +18,49 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const resolvedSearch = (await searchParams) ?? {};
|
||||
const openTableIdRaw = resolvedSearch?.openTableId;
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
|
||||
if (!doc) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const initialOptions: PageOptionsState = {
|
||||
wideLayout: doc.wide_layout ?? false,
|
||||
smallText: doc.use_small_text ?? false,
|
||||
showHeadingNumbers: doc.show_heading_numbers ?? true,
|
||||
showToc: doc.show_toc ?? false,
|
||||
showStructure: doc.show_structure ?? false,
|
||||
protectEditing: doc.protect_editing ?? false,
|
||||
showWordCount: doc.show_word_count ?? true,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: doc.word_count ?? 0,
|
||||
characterCount: doc.character_count ?? 0,
|
||||
blockCount: doc.block_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<DocumentShell
|
||||
documentId={doc.id}
|
||||
workspaceId={doc.workspace_id}
|
||||
title={doc.title ?? "无标题"}
|
||||
updatedAt={doc.updated_at}
|
||||
initialContent={null}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -10,9 +10,189 @@ 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/server/mindmap-files";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
const root = (() => {
|
||||
if (!input || typeof input !== "object") return input;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const record = input as any;
|
||||
return record && typeof record === "object" && "root" in record ? record.root : input;
|
||||
})();
|
||||
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value !== "string") return;
|
||||
if (!value.startsWith("asset:")) return;
|
||||
const id = value.slice("asset:".length).trim();
|
||||
if (!id) return;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
};
|
||||
|
||||
const get = (obj: unknown, key: string): unknown => {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
return (obj as Record<string, unknown>)[key];
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const data = get(node, "data");
|
||||
const image = get(node, "image");
|
||||
push(get(data, "image"));
|
||||
push(image);
|
||||
push(get(image, "url"));
|
||||
push(get(get(data, "image"), "url"));
|
||||
const children = get(node, "children");
|
||||
if (Array.isArray(children)) children.forEach(walk);
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return ids;
|
||||
}
|
||||
|
||||
function makeId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
|
||||
const workspaces = ensured.workspaces;
|
||||
const activeWorkspaceId = ensured.activeWorkspaceId;
|
||||
|
||||
let documents: DocumentRecord[] = [];
|
||||
let sidebarInitialData: SidebarInitialData | null = null;
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
const [docRows, trashedDocs] = await Promise.all([
|
||||
client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
}),
|
||||
client.query(api.documents.listTrashedByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
}),
|
||||
]);
|
||||
|
||||
documents = docRows as unknown as DocumentRecord[];
|
||||
|
||||
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
includeDeleted: true,
|
||||
});
|
||||
|
||||
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||
|
||||
const mindmapAssetChildren: Record<string, string[]> = {};
|
||||
activeMindmaps.forEach((r) => {
|
||||
const ids = extractMindmapImageAssetIdsFromData(r.data);
|
||||
if (ids.length > 0) mindmapAssetChildren[r.mindmap_id] = ids;
|
||||
});
|
||||
|
||||
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? activeWorkspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? activeWorkspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: r.deleted_at ?? null,
|
||||
deleted_by: r.deleted_by ?? null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
documents: docRows as unknown as DocumentRecord[],
|
||||
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets,
|
||||
mediaAssets: [],
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import {
|
||||
buildClientToolKey,
|
||||
resolveClientToolCall,
|
||||
@@ -26,11 +28,26 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
const userId = (() => {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
return auth.userId;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const resolvedUserId = async () => {
|
||||
if (userId) return userId;
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
return session.user.id;
|
||||
};
|
||||
|
||||
const finalUserId = await resolvedUserId();
|
||||
if (!finalUserId) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
const result: ClientToolResult = payload.ok
|
||||
? { ok: true, result: "result" in payload ? payload.result : null }
|
||||
@@ -39,7 +56,7 @@ export async function POST(request: Request) {
|
||||
const key = buildClientToolKey(requestId, callId);
|
||||
const resolved = resolveClientToolCall({
|
||||
key,
|
||||
userId: session.user.id,
|
||||
userId: finalUserId,
|
||||
result,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
@@ -48,4 +65,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/
|
||||
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
||||
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
||||
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
||||
import { createDocServerTools, type DocSupabaseClient } from "@/lib/ai-agent/tools/builtins/doc/docServerTools";
|
||||
@@ -14,6 +15,8 @@ import { createMediaServerTools, type MediaSupabaseClient } from "@/lib/ai-agent
|
||||
import { createSlashServerTools, type SlashSupabaseClient } from "@/lib/ai-agent/tools/builtins/slash/slashServerTools";
|
||||
import { createOnlyOfficeServerTools, type OnlyOfficeSupabaseClient } from "@/lib/ai-agent/tools/builtins/onlyoffice/onlyofficeServerTools";
|
||||
import { buildClientToolKey, registerClientToolCall } from "@/lib/ai-agent/runtime/clientToolBridge";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -70,12 +73,21 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 messages" }, { status: 400 });
|
||||
}
|
||||
|
||||
// v1:先要求登录(避免在生产环境暴露推理能力);后续可做更细的权限控制
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
// v1:鉴权(Convex 迁移阶段使用固定开发用户;非 Convex 模式仍走 Supabase session)
|
||||
const convexOn = isConvexEnabled();
|
||||
const { userId, supabase, convexClient } = await (async () => {
|
||||
if (convexOn) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||
}
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return { userId: "", supabase: null as any, convexClient: null as any };
|
||||
return { userId: session.user.id, supabase, convexClient: null as any };
|
||||
})();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -167,6 +179,25 @@ export async function POST(request: Request) {
|
||||
allowedToolIds.delete("doc_replace_range");
|
||||
}
|
||||
|
||||
// 说明:Convex 迁移阶段(M4)先确保“不会再触发 Supabase 依赖”。
|
||||
// 未迁移的能力(OnlyOffice 等)在 Convex 模式下直接禁用对应工具。
|
||||
if (convexOn) {
|
||||
for (const id of [...allowedToolIds]) {
|
||||
if (
|
||||
id === "search_web" ||
|
||||
id === "image_read" ||
|
||||
id === "slash_run" ||
|
||||
id.startsWith("rag_") ||
|
||||
id.startsWith("mindmap_") ||
|
||||
id.startsWith("doc_") ||
|
||||
id.startsWith("docs_")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
allowedToolIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const systemContextText = (() => {
|
||||
const lines: string[] = [];
|
||||
if (documentId) lines.push(`documentId=${documentId}`);
|
||||
@@ -181,12 +212,67 @@ export async function POST(request: Request) {
|
||||
return lines.join("\n").trim();
|
||||
})();
|
||||
|
||||
const normalizeBlocksForTools = (content: unknown): unknown[] => {
|
||||
if (Array.isArray(content)) return content;
|
||||
if (content && typeof content === "object" && "blocks" in (content as any)) {
|
||||
const blocks = (content as any).blocks;
|
||||
if (Array.isArray(blocks)) return blocks;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const extractPlainTextFromBlocks = (blocks: unknown[], maxChars: number) => {
|
||||
const pieces: string[] = [];
|
||||
const walk = (list: unknown[]) => {
|
||||
for (const b of list) {
|
||||
if (!b || typeof b !== "object") continue;
|
||||
const content = (b as any).content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const n of content) {
|
||||
const t = n && typeof n === "object" ? String((n as any).text ?? "") : "";
|
||||
if (t) pieces.push(t);
|
||||
if (pieces.join("").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
const children = (b as any).children;
|
||||
if (Array.isArray(children)) {
|
||||
walk(children);
|
||||
if (pieces.join("").length >= maxChars) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(blocks);
|
||||
const raw = pieces.join("").replace(/\s+/g, " ").trim();
|
||||
return raw.length > maxChars ? `${raw.slice(0, maxChars)}…` : raw;
|
||||
};
|
||||
|
||||
const mindmapTools = hasMindmapContext
|
||||
? createMindmapServerTools({
|
||||
supabase: supabase as unknown as MindmapSupabaseClient,
|
||||
ctx: { documentId, mindmapId, userId: session.user.id, selectedUids, attachments },
|
||||
ctx: { documentId, mindmapId, userId, selectedUids, attachments },
|
||||
cfg: { ...cfg, model: modelOverride ?? cfg.model },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadMindmap: async () => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const [mm, meta] = await Promise.all([
|
||||
convexClient.query(api.mindmaps.get, { userId, docId: documentId, mindmapId }),
|
||||
convexClient.query(api.documents.getMeta, { userId, id: documentId }),
|
||||
]);
|
||||
const title = meta?.title ?? null;
|
||||
const workspaceId = meta?.workspace_id ?? (mm as any)?.meta?.workspace_id ?? null;
|
||||
return {
|
||||
doc: { id: documentId, title, workspace_id: workspaceId },
|
||||
base: (mm as any)?.data ?? { data: { text: "中心主题" }, children: [] },
|
||||
};
|
||||
},
|
||||
saveMindmap: async ({ data }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.mindmaps.put, { userId, docId: documentId, mindmapId, data });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -198,14 +284,30 @@ export async function POST(request: Request) {
|
||||
allowedToolIds.has("doc_replace_range"))
|
||||
? createDocServerTools({
|
||||
supabase: supabase as unknown as DocSupabaseClient,
|
||||
ctx: { documentId, userId: session.user.id, baseBlocks: documentBlocks },
|
||||
ctx: { documentId, userId, baseBlocks: documentBlocks },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadBlocks: async () => {
|
||||
const base = normalizeBlocksForTools(documentBlocks);
|
||||
if (base.length > 0) return { blocks: base, source: "client" };
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.documents.getContent, { userId, id: documentId });
|
||||
const blocks = normalizeBlocksForTools(res?.content ?? null);
|
||||
return { blocks, source: "convex" };
|
||||
},
|
||||
saveBlocks: async (blocks: unknown[]) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateContent, { userId, id: documentId, content: blocks });
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
const ragTools = allowedToolIds.has("rag_lightrag_query")
|
||||
? createRagServerTools({
|
||||
ctx: { userId: session.user.id },
|
||||
ctx: { userId },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
@@ -214,24 +316,88 @@ export async function POST(request: Request) {
|
||||
allowedToolIds.has("docs_search") || allowedToolIds.has("docs_read")
|
||||
? createDocsServerTools({
|
||||
supabase: supabase as unknown as DocsSupabaseClient,
|
||||
ctx: { userId: session.user.id },
|
||||
ctx: { userId },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
searchDocs: async ({ query, limit, workspaceId, includeDeleted }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const wsIds = workspaceId
|
||||
? [workspaceId]
|
||||
: ((await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId }))?.workspaces ?? []).map((w: any) =>
|
||||
String(w?.id ?? ""),
|
||||
);
|
||||
const q = query.toLowerCase();
|
||||
const results: any[] = [];
|
||||
for (const wid of wsIds.filter(Boolean)) {
|
||||
const docs = await convexClient.query(api.documents.listByWorkspace, { userId, workspaceId: wid });
|
||||
const extra = includeDeleted
|
||||
? await convexClient.query(api.documents.listTrashedByWorkspace, { userId, workspaceId: wid }).catch(() => [])
|
||||
: [];
|
||||
const all = [...(Array.isArray(docs) ? docs : []), ...(Array.isArray(extra) ? extra : [])];
|
||||
for (const d of all) {
|
||||
const title = String((d as any)?.title ?? "");
|
||||
if (!title.toLowerCase().includes(q)) continue;
|
||||
results.push({
|
||||
id: String((d as any)?.id ?? ""),
|
||||
title,
|
||||
workspaceId: String((d as any)?.workspace_id ?? wid),
|
||||
parentId: (d as any)?.parent_id ? String((d as any).parent_id) : null,
|
||||
updatedAt: (d as any)?.updated_at ?? null,
|
||||
snippet: title.slice(0, 120),
|
||||
});
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
return results.slice(0, limit);
|
||||
},
|
||||
readDoc: async ({ documentId: rid, maxChars, includeContent }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: rid });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
const contentRes = await convexClient.query(api.documents.getContent, { userId, id: rid });
|
||||
const blocks = normalizeBlocksForTools(contentRes?.content ?? null);
|
||||
const rawText = extractPlainTextFromBlocks(blocks, maxChars);
|
||||
return {
|
||||
ok: true,
|
||||
documentId: rid,
|
||||
title: String(meta.title ?? ""),
|
||||
workspaceId: String((meta as any).workspace_id ?? ""),
|
||||
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
||||
updatedAt: (meta as any).updated_at ?? null,
|
||||
rawTextLength: rawText.length,
|
||||
rawText,
|
||||
...(includeContent ? { content: contentRes?.content ?? null } : {}),
|
||||
};
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
const mediaTools = allowedToolIds.has("image_read")
|
||||
? createMediaServerTools({
|
||||
supabase: supabase as unknown as MediaSupabaseClient,
|
||||
ctx: { userId: session.user.id, attachments },
|
||||
ctx: { userId, attachments },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadById: async (id: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
return await convexClient.query(api.mediaAssets.getById, { userId, id });
|
||||
},
|
||||
loadByFileUrl: async (_fileUrl: string) => null,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
const onlyofficeTools =
|
||||
allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap")
|
||||
!convexOn && (allowedToolIds.has("asset_extract_outline") || allowedToolIds.has("asset_to_mindmap"))
|
||||
? createOnlyOfficeServerTools({
|
||||
supabase: supabase as unknown as OnlyOfficeSupabaseClient,
|
||||
ctx: { userId: session.user.id, documentId: documentId || undefined, attachments },
|
||||
ctx: { userId, documentId: documentId || undefined, attachments },
|
||||
allowedToolIds,
|
||||
})
|
||||
: null;
|
||||
@@ -239,8 +405,56 @@ export async function POST(request: Request) {
|
||||
const slashTools = allowedToolIds.has("slash_run")
|
||||
? createSlashServerTools({
|
||||
supabase: supabase as unknown as SlashSupabaseClient,
|
||||
ctx: { userId: session.user.id, currentDocumentId: documentId || undefined },
|
||||
ctx: { userId, currentDocumentId: documentId || undefined },
|
||||
allowedToolIds,
|
||||
...(convexOn
|
||||
? {
|
||||
loadWorkspaceIds: async (uid: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const res = await convexClient.query(api.workspaces.fetchWorkspaceSummaries, { userId: uid });
|
||||
return (res?.workspaces ?? []).map((w: any) => String(w?.id ?? "")).filter(Boolean);
|
||||
},
|
||||
inferWorkspaceIdFromDoc: async (docId: string) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: docId });
|
||||
return meta ? String((meta as any).workspace_id ?? "") || null : null;
|
||||
},
|
||||
createDoc: async ({ workspaceId, parentId, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `doc_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const created = await convexClient.mutation(api.documents.create, {
|
||||
userId,
|
||||
id,
|
||||
workspaceId,
|
||||
parentId,
|
||||
title,
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
return {
|
||||
id: String((created as any).id ?? id),
|
||||
title: String((created as any).title ?? title),
|
||||
workspaceId: String((created as any).workspace_id ?? workspaceId),
|
||||
parentId: (created as any).parent_id ? String((created as any).parent_id) : parentId,
|
||||
createdAt: (created as any).created_at ?? null,
|
||||
updatedAt: (created as any).updated_at ?? null,
|
||||
};
|
||||
},
|
||||
renameDoc: async ({ documentId: did, title }) => {
|
||||
if (!convexClient) throw new Error("Convex 未初始化");
|
||||
await convexClient.mutation(api.documents.updateTitle, { userId, id: did, title });
|
||||
const meta = await convexClient.query(api.documents.getMeta, { userId, id: did });
|
||||
if (!meta) throw new Error("页面不存在");
|
||||
return {
|
||||
id: String((meta as any).id ?? did),
|
||||
title: String((meta as any).title ?? title),
|
||||
workspaceId: String((meta as any).workspace_id ?? ""),
|
||||
parentId: (meta as any).parent_id ? String((meta as any).parent_id) : null,
|
||||
updatedAt: (meta as any).updated_at ?? null,
|
||||
};
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -323,11 +537,11 @@ export async function POST(request: Request) {
|
||||
if (isOnlyOfficeClientTool(toolId)) {
|
||||
const callId = lastToolCall?.tool === toolId ? lastToolCall.id : `call_${Date.now()}`;
|
||||
const key = buildClientToolKey(requestId, callId);
|
||||
const wait = registerClientToolCall({
|
||||
key,
|
||||
userId: session.user.id,
|
||||
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
||||
});
|
||||
const wait = registerClientToolCall({
|
||||
key,
|
||||
userId,
|
||||
timeoutMs: DEFAULT_CLIENT_TOOL_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
// 说明:客户端收到该事件后,需要执行插件 API 并回调 /api/ai-agent/client-tool-result
|
||||
send("client_tool_call", { requestId, callId, tool: toolId, args: toolArgs });
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
type EmbedBlockPayload = {
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
targetDocumentId: string;
|
||||
position?: "end";
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { sourceDocumentId, blockId, targetDocumentId }: EmbedBlockPayload = await request.json();
|
||||
|
||||
if (!sourceDocumentId || !blockId || !targetDocumentId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceDocumentId === targetDocumentId) {
|
||||
return NextResponse.json({ error: "禁止嵌入到当前页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const hit = findBlockInTree(sourceBlocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: payload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
|
||||
if (!findBlockInTree(sourceBlocks, blockId)) {
|
||||
return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { data: targetDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", targetDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
props: {
|
||||
sourceDocumentId,
|
||||
targetBlockId: blockId,
|
||||
display: "embed",
|
||||
},
|
||||
content: [],
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||
const payload = withBlocksWrittenBack(targetDoc.content, nextBlocks);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload })
|
||||
.eq("id", targetDocumentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const sourceDocumentId = url.searchParams.get("sourceDocumentId") || "";
|
||||
const blockId = url.searchParams.get("blockId") || "";
|
||||
|
||||
if (!sourceDocumentId || !blockId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const hit = findBlockInTree(blocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
const { data: doc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const hit = findBlockInTree(blocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
type MoveBlockPayload = {
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
targetDocumentId: string;
|
||||
position?: "end";
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { sourceDocumentId, blockId, targetDocumentId }: MoveBlockPayload = await request.json();
|
||||
|
||||
if (!sourceDocumentId || !blockId || !targetDocumentId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sourceDocumentId === targetDocumentId) {
|
||||
// 说明:同页移动先不做“定位插入”,视为 no-op。
|
||||
return NextResponse.json({ ok: true, noop: true });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
|
||||
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
|
||||
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
|
||||
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: nextSourcePayload });
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data: sourceDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!sourceDoc) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const sourceBlocks = getBlocksFromDocumentContent(sourceDoc.content);
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const { data: targetDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", targetDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!targetDoc) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(targetDoc.content);
|
||||
const nextSourcePayload = withBlocksWrittenBack(sourceDoc.content, removedRes.nextBlocks);
|
||||
const nextTargetPayload = withBlocksWrittenBack(targetDoc.content, [...targetBlocks, removedRes.removed]);
|
||||
|
||||
const { error: srcErr } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: nextSourcePayload })
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id);
|
||||
if (srcErr) return NextResponse.json({ error: srcErr.message }, { status: 500 });
|
||||
|
||||
const { error: tgtErr } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: nextTargetPayload })
|
||||
.eq("id", targetDocumentId)
|
||||
.eq("user_id", session.user.id);
|
||||
if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
type PatchPayload = {
|
||||
sourceDocumentId: string;
|
||||
blockId: string;
|
||||
nextBlock: unknown;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { sourceDocumentId, blockId, nextBlock }: PatchPayload = await request.json();
|
||||
|
||||
if (!sourceDocumentId || !blockId || !nextBlock) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
|
||||
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: payload });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
const { data: doc } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content,user_id")
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const replaced = replaceBlockInTree(blocks, blockId, nextBlock as any);
|
||||
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ content: payload })
|
||||
.eq("id", sourceDocumentId)
|
||||
.eq("user_id", session.user.id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
|
||||
const id = randomUUID();
|
||||
|
||||
const result = await client.mutation(api.jobs.enqueueDemo, {
|
||||
userId: auth.userId,
|
||||
id,
|
||||
ms,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id") ?? "";
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "缺少 id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const job = await client.query(api.jobs.get, { userId: auth.userId, id });
|
||||
if (!job) {
|
||||
return NextResponse.json({ error: "任务不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(job);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = requireAuthContext();
|
||||
return NextResponse.json({ ok: true, auth }, { status: 200 });
|
||||
} catch (err) {
|
||||
const status = err instanceof HttpError ? err.status : 500;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const url = new URL(request.url);
|
||||
const documentId = url.searchParams.get("documentId") ?? "";
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const result = await client.query(api.documents.getContent, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ content: result.content ?? null });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -37,4 +63,3 @@ export async function GET(request: Request) {
|
||||
|
||||
return NextResponse.json({ content: document.content ?? null });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import type { Json } from "@/types/supabase";
|
||||
@@ -158,6 +162,142 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedItems = payload.items.filter((it) => it?.documentId);
|
||||
if (normalizedItems.length === 0) {
|
||||
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetParentId = payload.targetParentId ?? null;
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (targetParentId) {
|
||||
const targetDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: targetParentId });
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
workspaceId = targetDoc.workspace_id;
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const firstMeta = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
workspaceId = firstMeta.workspace_id;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const wid = workspaceId;
|
||||
|
||||
const allDocs = await client.query(api.documents.listAllForCopy, {
|
||||
userId: auth.userId,
|
||||
workspaceId: wid,
|
||||
});
|
||||
|
||||
const sourceById = new Map<string, DocRow>();
|
||||
(allDocs as unknown as DocRow[]).forEach((d) => sourceById.set(d.id, d));
|
||||
|
||||
const missing = sourceIds.find((id) => !sourceById.has(id));
|
||||
if (missing) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string | null, DocRow[]>();
|
||||
(allDocs as unknown as DocRow[]).forEach((doc) => {
|
||||
const list = childrenByParent.get(doc.parent_id) ?? [];
|
||||
list.push(doc);
|
||||
childrenByParent.set(doc.parent_id, list);
|
||||
});
|
||||
|
||||
const existingTitleSetByParent = new Map<string | null, Set<string>>();
|
||||
const seedTitleSet = (parent: string | null) => {
|
||||
if (existingTitleSetByParent.has(parent)) return;
|
||||
const titles = new Set<string>();
|
||||
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
|
||||
existingTitleSetByParent.set(parent, titles);
|
||||
};
|
||||
seedTitleSet(targetParentId);
|
||||
|
||||
const newIdByOldId = new Map<string, string>();
|
||||
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
|
||||
|
||||
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
|
||||
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
newIdByOldId.set(node.id, newId);
|
||||
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
|
||||
if (!recursive) return;
|
||||
const children = getChildrenSorted(childrenByParent, node.id);
|
||||
children.forEach((child) => visit(child, newId, newId));
|
||||
};
|
||||
visit(root, newParent, newParent);
|
||||
};
|
||||
|
||||
normalizedItems.forEach((item) => {
|
||||
const doc = sourceById.get(item.documentId) ?? null;
|
||||
if (doc) {
|
||||
enqueueTree(doc, targetParentId, Boolean(item.recursive));
|
||||
}
|
||||
});
|
||||
|
||||
if (copyQueue.length === 0) {
|
||||
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
|
||||
|
||||
for (const item of copyQueue) {
|
||||
const newId = newIdByOldId.get(item.old.id)!;
|
||||
const parentId = item.newParentId;
|
||||
|
||||
if (!existingTitleSetByParent.has(parentId)) {
|
||||
seedTitleSet(parentId);
|
||||
}
|
||||
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
|
||||
existingTitleSetByParent.set(parentId, titleSet);
|
||||
|
||||
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
|
||||
titleSet.add(newTitle);
|
||||
|
||||
await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: newId,
|
||||
workspaceId: wid,
|
||||
parentId,
|
||||
accessScope: (item.old.access_scope ?? "private") as "private" | "shared" | "public",
|
||||
title: newTitle,
|
||||
content: item.old.content ?? [],
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(newId, newTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
userId: auth.userId,
|
||||
sourceDocId: item.old.id,
|
||||
targetDocId: newId,
|
||||
});
|
||||
insertedDocs.push({ oldId: item.old.id, newId });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
type CreateChildPayload = {
|
||||
parentId: string | null;
|
||||
@@ -11,6 +21,58 @@ type CreateChildPayload = {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
if (typeof parentId === "undefined") {
|
||||
return NextResponse.json({ error: "缺少 parentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let workspaceId: string;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: parentId });
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||
}
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
workspaceId = ensured.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedTitle = title && title.trim().length > 0 ? title.trim() : "未命名页面";
|
||||
const contentPayload = Array.isArray(blocks) ? blocks : [];
|
||||
const pageId = makeId();
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: resolvedTitle,
|
||||
accessScope,
|
||||
content: contentPayload,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
pageId: created.id,
|
||||
title: created.title ?? resolvedTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -7,6 +7,10 @@ import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/docume
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
@@ -25,6 +29,9 @@ async function ensureDocumentScaffold(id: string, title: string | null) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
if (isConvexEnabled()) {
|
||||
return await handleCreateRequestConvex(request);
|
||||
}
|
||||
return await handleCreateRequest(request);
|
||||
} catch (error) {
|
||||
console.error("创建页面失败", error);
|
||||
@@ -33,6 +40,83 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateRequestConvex(request: Request) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
|
||||
let workspaceId: string | null = null;
|
||||
let parentContent: Json | null = null;
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: parentId,
|
||||
});
|
||||
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
const parentContentRes = await client.query(api.documents.getContent, { userId: auth.userId, id: parentId });
|
||||
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
||||
} else {
|
||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const data = await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
title: "无标题",
|
||||
accessScope,
|
||||
content: [],
|
||||
});
|
||||
|
||||
// 为文件树创建本地目录和 index.md
|
||||
if (data?.id) {
|
||||
await ensureDocumentScaffold(data.id, data.title ?? "无标题");
|
||||
}
|
||||
|
||||
if (parentId && data) {
|
||||
const existingBlocks = extractBlocksFromContent(parentContent);
|
||||
const pageReferenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: data.id,
|
||||
title: data.title ?? "无标题",
|
||||
},
|
||||
};
|
||||
const nextBlocks = [...existingBlocks, pageReferenceBlock as Json];
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: parentId,
|
||||
content: payload,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
async function handleCreateRequest(request: Request) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
@@ -43,6 +47,50 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fallbackTitle =
|
||||
sourceDoc.title?.trim() && sourceDoc.title.trim().length > 0 ? sourceDoc.title.trim() : "无标题";
|
||||
const duplicatedTitle = `${fallbackTitle} 副本`;
|
||||
|
||||
const newId = typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const duplicated = await client.mutation(api.documents.duplicate, {
|
||||
userId: auth.userId,
|
||||
sourceId: documentId,
|
||||
newId,
|
||||
title: duplicatedTitle,
|
||||
});
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
userId: auth.userId,
|
||||
sourceDocId: documentId,
|
||||
targetDocId: duplicated.id,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: duplicated.id,
|
||||
title: duplicated.title ?? duplicatedTitle,
|
||||
parent_id: duplicated.parent_id ?? null,
|
||||
sort_order: duplicated.sort_order ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -3,6 +3,9 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface EmbedPayload {
|
||||
sourceId: string;
|
||||
@@ -10,6 +13,49 @@ interface EmbedPayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
|
||||
const { sourceId, targetId }: EmbedPayload = await request.json();
|
||||
|
||||
if (!sourceId || !targetId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const targetContent = await client.query(api.documents.getContent, { userId: auth.userId, id: targetId });
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
props: {
|
||||
pageId: sourceDoc.id,
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: targetId,
|
||||
content: payload,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -8,6 +12,17 @@ interface EmptyTrashPayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface MovePayload {
|
||||
documentId: string;
|
||||
@@ -8,6 +12,20 @@ interface MovePayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.move, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
@@ -19,6 +23,31 @@ const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Table
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId, options }: OptionsPayload = await request.json();
|
||||
if (!documentId || !options) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateOptions, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
options: {
|
||||
wideLayout: options.wideLayout,
|
||||
smallText: options.smallText,
|
||||
showHeadingNumbers: options.showHeadingNumbers,
|
||||
showToc: options.showToc,
|
||||
showStructure: options.showStructure,
|
||||
protectEditing: options.protectEditing,
|
||||
showWordCount: options.showWordCount,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.purge, { userId: auth.userId, id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.restore, { userId: auth.userId, id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
interface SavePayload {
|
||||
documentId: string;
|
||||
@@ -7,6 +11,18 @@ interface SavePayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId, content }: SavePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
content,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { DocumentStats } from "@/types/page-options";
|
||||
|
||||
interface StatsPayload {
|
||||
@@ -8,6 +12,25 @@ interface StatsPayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId, stats }: StatsPayload = await request.json();
|
||||
if (!documentId || !stats) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateStats, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
wordCount: stats.wordCount,
|
||||
characterCount: stats.characterCount,
|
||||
blockCount: stats.blockCount,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
interface RenamePayload {
|
||||
documentId: string;
|
||||
@@ -7,6 +11,18 @@ interface RenamePayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const { documentId, title }: RenamePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.updateTitle, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
title,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,10 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const limit = Number.parseInt(searchParams.get("limit") ?? "12", 10);
|
||||
const assetType = searchParams.get("assetType") ?? undefined;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const items = await client.query(api.mediaAssets.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
assetType,
|
||||
includeDeleted: false,
|
||||
limit: Number.isNaN(limit) ? 12 : limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { user },
|
||||
@@ -46,6 +82,75 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
fileUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
assetType?: string;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
if (!payload.workspaceId || !payload.documentId || !payload.fileUrl) {
|
||||
return NextResponse.json({ error: "参数不完整" }, { status: 400 });
|
||||
}
|
||||
|
||||
const assetId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const asset: MediaAsset = {
|
||||
id: assetId,
|
||||
workspace_id: payload.workspaceId,
|
||||
document_id: payload.documentId,
|
||||
file_url: payload.fileUrl,
|
||||
thumbnail_url: payload.thumbnailUrl ?? payload.fileUrl,
|
||||
asset_type: payload.assetType ?? "image",
|
||||
file_name: payload.fileName ?? null,
|
||||
file_size: payload.fileSize ?? null,
|
||||
mime_type: payload.mimeType ?? null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.mediaAssets.create, {
|
||||
userId: auth.userId,
|
||||
asset: {
|
||||
id: asset.id,
|
||||
workspace_id: asset.workspace_id,
|
||||
document_id: asset.document_id,
|
||||
asset_type: asset.asset_type,
|
||||
file_url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: asset.file_name,
|
||||
file_size: asset.file_size,
|
||||
mime_type: asset.mime_type,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ asset });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { user },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -65,6 +68,151 @@ function sanitizeSubPath(input: string | undefined): string {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = (await request.json()) as BatchPayload;
|
||||
if (!payload?.action || !payload.assetIds?.length) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const assets = (await client.query(api.mediaAssets.listByIds, {
|
||||
userId: auth.userId,
|
||||
ids: payload.assetIds,
|
||||
})) as any[];
|
||||
|
||||
if (!assets?.length) {
|
||||
return NextResponse.json({ error: "未找到附件" }, { status: 404 });
|
||||
}
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
try {
|
||||
switch (payload.action) {
|
||||
case "delete": {
|
||||
for (const a of assets) {
|
||||
await client.mutation(api.mediaAssets.patchById, {
|
||||
userId: auth.userId,
|
||||
id: String(a.id),
|
||||
patch: { deleted_at: nowIso(), deleted_by: auth.userId },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
case "restore": {
|
||||
for (const a of assets) {
|
||||
await client.mutation(api.mediaAssets.patchById, {
|
||||
userId: auth.userId,
|
||||
id: String(a.id),
|
||||
patch: { deleted_at: null, deleted_by: null, purged_at: null },
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
case "rename": {
|
||||
if (payload.assetIds.length !== 1 || !payload.newName) {
|
||||
return NextResponse.json({ error: "重命名需要单个文件与新名称" }, { status: 400 });
|
||||
}
|
||||
const asset = assets[0];
|
||||
const currentName = String(asset.file_name ?? "");
|
||||
const ext = currentName.includes(".") ? `.${currentName.split(".").pop()}` : "";
|
||||
const newFileName = payload.newName.includes(".") || !ext ? payload.newName : `${payload.newName}${ext}`;
|
||||
const safeName = newFileName.replace(/[\\/]/g, "_");
|
||||
|
||||
await client.mutation(api.mediaAssets.patchById, {
|
||||
userId: auth.userId,
|
||||
id: String(asset.id),
|
||||
patch: { file_name: safeName },
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
case "copy":
|
||||
case "move": {
|
||||
if (!payload.targetDocumentId) {
|
||||
return NextResponse.json({ error: "缺少目标页面" }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetDoc = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: payload.targetDocumentId,
|
||||
});
|
||||
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const existing = (await client.query(api.mediaAssets.listByDocument, {
|
||||
userId: auth.userId,
|
||||
documentId: payload.targetDocumentId,
|
||||
limit: 500,
|
||||
})) as any[];
|
||||
const existingNames = new Set<string>(
|
||||
(existing ?? []).map((r) => (r?.file_name ?? "").toString()).filter(Boolean),
|
||||
);
|
||||
|
||||
const results: any[] = [];
|
||||
|
||||
for (const asset of assets) {
|
||||
const fileName = makeUniqueFileName(asset.file_name ?? "附件", existingNames).replace(/[\\/]/g, "_");
|
||||
const storageId = (asset as { storage_id?: string | null })?.storage_id ?? null;
|
||||
if (!storageId) continue;
|
||||
|
||||
if (payload.action === "copy") {
|
||||
const newId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: auth.userId,
|
||||
storageId: storageId as any,
|
||||
asset: {
|
||||
id: newId,
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(payload.targetDocumentId),
|
||||
asset_type: String(asset.asset_type ?? "file"),
|
||||
file_name: fileName,
|
||||
file_size: typeof asset.file_size === "number" ? asset.file_size : null,
|
||||
mime_type: (asset.mime_type ?? null) as any,
|
||||
},
|
||||
});
|
||||
|
||||
results.push(created);
|
||||
} else {
|
||||
await client.mutation(api.mediaAssets.patchById, {
|
||||
userId: auth.userId,
|
||||
id: String(asset.id),
|
||||
patch: {
|
||||
workspace_id: String(targetDoc.workspace_id),
|
||||
document_id: String(payload.targetDocumentId),
|
||||
file_name: fileName,
|
||||
},
|
||||
});
|
||||
|
||||
results.push({ ...asset, workspace_id: String(targetDoc.workspace_id), document_id: String(payload.targetDocumentId), file_name: fileName });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: results });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "操作失败";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -85,4 +115,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ success: true, updated: assetIds.length });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:OCR 链路目前仍依赖 Supabase JWT/表结构,迁移阶段先显式禁用,避免 UI/接口误用。
|
||||
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -25,6 +29,32 @@ function makeExpiredDeletedAt(): string {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { assetId }: PurgePayload = await request.json().catch(() => ({}));
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const res = await client.mutation(api.mediaAssets.purgeById, {
|
||||
userId: auth.userId,
|
||||
id: assetId,
|
||||
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -86,4 +116,3 @@ export async function POST(request: Request) {
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -61,6 +65,47 @@ const resolveAssetObjectLocation = async (params: {
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId");
|
||||
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
const refreshed = await client.mutation(api.mediaAssets.refreshUrl, { userId: auth.userId, id: assetId });
|
||||
const signedUrl = (refreshed as { signedUrl?: string | null } | null)?.signedUrl ?? null;
|
||||
if (!signedUrl) {
|
||||
return NextResponse.json({ error: "生成签名链接失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
mime_type: asset.mime_type,
|
||||
file_size: asset.file_size,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { user },
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -92,6 +94,26 @@ const tryResolveExistingObjectPath = async (params: {
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
try {
|
||||
requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get("fileUrl");
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ signedUrl: fileUrl });
|
||||
}
|
||||
|
||||
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
||||
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
||||
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -17,6 +21,86 @@ const resolveAssetType = (mime: string): "image" | "video" | "audio" | "file" =>
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
try {
|
||||
auth = requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const workspaceId = String(formData.get("workspaceId") ?? "");
|
||||
const documentId = String(formData.get("documentId") ?? "");
|
||||
const mindmapIdRaw = String(formData.get("mindmapId") ?? "").trim();
|
||||
|
||||
if (!(file instanceof File) || !workspaceId || !documentId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const assetId =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const assetType = resolveAssetType(file.type || "");
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
// 1) 获取 Convex 的上传 URL(短时有效)
|
||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||
return NextResponse.json({ error: "获取上传地址失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
// 2) 上传文件到 Convex Files
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type || "application/octet-stream" },
|
||||
body: buffer,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
const text = await uploadRes.text().catch(() => "");
|
||||
return NextResponse.json({ error: `上传到 Convex 失败:${uploadRes.status} ${text}` }, { status: 500 });
|
||||
}
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = uploadJson?.storageId ?? "";
|
||||
if (!storageId) {
|
||||
return NextResponse.json({ error: "上传到 Convex 失败:缺少 storageId" }, { status: 500 });
|
||||
}
|
||||
|
||||
// 3) 写入 Convex 的 media_assets 元数据(并记录 storageId)
|
||||
const created = await client.mutation(api.mediaAssets.createWithStorage, {
|
||||
userId: auth.userId,
|
||||
storageId: storageId as any,
|
||||
asset: {
|
||||
id: assetId,
|
||||
workspace_id: workspaceId,
|
||||
document_id: documentId,
|
||||
asset_type: assetType,
|
||||
file_name: file.name || null,
|
||||
file_size: file.size,
|
||||
mime_type: file.type || null,
|
||||
},
|
||||
});
|
||||
|
||||
const asset = created as unknown as MediaAsset;
|
||||
|
||||
return NextResponse.json({
|
||||
asset,
|
||||
mindmapUrl: `asset:${asset.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,6 +32,24 @@ async function purgeTrashFolder(folder: string): Promise<number> {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -2,6 +2,9 @@ import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -19,6 +22,53 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
|
||||
if (!ops.length) {
|
||||
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
|
||||
}
|
||||
if (ops.length > 80) {
|
||||
return NextResponse.json({ error: "ops 过多(最多 80)" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
|
||||
const baseData = current?.data ?? defaultMindmapData;
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
|
||||
|
||||
await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
data: nextData,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
applied,
|
||||
errors,
|
||||
data: nextData,
|
||||
meta: {
|
||||
documentId: docId,
|
||||
mindmapId,
|
||||
actor: payload?.actor ?? null,
|
||||
reason: payload?.reason ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -70,4 +120,3 @@ export async function POST(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -112,6 +115,17 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -155,6 +169,28 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||
data?: unknown;
|
||||
createOnly?: boolean;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -203,6 +239,21 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -263,6 +314,37 @@ export async function PATCH(
|
||||
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
|
||||
) {
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
|
||||
if (action !== "restore" && action !== "purge") {
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "purge") {
|
||||
const result = await client.mutation(api.mindmaps.purge, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
}
|
||||
|
||||
const result = await client.mutation(api.mindmaps.restore, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message ?? "操作失败";
|
||||
const status = msg.includes("未找到") ? 404 : 400;
|
||||
return NextResponse.json({ error: msg }, { status });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
@@ -40,6 +43,18 @@ export async function GET(
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -89,6 +104,20 @@ export async function POST(
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const { data } = await request.json();
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -130,6 +159,18 @@ export async function DELETE(
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "");
|
||||
const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim();
|
||||
|
||||
type OnlyOfficeCallbackBody = {
|
||||
status?: number;
|
||||
@@ -11,6 +15,19 @@ type OnlyOfficeCallbackBody = {
|
||||
key?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const tryRewriteOnlyOfficeDownloadUrl = (raw: string) => {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
@@ -33,6 +50,16 @@ export async function POST(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId") || "";
|
||||
|
||||
// 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。
|
||||
// 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。
|
||||
if (ONLYOFFICE_CALLBACK_SECRET) {
|
||||
const got = normalizeSecret(searchParams.get("token") || "");
|
||||
const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET);
|
||||
if (!got || got !== expected) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null;
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 0 });
|
||||
@@ -53,6 +80,54 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:Convex 模式下,保存写回 Convex Files,并更新 media_assets.storage_id/file_url。
|
||||
const userId = String(process.env.DEV_USER_ID || "dev-user").trim() || "dev-user";
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId, id: assetId });
|
||||
if (!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 uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId });
|
||||
if (!uploadUrl || typeof uploadUrl !== "string") {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadRes = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": asset.mime_type || "application/octet-stream" },
|
||||
body: buf,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const uploadJson = (await uploadRes.json().catch(() => null)) as { storageId?: string } | null;
|
||||
const storageId = String(uploadJson?.storageId || "");
|
||||
if (!storageId) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
await client.mutation(api.mediaAssets.replaceStorageFromUpload, {
|
||||
userId,
|
||||
id: assetId,
|
||||
storageId: storageId as any,
|
||||
});
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabaseAdmin
|
||||
.from("media_assets")
|
||||
.select("id,bucket,storage_path,mime_type")
|
||||
|
||||
@@ -96,6 +96,7 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
runtimeCfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL,
|
||||
);
|
||||
const storageOverride = tryParseOriginHost(runtimeCfg.onlyofficeStorageHostOverride);
|
||||
const convexOrigin = tryParseOriginUrl(process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
const isSupabasePath =
|
||||
target.pathname.startsWith("/storage/v1/") ||
|
||||
@@ -155,7 +156,17 @@ const handle = async (request: Request, method: "GET" | "HEAD") => {
|
||||
addAllowed(storageOverride.hostname, storageOverride.port);
|
||||
}
|
||||
|
||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname)) {
|
||||
if (convexOrigin?.hostname) {
|
||||
addAllowed(convexOrigin.hostname, convexOrigin.port || "");
|
||||
if (isLocalHostname(convexOrigin.hostname)) {
|
||||
addAllowed("127.0.0.1", convexOrigin.port || "");
|
||||
addAllowed("localhost", convexOrigin.port || "");
|
||||
addAllowed("host.docker.internal", convexOrigin.port || "");
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:默认禁止代理到内网/私有地址;但如果该 hostname 被显式配置为允许(例如 Docker bridge/host 回源),则放行。
|
||||
if (isPrivateIpv4(target.hostname) && !isLocalHostname(target.hostname) && !allowedHostnames.has(target.hostname)) {
|
||||
return NextResponse.json({ error: "禁止访问内网/私有地址" }, { status: 403 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const workspaceId = searchParams.get("workspaceId");
|
||||
const pageId = searchParams.get("pageId");
|
||||
if (!workspaceId || !pageId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId 或 pageId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limit = Number(searchParams.get("limit") ?? "50");
|
||||
const offset = Number(searchParams.get("offset") ?? "0");
|
||||
|
||||
const backlinks = await client.query(api.references.listBacklinks, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
pageId,
|
||||
limit: Number.isFinite(limit) ? limit : 50,
|
||||
offset: Number.isFinite(offset) ? offset : 0,
|
||||
});
|
||||
|
||||
return NextResponse.json({ backlinks });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type DisplayMode = "inline" | "embed";
|
||||
|
||||
@@ -16,6 +19,30 @@ interface RecordReferencePayload {
|
||||
const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inline" || mode === "embed";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const body = (await request.json()) as RecordReferencePayload;
|
||||
if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) {
|
||||
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
|
||||
}
|
||||
if (!isValidDisplayMode(String(body.displayMode ?? ""))) {
|
||||
return NextResponse.json({ error: "非法的引用模式" }, { status: 400 });
|
||||
}
|
||||
|
||||
const reference = await client.mutation(api.references.record, {
|
||||
userId: auth.userId,
|
||||
workspaceId: body.workspaceId,
|
||||
sourcePageId: body.sourcePageId,
|
||||
targetPageId: body.targetPageId,
|
||||
sourceBlockId: body.sourceBlockId ?? null,
|
||||
alias: body.alias ?? null,
|
||||
displayMode: body.displayMode,
|
||||
isPreviewable: Boolean(body.isPreviewable ?? true),
|
||||
});
|
||||
|
||||
return NextResponse.json({ reference });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type {
|
||||
DocumentSearchFilters,
|
||||
DocumentSearchRequest,
|
||||
@@ -144,6 +147,89 @@ const fetchOcrMatches = async (
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const sortedByRecent = [...docs].sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const narrowed = sortedByRecent.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
const title = (row.title ?? "无标题").toLowerCase();
|
||||
return title.includes(normalizedLower);
|
||||
});
|
||||
|
||||
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: buildSnippet(row.title ?? "", normalizedQuery),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "title",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 2,
|
||||
}));
|
||||
|
||||
const response: DocumentSearchResponse = {
|
||||
results,
|
||||
recent,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface RecentPayload {
|
||||
workspaceId: string;
|
||||
@@ -7,6 +10,24 @@ interface RecentPayload {
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = getAuthedConvexClient();
|
||||
const { workspaceId, documentId }: RecentPayload = await request.json();
|
||||
|
||||
if (!workspaceId || !documentId) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
await client.mutation(api.recents.upsert, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
documentId,
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -3,6 +3,11 @@ import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
detectLocalMindmapFiles,
|
||||
detectLocalMindmapDocs,
|
||||
@@ -13,7 +18,188 @@ import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
const root = (() => {
|
||||
if (!input || typeof input !== "object") return input;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const record = input as any;
|
||||
// 兼容:某些导图结构为 { root: ... }
|
||||
return record && typeof record === "object" && "root" in record ? record.root : input;
|
||||
})();
|
||||
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value !== "string") return;
|
||||
if (!value.startsWith("asset:")) return;
|
||||
const id = value.slice("asset:".length).trim();
|
||||
if (!id) return;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
};
|
||||
|
||||
const get = (obj: unknown, key: string): unknown => {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
return (obj as Record<string, unknown>)[key];
|
||||
};
|
||||
|
||||
const walk = (node: unknown) => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
|
||||
const data = get(node, "data");
|
||||
const image = get(node, "image");
|
||||
|
||||
// 常见:node.data.image = "asset:xxx"
|
||||
push(get(data, "image"));
|
||||
// 兼容:node.image = "asset:xxx"
|
||||
push(image);
|
||||
// 兼容:node.image.url = "asset:xxx"
|
||||
push(get(image, "url"));
|
||||
// 兼容:node.data.image.url = "asset:xxx"
|
||||
push(get(get(data, "image"), "url"));
|
||||
|
||||
const children = get(node, "children");
|
||||
if (Array.isArray(children)) {
|
||||
children.forEach(walk);
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||
|
||||
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
|
||||
const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, {
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
|
||||
const activeWorkspaceId = summaries.activeWorkspaceId || bootstrap.activeWorkspaceId;
|
||||
const targetWorkspaceId = workspaceIdParam || activeWorkspaceId;
|
||||
|
||||
if (!targetWorkspaceId) {
|
||||
return NextResponse.json({ error: "暂无可用工作空间" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
const documents = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
});
|
||||
|
||||
const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
});
|
||||
|
||||
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeDeleted: true,
|
||||
});
|
||||
|
||||
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||
|
||||
const mindmapAssetChildren: Record<string, string[]> = {};
|
||||
activeMindmaps.forEach((r) => {
|
||||
const ids = extractMindmapImageAssetIdsFromData(r.data);
|
||||
if (ids.length > 0) {
|
||||
mindmapAssetChildren[r.mindmap_id] = ids;
|
||||
}
|
||||
});
|
||||
|
||||
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? targetWorkspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? targetWorkspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: r.deleted_at ?? null,
|
||||
deleted_by: r.deleted_by ?? null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const payload: SidebarInitialData = {
|
||||
activeWorkspaceId: targetWorkspaceId,
|
||||
workspaces,
|
||||
documents,
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
tableAssets: [],
|
||||
mediaAssets: [],
|
||||
};
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "拉取侧边栏数据失败" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const workspaceId = payload.workspaceId as string | undefined;
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.workspaces.switchDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
|
||||
+17
-1
@@ -29,6 +29,19 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
|
||||
const m = String(method || "").toUpperCase();
|
||||
if (m !== "GET" && m !== "HEAD") return "no-store";
|
||||
if (contentType.includes("text/html")) return "no-store";
|
||||
|
||||
const p = `/${(pathParts ?? []).join("/")}`.toLowerCase();
|
||||
// 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。
|
||||
if (p.includes("editor.bin") || p.endsWith(".bin")) {
|
||||
return "public, max-age=3600, stale-while-revalidate=600";
|
||||
}
|
||||
return "public, max-age=300, stale-while-revalidate=300";
|
||||
};
|
||||
|
||||
const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||
const incomingUrl = new URL(request.url);
|
||||
const target = new URL(
|
||||
@@ -60,6 +73,10 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => {
|
||||
stripHopByHopHeaders(outHeaders);
|
||||
outHeaders.delete("content-encoding");
|
||||
outHeaders.delete("content-length");
|
||||
outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method));
|
||||
outHeaders.delete("pragma");
|
||||
outHeaders.delete("expires");
|
||||
outHeaders.delete("set-cookie");
|
||||
|
||||
return new NextResponse(upstream.body, {
|
||||
status: upstream.status,
|
||||
@@ -83,4 +100,3 @@ export async function OPTIONS(request: NextRequest, ctx: RouteCtx) {
|
||||
const { path } = await ctx.params;
|
||||
return proxyCache(request, path ?? []);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -22,16 +23,19 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||
const useConvex = isConvexEnabled();
|
||||
|
||||
let session = null;
|
||||
try {
|
||||
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
|
||||
} catch {
|
||||
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
|
||||
session = null;
|
||||
if (!useConvex) {
|
||||
try {
|
||||
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
|
||||
} catch {
|
||||
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop };
|
||||
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
|
||||
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
|
||||
|
||||
return (
|
||||
@@ -46,9 +50,13 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<SupabaseProvider session={session}>
|
||||
{useConvex ? (
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SupabaseProvider>
|
||||
) : (
|
||||
<SupabaseProvider session={session}>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SupabaseProvider>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -32,6 +32,39 @@ window.__MNOTE_DISABLE_ONLYOFFICE_SW__ = true;
|
||||
</script>
|
||||
`.trim();
|
||||
|
||||
const XHR_REWRITE_SNIPPET = `
|
||||
<script>
|
||||
// 说明:ONLYOFFICE 在被反向代理(/onlyoffice-server)时,运行期仍可能发起指向内部端口
|
||||
// http://127.0.0.1:8081/cache/... 的绝对请求(来自 ONLYOFFICE 内部逻辑)。
|
||||
// 这会导致浏览器从 origin(3000) 跨域请求 8081 并触发 CORS 拦截。
|
||||
// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR:把内部 8081 的请求改写回同源 /onlyoffice-server/*。
|
||||
window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
(function () {
|
||||
try {
|
||||
var proxyPrefix = location.origin.replace(/\\/+$/, '') + '/onlyoffice-server';
|
||||
var internal = {
|
||||
'http://127.0.0.1:8081': true,
|
||||
'http://localhost:8081': true
|
||||
};
|
||||
function rewrite(u) {
|
||||
try {
|
||||
var abs = new URL(u, location.origin);
|
||||
var origin = abs.protocol + '//' + abs.host;
|
||||
if (!internal[origin]) return u;
|
||||
return proxyPrefix + abs.pathname + abs.search + abs.hash;
|
||||
} catch (e) {
|
||||
return u;
|
||||
}
|
||||
}
|
||||
var origOpen = XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
|
||||
return origOpen.call(this, method, rewrite(url), async, user, password);
|
||||
};
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
`.trim();
|
||||
|
||||
const stripHopByHopHeaders = (headers: Headers) => {
|
||||
// 说明:Hop-by-hop headers 不应被代理转发/透传
|
||||
const hopByHop = [
|
||||
@@ -51,8 +84,63 @@ const stripHopByHopHeaders = (headers: Headers) => {
|
||||
|
||||
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 hasSw = html.includes("window.__MNOTE_DISABLE_ONLYOFFICE_SW__");
|
||||
const hasXhr = html.includes("window.__MNOTE_ONLYOFFICE_XHR_REWRITE__");
|
||||
if (hasSw && hasXhr) return html;
|
||||
const injected = [
|
||||
hasSw ? "" : DISABLE_SERVICE_WORKER_SNIPPET,
|
||||
hasXhr ? "" : XHR_REWRITE_SNIPPET,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
return html.replace(/<head[^>]*>/i, (m) => `${m}\n${injected}\n`);
|
||||
};
|
||||
|
||||
const pickCacheControl = (pathParts: string[], contentType: string, method: string) => {
|
||||
const m = String(method || "").toUpperCase();
|
||||
if (m !== "GET" && m !== "HEAD") return "no-store";
|
||||
if (contentType.includes("text/html")) return "no-store";
|
||||
|
||||
const p = `/${(pathParts ?? []).join("/")}`;
|
||||
const lower = p.toLowerCase();
|
||||
|
||||
// 说明:这些路径通常是运行期接口/动态响应,不应缓存。
|
||||
if (
|
||||
lower.includes("/docservice/") ||
|
||||
lower.includes("/coauthoring/") ||
|
||||
lower.includes("/converter/") ||
|
||||
lower.includes("/healthcheck") ||
|
||||
lower.includes("/metrics")
|
||||
) {
|
||||
return "no-store";
|
||||
}
|
||||
|
||||
const isStaticByPath =
|
||||
lower.includes("/web-apps/") ||
|
||||
lower.includes("/sdkjs/") ||
|
||||
lower.endsWith(".js") ||
|
||||
lower.endsWith(".css") ||
|
||||
lower.endsWith(".map") ||
|
||||
lower.endsWith(".png") ||
|
||||
lower.endsWith(".jpg") ||
|
||||
lower.endsWith(".jpeg") ||
|
||||
lower.endsWith(".gif") ||
|
||||
lower.endsWith(".svg") ||
|
||||
lower.endsWith(".ico") ||
|
||||
lower.endsWith(".woff") ||
|
||||
lower.endsWith(".woff2") ||
|
||||
lower.endsWith(".ttf") ||
|
||||
lower.endsWith(".otf") ||
|
||||
lower.endsWith(".json") ||
|
||||
lower.endsWith(".wasm") ||
|
||||
lower.endsWith(".bin");
|
||||
|
||||
// 说明:ONLYOFFICE 静态资源体积大,且文件名通常稳定;这里尽量给浏览器缓存,提升二次打开速度。
|
||||
if (isStaticByPath) {
|
||||
return "public, max-age=604800, stale-while-revalidate=86400";
|
||||
}
|
||||
|
||||
return "public, max-age=300, stale-while-revalidate=300";
|
||||
};
|
||||
|
||||
const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
@@ -63,6 +151,17 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
const headers = new Headers(request.headers);
|
||||
// 说明:避免把外部 Host 传给上游
|
||||
headers.delete("host");
|
||||
// 说明:ONLYOFFICE 在被反向代理时,会根据 X-Forwarded-* 推导自身对外地址,
|
||||
// 用于生成静态资源/缓存文件的 URL。若缺失这些信息,可能会返回指向内部端口
|
||||
//(例如 http://127.0.0.1:8081/cache/...)的绝对 URL,导致浏览器跨域请求被 CORS 拦截。
|
||||
headers.set("x-forwarded-host", incomingUrl.host);
|
||||
headers.set("x-forwarded-proto", incomingUrl.protocol.replace(":", ""));
|
||||
if (incomingUrl.port) {
|
||||
headers.set("x-forwarded-port", incomingUrl.port);
|
||||
} else {
|
||||
headers.set("x-forwarded-port", incomingUrl.protocol === "https:" ? "443" : "80");
|
||||
}
|
||||
headers.set("x-forwarded-prefix", "/onlyoffice-server");
|
||||
// 说明:避免上游返回 gzip 后被 Node fetch 自动解压,但仍带着 content-encoding,
|
||||
// 导致浏览器二次解压报 ERR_CONTENT_DECODING_FAILED。
|
||||
headers.set("accept-encoding", "identity");
|
||||
@@ -89,6 +188,10 @@ const proxy = async (request: NextRequest, pathParts: string[]) => {
|
||||
outHeaders.delete("content-length");
|
||||
|
||||
const contentType = upstream.headers.get("content-type") || "";
|
||||
outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method));
|
||||
outHeaders.delete("pragma");
|
||||
outHeaders.delete("expires");
|
||||
outHeaders.delete("set-cookie");
|
||||
if (contentType.includes("text/html")) {
|
||||
const html = await upstream.text();
|
||||
const injected = injectDisableServiceWorker(html);
|
||||
|
||||
@@ -15,6 +15,7 @@ declare global {
|
||||
__MNOTE_ONLYOFFICE_ERRLOG__?: Array<Record<string, unknown>>;
|
||||
__MNOTE_ONLYOFFICE_ERR_HOOKED__?: boolean;
|
||||
__MNOTE_ONLYOFFICE_DOMPATCHED__?: boolean;
|
||||
__MNOTE_ONLYOFFICE_DEBUG__?: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +133,97 @@ const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return btoa(binary).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
};
|
||||
|
||||
const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => {
|
||||
// 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向
|
||||
// `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。
|
||||
// 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。
|
||||
// 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。
|
||||
if (typeof window === "undefined") return;
|
||||
if (!baseUrl) return;
|
||||
|
||||
const normalizedBase = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
const isProxyMode = normalizedBase === "/onlyoffice-server" || normalizedBase.endsWith("/onlyoffice-server");
|
||||
if (!isProxyMode) return;
|
||||
|
||||
const proxyPrefix = (() => {
|
||||
if (/^https?:\/\//i.test(normalizedBase)) return normalizedBase;
|
||||
return `${window.location.origin.replace(/\/+$/, "")}${normalizedBase}`;
|
||||
})();
|
||||
|
||||
const internalOrigins = new Set<string>(["http://127.0.0.1:8081", "http://localhost:8081"]);
|
||||
try {
|
||||
if (onlyofficeBaseUrlDesktop) {
|
||||
const u = new URL(onlyofficeBaseUrlDesktop);
|
||||
internalOrigins.add(`${u.protocol}//${u.host}`);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const patchWindow = (win: Window) => {
|
||||
try {
|
||||
if ((win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__) return;
|
||||
const rewriteUrl = (input: string) => {
|
||||
try {
|
||||
const u = new (win as any).URL(input, (win as any).location?.origin || window.location.origin);
|
||||
const origin = `${u.protocol}//${u.host}`;
|
||||
if (!internalOrigins.has(origin)) return input;
|
||||
return `${proxyPrefix}${u.pathname}${u.search}${u.hash}`;
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||
if (typeof origOpen !== "function") return;
|
||||
// eslint-disable-next-line no-extend-native
|
||||
(win as any).XMLHttpRequest.prototype.open = function openPatched(
|
||||
method: string,
|
||||
url: string,
|
||||
async?: boolean,
|
||||
user?: string | null,
|
||||
password?: string | null,
|
||||
) {
|
||||
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
|
||||
// eslint-disable-next-line prefer-rest-params
|
||||
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
|
||||
};
|
||||
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
patchWindow(window);
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
try {
|
||||
const frames = Array.from(document.querySelectorAll("iframe"));
|
||||
for (const f of frames) {
|
||||
try {
|
||||
const w = (f as HTMLIFrameElement).contentWindow;
|
||||
if (!w) continue;
|
||||
// 说明:同源时才能访问 location;跨域会抛异常,直接跳过。
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
w.location?.origin;
|
||||
patchWindow(w);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (Date.now() - start > 120_000) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const docTypeFromExt = (ext: string) => {
|
||||
const word = ["doc", "docx", "odt", "rtf"];
|
||||
const slide = ["ppt", "pptx", "odp"];
|
||||
@@ -167,9 +259,53 @@ export default function OnlyOfficePage() {
|
||||
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
|
||||
const mode = (params.get("mode") ?? "edit") as EditorMode;
|
||||
const assetId = params.get("assetId") ?? "";
|
||||
const channel = (params.get("channel") ?? "").trim().toLowerCase();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []);
|
||||
const baseUrl = runtimeConfig.onlyofficeBaseUrlWeb || runtimeConfig.onlyofficeBaseUrl;
|
||||
const baseUrlCandidates = useMemo(() => {
|
||||
const uniq: string[] = [];
|
||||
const push = (v?: string | null) => {
|
||||
const s = String(v || "").trim().replace(/\/+$/, "");
|
||||
if (!s) return;
|
||||
if (!uniq.includes(s)) uniq.push(s);
|
||||
};
|
||||
|
||||
// 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL),
|
||||
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
|
||||
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
|
||||
try {
|
||||
push("/onlyoffice-server");
|
||||
push(`${window.location.origin.replace(/\/+$/, "")}/onlyoffice-server`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 说明:默认优先使用运行期根据 isDesktop 归一化后的 onlyofficeBaseUrl;
|
||||
// 如遇到端口转发/本机服务不可达,可自动回退到另一套配置。
|
||||
if (channel === "web") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
return uniq;
|
||||
}
|
||||
if (channel === "desktop") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
return uniq;
|
||||
}
|
||||
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
if (runtimeConfig.isDesktop) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
} else {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
return uniq;
|
||||
}, [channel, runtimeConfig]);
|
||||
|
||||
const [baseUrlIndex, setBaseUrlIndex] = useState(0);
|
||||
const baseUrl = baseUrlCandidates[baseUrlIndex] || "";
|
||||
const storageHostOverride = runtimeConfig.onlyofficeStorageHostOverride;
|
||||
const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin;
|
||||
const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin;
|
||||
@@ -293,15 +429,33 @@ export default function OnlyOfficePage() {
|
||||
// 如果我们已经配置了专用回源(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);
|
||||
const isConvexStorageUrl = (() => {
|
||||
// 说明:Convex Files 的直链通常形如:
|
||||
// - http://127.0.0.1:3210/api/storage/<id>
|
||||
// - https://<convex-host>/api/storage/<id>
|
||||
// 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000),
|
||||
// 进而导致 ONLYOFFICE 报 “下载失败(-4)”。
|
||||
try {
|
||||
const u = new URL(fileUrl);
|
||||
return u.pathname.startsWith("/api/storage/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
let base =
|
||||
storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl
|
||||
? 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");
|
||||
let alreadyProxy = raw.pathname.includes("/api/onlyoffice/proxy");
|
||||
|
||||
const isLocalHost =
|
||||
raw.hostname === "127.0.0.1" || raw.hostname === "localhost" || raw.hostname === "host.docker.internal";
|
||||
|
||||
// 关键修复:当 fileUrl 已经是 /api/onlyoffice/proxy,但来源是外网 https(例如 frp/隧道域名)时,
|
||||
// ONLYOFFICE 容器会去请求该 https 地址并因证书/自签失败,从而报“下载失败(-4)”。
|
||||
@@ -312,11 +466,23 @@ export default function OnlyOfficePage() {
|
||||
raw.host = po.host;
|
||||
base = raw.toString();
|
||||
}
|
||||
|
||||
// 关键兜底:OnlyOffice 的 document.url 由“文档服务器容器”去拉取。
|
||||
// 如果这里是 localhost/127.0.0.1(对容器而言指向它自己),会导致“下载失败(-4)”。
|
||||
// 因此在配置了 proxyOrigin 时,强制走 /api/onlyoffice/proxy 把回源留给 Next 服务端完成。
|
||||
if (!alreadyProxy && proxyOrigin && isLocalHost) {
|
||||
const proxyBase = proxyOrigin || window.location.origin;
|
||||
const proxy = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(base));
|
||||
base = proxy.toString();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
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();
|
||||
alreadyProxy = true;
|
||||
}
|
||||
|
||||
const u = new URL(base);
|
||||
@@ -337,6 +503,25 @@ export default function OnlyOfficePage() {
|
||||
}
|
||||
}, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.__MNOTE_ONLYOFFICE_DEBUG__ = {
|
||||
pageOrigin: window.location.origin,
|
||||
baseUrl,
|
||||
proxyOrigin,
|
||||
callbackOrigin,
|
||||
fileUrlInput: fileUrl,
|
||||
resolvedFileUrl,
|
||||
fileName,
|
||||
fileType,
|
||||
mode,
|
||||
assetId,
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [assetId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl) {
|
||||
setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。");
|
||||
@@ -346,6 +531,28 @@ export default function OnlyOfficePage() {
|
||||
setError("缺少 fileUrl 参数。");
|
||||
return;
|
||||
}
|
||||
|
||||
setupOnlyOfficeInternalRequestRewrite(baseUrl, runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
|
||||
// 说明:外网访问(例如 frp/隧道)时,OnlyOffice 文档服务器运行在本机 Docker 容器内,无法直接访问
|
||||
// document.url 里的 127.0.0.1/localhost。此时必须把 document.url 指向一个“容器可访问”的 Next Origin
|
||||
//(onlyofficeProxyOrigin / onlyofficeProxyOriginWeb),让 Next 服务端代为回源下载。
|
||||
try {
|
||||
const pageHost = window.location.hostname;
|
||||
const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost";
|
||||
const isPageRemote = !isPageLocal;
|
||||
const u = new URL(fileUrl);
|
||||
const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal";
|
||||
if (isPageRemote && isFileLocal && !proxyOrigin) {
|
||||
setError(
|
||||
"外网访问时检测到 fileUrl 为本机地址(127.0.0.1/localhost),但未配置 onlyofficeProxyOriginWeb。请在 public/mnote-env.json 配置 onlyofficeProxyOriginWeb/onlyofficeCallbackOriginWeb(例如 http://host.docker.internal:3000 或当前 Docker 可达的主机 IP)。",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`;
|
||||
loadScript(scriptUrl)
|
||||
.then(async () => {
|
||||
@@ -462,9 +669,15 @@ export default function OnlyOfficePage() {
|
||||
});
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
// 说明:优先“无感回退”到备选 baseUrl(常见于本机 8081 未启动/外网转发不可达)。
|
||||
const hasNext = baseUrlIndex + 1 < baseUrlCandidates.length;
|
||||
if (hasNext) {
|
||||
setBaseUrlIndex((i) => i + 1);
|
||||
return;
|
||||
}
|
||||
setError(err.message);
|
||||
});
|
||||
}, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]);
|
||||
}, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,51 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
return typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
});
|
||||
|
||||
const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
|
||||
if (firstDoc?.id) {
|
||||
redirect(`/documents/${firstDoc.id}`);
|
||||
}
|
||||
|
||||
const docId = makeId();
|
||||
await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: docId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
parentId: null,
|
||||
title: "新页面",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
redirect(`/documents/${docId}`);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const session = (await supabase.auth.getSession()).data.session;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user