/** * `/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. * * The login page and dashboard are now served by the `database-admin` SvelteKit * SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the * concrete JSON/redirect routes the SPA depends on: * * /database/config — unauthenticated bootstrap: silo org slug + dev toggle * /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, verifySession } 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 { renderLibraryBrowser } from "./libraryBrowser.js"; import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.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 { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.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; app.get("/database/admin", async (request, reply) => { // Already signed in → straight to the dashboard. if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) { return reply.redirect("/database/dashboard"); } return reply.type("text/html").send(renderLoginPage(config)); }); app.get("/database/dashboard", async (request, reply) => { const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config); if (user === null) return reply.redirect("/database/admin"); const stats = await loadDashboardStats(config.prisma, filelibDepsForStats); return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug)); }); // 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(/database/dashboard#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 | null, ): Promise { if (deps === null) return null; 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 }; } /** Verify the session cookie and load the user, or null if not signed in. */ async function resolveUser( rawCookie: string | undefined, config: DatabaseRouteConfig, ): Promise<{ displayName: string } | null> { if (rawCookie === undefined || rawCookie === "") return null; const session = verifySession(rawCookie, config.sessionSecret); if (session === null) return null; const user = await config.prisma.user.findUnique({ where: { id: session.userId }, select: { displayName: true }, }); return user; } /** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */ function pageHead(title: string): string { return ` ${title} ${UI_HEAD_FONTS} `; } function renderLoginPage(config: DatabaseRouteConfig): string { const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`; const devButton = config.allowDevLoginBypass ? `
开发模式
⚡ 一键登录管理员

仅开发环境可见 · 跳过飞书 OAuth

` : ""; return ` ${pageHead("Database Admin · 登录")}
Database Admin

使用飞书登录以管理数据库

使用飞书登录 ${devButton}
`; } /** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */ const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [ { id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" }, { id: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" }, { id: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" }, { id: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" }, { id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" }, { id: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" }, ]; function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string { const nav = NAV_TABS.map((t) => ` `).join("\n"); const cards = [ { label: "文件夹", value: stats?.folders ?? "—" }, { label: "项目", value: stats?.projects ?? "—" }, { label: "文件", value: stats?.files ?? "—" }, { label: "活跃授权", value: stats?.grants ?? "—" }, ].map((s) => `

${s.label}

${s.value}

`).join(""); const recentRows = stats === null || stats.recent.length === 0 ? `
暂无文件库活动 · 到「文件库」里创建第一个文件夹吧
` : stats.recent.map((r) => `
${escapeHtml(r.action)} ${escapeHtml(r.label)} ${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))}
`).join(""); const initial = escapeHtml(displayName.slice(0, 1) || "U"); return ` ${pageHead("Database Admin")}

概览

文件库实时数据

${cards}

最近活动

${recentRows}
`; } function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); }