forked from EduCraft/curriculum-project-hub
feat(database): init database folder frontend and permission
This commit is contained in:
@@ -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 };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user