forked from EduCraft/curriculum-project-hub
311 lines
12 KiB
TypeScript
311 lines
12 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 创建按钮显隐)。
|
|
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 };
|
|
});
|
|
}
|