/** * /database/api/* 树与授权端点(契约 9.1/9.2 + C2 过渡搜索)。 * 约定:绝对路径;actorOrNull 前置;业务全走 filelib 服务层;错误统一 sendRouteError。 */ import type { FastifyInstance } from "fastify"; import { breadcrumb, createNode, getEffectiveRole, listChildren, moveNode, renameNode, softDeleteNode, updateNodeDescription, type FileLibActor, type InitialGrant, } from "../filelib/treeService.js"; import { forceAdjustGrants, listGrants, putGrants, revokeGrant, setIndependentPermission, } from "../filelib/grantService.js"; import { FileLibError } from "../filelib/model.js"; import { actorOrNull, bodyObject, optionalString, requireString, sendRouteError, treeDeps, type FileLibRouteDeps, } from "../filelib/routeShared.js"; export async function registerFileLibRoutes( app: FastifyInstance, deps: FileLibRouteDeps, ): Promise { const tree = treeDeps(deps); const grantDeps = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver }; /* ------------------------------------------------------------ 身份 */ // 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。 app.get("/database/api/me", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin }; }); /* ------------------------------------------------------------ 树节点 */ app.get("/database/api/nodes", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const query = request.query as { parentId?: string }; const parentId = query.parentId === undefined || query.parentId === "" ? null : query.parentId; return { nodes: await listChildren(tree, actor, parentId) }; } catch (error) { return sendRouteError(reply, error); } }); app.post("/database/api/nodes", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const body = bodyObject(request.body); const parentIdRaw = body["parentId"]; if (parentIdRaw !== null && typeof parentIdRaw !== "string") { throw new FileLibError(400, "invalid_request", "parentId must be a string or null"); } const kindRaw = requireString(body, "kind"); if (kindRaw !== "FOLDER" && kindRaw !== "PROJECT") { throw new FileLibError(400, "invalid_request", "kind must be FOLDER or PROJECT"); } const grants = parseGrants(body["grants"]); const description = optionalString(body, "description"); const node = await createNode(tree, actor, { parentId: parentIdRaw, kind: kindRaw, name: requireString(body, "name"), description: description || undefined, grants, }); return reply.status(201).send({ node }); } catch (error) { return sendRouteError(reply, error); } }); app.get("/database/api/nodes/:id", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; const role = await getEffectiveRole(tree, actor, id); // 无权限即 404(D8) const node = await deps.prisma.fileLibNode.findFirst({ where: { id, organizationId: deps.organizationId }, }); if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); const settings = node.kind === "PROJECT" ? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } }) : null; return { node: { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, description: node.description, role, provisionStatus: node.provisionStatus, independentPermission: settings?.independentPermissionsEnabled ?? false, createdAt: node.createdAt, updatedAt: node.updatedAt, }, }; } catch (error) { return sendRouteError(reply, error); } }); app.patch("/database/api/nodes/:id", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; const body = bodyObject(request.body); const name = optionalString(body, "name"); const hasParent = Object.prototype.hasOwnProperty.call(body, "parentId"); const hasDesc = Object.prototype.hasOwnProperty.call(body, "description"); if (name === undefined && !hasParent && !hasDesc) { throw new FileLibError(400, "invalid_request", "nothing to update (name? parentId? description?)"); } let node; if (name !== undefined) node = await renameNode(tree, actor, id, name); if (hasParent) { const parentId = body["parentId"]; if (parentId !== null && typeof parentId !== "string") { throw new FileLibError(400, "invalid_request", "parentId must be a string or null"); } node = await moveNode(tree, actor, id, parentId); } if (hasDesc) { const d = body["description"]; if (d !== null && typeof d !== "string") { throw new FileLibError(400, "invalid_request", "description must be a string or null"); } node = await updateNodeDescription(tree, actor, id, typeof d === "string" ? d : null); } return { node }; } catch (error) { return sendRouteError(reply, error); } }); app.delete("/database/api/nodes/:id", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; await softDeleteNode(tree, actor, id); return reply.status(204).send(); } catch (error) { return sendRouteError(reply, error); } }); app.get("/database/api/nodes/:id/breadcrumb", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; return { breadcrumb: await breadcrumb(tree, actor, id) }; } catch (error) { return sendRouteError(reply, error); } }); app.get("/database/api/nodes/:id/effective-permission", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; return { role: await getEffectiveRole(tree, actor, id) }; } catch (error) { return sendRouteError(reply, error); } }); /* ------------------------------------------------------------ 授权 */ app.get("/database/api/nodes/:id/grants", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; return { grants: await listGrants(grantDeps, actor, id) }; } catch (error) { return sendRouteError(reply, error); } }); app.put("/database/api/nodes/:id/grants", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; const body = bodyObject(request.body); const items = parseGrants(body["grants"]); if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants"); return await putGrants(grantDeps, actor, id, items); } catch (error) { return sendRouteError(reply, error); } }); app.delete("/database/api/nodes/:id/grants/:grantId", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id, grantId } = request.params as { id: string; grantId: string }; await revokeGrant(grantDeps, actor, id, grantId); return reply.status(204).send(); } catch (error) { return sendRouteError(reply, error); } }); // D19 高危通道:网站管理员凭 id 强制调整,全部留 admin.force_adjust 审计。 app.put("/database/api/admin/nodes/:id/grants", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; const body = bodyObject(request.body); const items = parseGrants(body["grants"]); if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants"); return await forceAdjustGrants(grantDeps, actor, id, items); } catch (error) { return sendRouteError(reply, error); } }); app.put("/database/api/projects/:id/independent-permission", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const { id } = request.params as { id: string }; const body = bodyObject(request.body); if (typeof body["enabled"] !== "boolean") { throw new FileLibError(400, "invalid_request", "enabled must be a boolean"); } return await setIndependentPermission(grantDeps, actor, id, body["enabled"]); } catch (error) { return sendRouteError(reply, error); } }); /* ------------------------------------------------------------ Group 搜索(过渡) */ // 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。 app.get("/database/api/groups/search", async (request, reply) => { const actor = await actorOrNull(request, reply, deps); if (actor === null) return reply; try { const q = ((request.query as { q?: string }).q ?? "").trim(); const teams = await deps.prisma.team.findMany({ where: { organizationId: deps.organizationId, archivedAt: null, ...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }), }, take: 20, orderBy: { name: "asc" }, select: { id: true, name: true }, }); return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) }; } catch (error) { return sendRouteError(reply, error); } }); } function parseGrants(raw: unknown): InitialGrant[] | undefined { if (raw === undefined) return undefined; if (!Array.isArray(raw)) throw new FileLibError(400, "invalid_request", "grants must be an array"); return raw.map((item) => { if (typeof item !== "object" || item === null) { throw new FileLibError(400, "invalid_request", "grant entries must be objects"); } const grant = item as Record; const principalType = grant["principalType"]; if (principalType !== "USER" && principalType !== "GROUP") { throw new FileLibError(400, "invalid_request", "grant.principalType must be USER or GROUP"); } const role = grant["role"]; if (role !== "VIEW" && role !== "EDIT" && role !== "MANAGE") { throw new FileLibError(400, "invalid_request", "grant.role must be VIEW, EDIT or MANAGE"); } if (typeof grant["principalId"] !== "string" || grant["principalId"] === "") { throw new FileLibError(400, "invalid_request", "grant.principalId must be a non-empty string"); } return { principalType, principalId: grant["principalId"], role }; }); }