0.3.5 共享功能修复

This commit is contained in:
liaibo
2026-01-24 12:32:51 +08:00
parent 25923f308c
commit 3c3f407f4b
44 changed files with 3754 additions and 420 deletions
+250 -14
View File
@@ -4,6 +4,7 @@ import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time";
import { collectSubtree } from "./_utils/documentTree";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
import { extractTextFromDocumentContent } from "./_utils/text";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
@@ -17,6 +18,8 @@ async function requireUserId(ctx: any): Promise<string> {
type SharePermission = "read" | "edit";
type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean };
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
const member = await ctx.db
.query("workspace_members")
@@ -28,13 +31,17 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str
return member;
}
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
const direct = await ctx.db
.query("document_shares")
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
.first();
if (direct) {
return direct.permission as SharePermission;
return {
permission: direct.permission as SharePermission,
disableDownload: Boolean((direct as any).disable_download),
disableCopy: Boolean((direct as any).disable_copy),
};
}
let parentId: string | null = doc.parent_id ?? null;
@@ -44,7 +51,11 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
.withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId))
.first();
if (parentShare && parentShare.include_descendants) {
return parentShare.permission as SharePermission;
return {
permission: parentShare.permission as SharePermission,
disableDownload: Boolean((parentShare as any).disable_download),
disableCopy: Boolean((parentShare as any).disable_copy),
};
}
const parent = await ctx.db
@@ -57,7 +68,12 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi
return null;
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
const memberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
@@ -77,6 +93,8 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
};
let best: SharePermission | null = null;
let disableDownload = false;
let disableCopy = false;
const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => {
const shares = await ctx.db
@@ -89,23 +107,25 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
if (!groupIds.has(gid)) continue;
if (requireIncludeDesc && !s.include_descendants) continue;
disableDownload = disableDownload || Boolean((s as any).disable_download);
disableCopy = disableCopy || Boolean((s as any).disable_copy);
const perm = await permissionFromGroup(documentId, gid);
if (perm === "edit") {
best = "edit";
return true;
} else {
best = best ?? "read";
}
best = best ?? "read";
}
return false;
};
// 当前页面是否被群组公开
if (await checkDocId(doc.id, false)) return "edit";
await checkDocId(doc.id, false);
// 沿父链查找“包含子页面”的群组公开
let parentId: string | null = doc.parent_id ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
if (await checkDocId(parentId, true)) return "edit";
await checkDocId(parentId, true);
const parent = await ctx.db
.query("documents")
@@ -114,7 +134,48 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string):
parentId = parent?.parent_id ?? null;
}
return best;
if (!best) return null;
return { permission: best, disableDownload, disableCopy };
}
async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
const policy = await resolveGroupSharePolicy(ctx, doc, userId);
return policy?.permission ?? null;
}
async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) {
// 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系;
// 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。
const directShares = await ctx.db
.query("document_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
for (const row of directShares) {
await ctx.db.delete(row._id);
}
const groupShares = await ctx.db
.query("document_group_shares")
.withIndex("by_document", (q: any) => q.eq("document_id", documentId))
.collect();
const touchedGroupIds = new Set<string>();
for (const row of groupShares) {
touchedGroupIds.add(String(row.group_id));
await ctx.db.delete(row._id);
}
// 删除该文档在相关群组下的“用户可编辑覆盖权限”
for (const groupId of touchedGroupIds) {
const perms = await ctx.db
.query("document_group_user_permissions")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId))
.collect();
for (const p of perms) {
if (String(p.document_id) !== documentId) continue;
await ctx.db.delete(p._id);
}
}
}
export const getMeta = query({
@@ -135,14 +196,18 @@ export const getMeta = query({
}
let canEdit = false;
let disableDownload = false;
let disableCopy = false;
if (doc.user_id === userId) {
canEdit = true;
} else if (doc.access_scope === "public") {
canEdit = false;
} else {
const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
if (!perm) return null;
canEdit = perm === "edit";
const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId));
if (!policy) return null;
canEdit = policy.permission === "edit";
disableDownload = policy.disableDownload;
disableCopy = policy.disableCopy;
}
return {
id: doc.id,
@@ -150,6 +215,8 @@ export const getMeta = query({
workspace_id: doc.workspace_id,
access_scope: doc.access_scope,
can_edit: canEdit,
disable_download: disableDownload,
disable_copy: disableCopy,
title: doc.title ?? null,
parent_id: doc.parent_id ?? null,
created_at: doc.created_at,
@@ -168,6 +235,37 @@ export const getMeta = query({
},
});
export const getPermissionForUser = query({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
const doc = await ctx.db
.query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id))
.first();
if (!doc) return null;
if (doc.deleted_at != null) return null;
// workspace 权限
await requireWorkspaceMember(ctx, doc.workspace_id, args.userId);
if (doc.user_id === args.userId) {
return { permission: "edit" as const, disableDownload: false, disableCopy: false };
}
if (doc.access_scope === "public") {
return { permission: "read" as const, disableDownload: false, disableCopy: false };
}
const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId));
if (!policy) return null;
return {
permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const),
disableDownload: policy.disableDownload,
disableCopy: policy.disableCopy,
};
},
});
export const getMetaForIngest = internalQuery({
args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => {
@@ -379,6 +477,134 @@ export const listByWorkspace = query({
},
});
export const listSearchDataByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// workspace 权限
try {
await requireWorkspaceMember(ctx, args.workspaceId, userId);
} catch {
return [];
}
const docs = await ctx.db
.query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect();
const alive = docs.filter((d) => d.deleted_at == null);
const shares = await ctx.db
.query("document_shares")
.withIndex("by_workspace_shared_with", (q: any) =>
q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId),
)
.collect();
const directShares = new Map<
string,
{ permission: SharePermission; includeDescendants: boolean }
>();
for (const s of shares) {
directShares.set(s.document_id, {
permission: s.permission as SharePermission,
includeDescendants: Boolean(s.include_descendants),
});
}
const groupMemberships = await ctx.db
.query("group_members")
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
.collect();
const groupIds = new Set<string>(groupMemberships.map((m: any) => String(m.group_id)));
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
if (groupIds.size > 0) {
for (const gid of groupIds) {
const rows = await ctx.db
.query("document_group_shares")
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
.collect();
for (const r of rows) {
const existing = directGroupShares.get(r.document_id);
if (!existing) {
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
} else {
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
}
}
}
}
const parentById = new Map<string, string | null>();
for (const d of alive) {
parentById.set(d.id, d.parent_id ?? null);
}
const shareAccessCache = new Map<string, boolean>();
const canAccessByShare = (docId: string): boolean => {
const cached = shareAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directShares.has(docId)) {
shareAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
shareAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
shareAccessCache.set(docId, false);
return false;
};
const groupAccessCache = new Map<string, boolean>();
const canAccessByGroupShare = (docId: string): boolean => {
const cached = groupAccessCache.get(docId);
if (typeof cached === "boolean") return cached;
if (directGroupShares.has(docId)) {
groupAccessCache.set(docId, true);
return true;
}
let parentId = parentById.get(docId) ?? null;
for (let depth = 0; depth < 60 && parentId; depth += 1) {
const parentShare = directGroupShares.get(parentId);
if (parentShare && parentShare.includeDescendants) {
groupAccessCache.set(docId, true);
return true;
}
parentId = parentById.get(parentId) ?? null;
}
groupAccessCache.set(docId, false);
return false;
};
return alive
.filter((d) => {
if (d.user_id === userId) return true;
if (d.access_scope === "public") return true;
return canAccessByShare(d.id) || canAccessByGroupShare(d.id);
})
.map((d) => ({
id: d.id,
workspace_id: d.workspace_id,
title: d.title ?? null,
created_at: d.created_at ?? null,
updated_at: d.updated_at ?? null,
raw_text:
typeof d.raw_text === "string" && d.raw_text.trim()
? d.raw_text
: extractTextFromDocumentContent(d.content ?? null),
}));
},
});
export const listTrashedByWorkspace = query({
args: { workspaceId: v.string() },
handler: async (ctx, args) => {
@@ -426,6 +652,7 @@ export const create = mutation({
const title = (args.title ?? "无标题") || "无标题";
const content = typeof args.content === "undefined" ? [] : args.content;
const rawText = extractTextFromDocumentContent(content);
await ctx.db.insert("documents", {
id: args.id,
@@ -434,6 +661,7 @@ export const create = mutation({
parent_id: args.parentId,
title,
content,
raw_text: rawText,
access_scope: args.accessScope,
sort_order: sortOrder,
is_starred: false,
@@ -489,7 +717,8 @@ export const updateContent = mutation({
if (perm !== "edit") throw new Error("无权限");
}
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
const rawText = extractTextFromDocumentContent(args.content);
await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts });
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
@@ -641,6 +870,9 @@ export const softDelete = mutation({
for (const item of subtree) {
if (item.deleted_at != null) continue;
await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
// 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。
// 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
moved += 1;
}
@@ -710,6 +942,7 @@ export const purge = mutation({
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
for (const item of subtree) {
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
await ctx.db.delete(item._id);
}
@@ -739,6 +972,7 @@ export const emptyTrashByWorkspace = mutation({
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
for (const d of toDelete) {
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
await ctx.db.delete(d._id);
}
@@ -857,6 +1091,7 @@ export const duplicate = mutation({
const ts = nowIso();
const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题";
const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`;
const rawText = extractTextFromDocumentContent(source.content ?? []);
await ctx.db.insert("documents", {
id: args.newId,
@@ -865,6 +1100,7 @@ export const duplicate = mutation({
parent_id: source.parent_id,
title,
content: source.content ?? [],
raw_text: rawText,
access_scope: source.access_scope,
sort_order: sortOrder,
is_starred: false,