feat(database): init database folder frontend and permission

This commit is contained in:
ymy
2026-07-23 23:41:11 +08:00
parent 5df1900ca8
commit 4021e58d5d
67 changed files with 9436 additions and 150 deletions
+310
View File
@@ -0,0 +1,310 @@
/**
* /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<void> {
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<string, unknown>;
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 };
});
}