/** * `/database/*` route aggregator. * * Owns the `/database` HTTP surface (single place new sub-routes get wired in). * Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the * literal string it serves. * * 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由 * `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database, * 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点: * * /database/config — unauthenticated bootstrap: silo org slug + dev toggle * /database/api/stats — 概览页统计(需登录 + silo org OWNER/ADMIN) * /database/dev-login — DEV ONLY bypass, registered only when the flag is on * * The dev bypass (/database/dev-login) is self-contained here and gated by * allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS + * NODE_ENV). Production requires real Feishu OAuth. * * NOTE: concrete routes here MUST be registered before the SPA fallback * (registerDatabaseSpa serves /database and /database/*), or the wildcard would * shadow them. */ import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import path from "node:path"; import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; import { createManifestStubAdapter } from "../filelib/exportService.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js"; import { actorOrNull, sendRouteError } from "../filelib/routeShared.js"; import type { FileLibRouteDeps } from "../filelib/routeShared.js"; export interface DatabaseRouteConfig { readonly prisma: PrismaClient; /** HMAC secret for the signed session cookie — reused from the admin plane. */ readonly sessionSecret: string; /** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */ readonly siloOrganizationSlug: string; /** DEV ONLY. Enables the one-click button and the /database/dev-login route. */ readonly allowDevLoginBypass: boolean; } export async function registerDatabaseRoutes( app: FastifyInstance, config: DatabaseRouteConfig, ): Promise { // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 let filelibDepsForStats: FileLibRouteDeps | null = null; // 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。 // 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册, // 两处并存是为了兼容既有前端调用点。 app.get("/database/config", async () => ({ orgSlug: config.siloOrganizationSlug, devLoginEnabled: config.allowDevLoginBypass, })); // 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的 // 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。 app.get("/database/api/stats", async (request, reply) => { if (filelibDepsForStats === null) { return reply.status(503).send({ error: { code: "unavailable", message: "filelib not ready" } }); } const actor = await actorOrNull(request, reply, filelibDepsForStats); if (actor === null) return reply; if (!actor.isWebsiteAdmin) { return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } }); } try { return await loadDashboardStats(config.prisma, filelibDepsForStats); } catch (error) { return sendRouteError(reply, error); } }); // DEV ONLY bypass — self-contained here, registered only when the flag is on // (see ./plugin.ts). Mints a session for an existing OWNER/ADMIN, reusing the // scoped SessionIdentity shape the Feishu OAuth callback produces so the // session guard behaves identically. Never registered in production. if (config.allowDevLoginBypass) { app.get("/database/dev-login", async (_request, reply) => { const membership = await config.prisma.organizationMembership.findFirst({ where: { revokedAt: null, role: { in: ["OWNER", "ADMIN"] }, organization: { status: "ACTIVE" }, }, select: { userId: true, organizationId: true }, orderBy: { createdAt: "asc" }, }); if (membership === null) { return reply.status(404).send({ error: { code: "no_admin", message: "no active OWNER/ADMIN to impersonate" } }); } const identity = await config.prisma.feishuUserIdentity.findFirst({ where: { userId: membership.userId, connection: { organizationId: membership.organizationId, status: "ACTIVE" }, }, select: { id: true, connectionId: true, connection: { select: { organizationId: true } } }, }); if (identity === null) { return reply.status(404).send({ error: { code: "no_identity", message: "admin has no active scoped Feishu identity" } }); } const token = signSession( { userId: membership.userId, feishuIdentityId: identity.id, feishuConnectionId: identity.connectionId, feishuOrganizationId: identity.connection.organizationId, }, config.sessionSecret, ); // Local dev is http://127.0.0.1, so secure:false. This route only ever // runs outside production (double-gated in ./plugin.ts). reply.setCookie(SESSION_COOKIE_NAME, token, { path: "/", httpOnly: true, sameSite: "lax", secure: false, maxAge: 7 * 24 * 60 * 60, }); reply.log.warn({ userId: membership.userId, orgId: membership.organizationId }, "DEV database login bypass used"); return reply.redirect("/database/dashboard"); }); } // 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。 // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); // 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。 const siloOrg = await config.prisma.organization.findUnique({ where: { slug: config.siloOrganizationSlug }, select: { id: true }, }); if (siloOrg === null) { app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered"); return; } const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos"); const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json")); const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"]; const filelibDeps: FileLibRouteDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret, organizationId: siloOrg.id, storageRoot, groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === "" ? createMemberGroupResolver(config.prisma) : createHttpGroupResolver({ baseUrl: groupServiceUrl }), versionStore, exportAdapters: [createManifestStubAdapter(versionStore)], }; await registerFileLibRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, siloOrganizationSlug: config.siloOrganizationSlug, allowDevLoginBypass: config.allowDevLoginBypass, }); // 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。 // SPA 化后目标是真路由(不再是 #library 锚点)。 app.get("/database/library", async (_request, reply) => reply.redirect("/database/dashboard/library"), ); filelibDepsForStats = filelibDeps; } /* ------------------------------------------------------------ 概览页统计 */ interface DashboardStats { readonly folders: number; readonly projects: number; readonly files: number; readonly grants: number; readonly recent: ReadonlyArray<{ readonly action: string; readonly actor: string; readonly label: string; readonly when: Date; }>; } /** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ async function loadDashboardStats( prisma: PrismaClient, deps: FileLibRouteDeps, ): Promise { const organizationId = deps.organizationId; const [folders, projects, grants] = await Promise.all([ prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }), prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }), prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }), ]); // 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计) const readyProjects = await prisma.fileLibNode.findMany({ where: { organizationId, kind: "PROJECT", deletedAt: null, provisionStatus: "READY", storageDir: { not: null }, }, select: { storageDir: true }, }); let files = 0; for (const project of readyProjects) { if (project.storageDir === null) continue; try { files += (await deps.versionStore.list(project.storageDir)).length; } catch { /* repo 缺失(如重启未恢复)不计 */ } } const entries = await prisma.auditEntry.findMany({ where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } }, orderBy: { createdAt: "desc" }, take: 8, }); const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))]; const users = actorIds.length === 0 ? [] : await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } }); const nameById = new Map(users.map((u) => [u.id, u.displayName])); const recent = entries.map((entry) => { const meta = (entry.metadata ?? {}) as Record; const label = (typeof meta["name"] === "string" ? meta["name"] : undefined) ?? (typeof meta["to"] === "string" ? meta["to"] : undefined) ?? (typeof meta["path"] === "string" ? meta["path"] : undefined) ?? (typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : ""); return { action: entry.action, actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown", label, when: entry.createdAt, }; }); return { folders, projects, files, grants, recent }; }