Files
curriculum-project-hub/hub/src/database/routes/filelibRoutes.ts
T
cdeb29ccf2 fix(database): 补回文件库的「授权」tab 与 /me 的显示名
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
2026-07-26 20:19:47 +08:00

302 lines
11 KiB
TypeScript

/**
* /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 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
});
/* ------------------------------------------------------------ 树节点 */
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 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
}
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 };
});
}