Files
curriculum-project-hub/hub/src/database/static.ts
T
d159e372d2 refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
2026-07-26 20:17:59 +08:00

72 lines
3.3 KiB
TypeScript

/**
* 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
* 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
*
* 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
* 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。
*
* 三类路由:
* /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
* `_filelib`,以避开 admin-web 在根上注册的 /_app/*
* (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。
* /app, /app/* — 老师端 SPA 回退
* /database, /database/* — 管理后台 SPA 回退
*
* 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由
* databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。
*
* Override the UI directory with `CPH_FILELIB_UI_DIR` if needed.
*/
import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join, resolve as resolvePath } from "node:path";
import fastifyStatic from "@fastify/static";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
function resolveUiDir(): string {
const override = process.env["CPH_FILELIB_UI_DIR"];
if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url));
return resolvePath(join(here, "..", "..", "filelib-web", "build"));
}
/** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */
const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const;
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn(
{ uiDir },
"filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully functional.",
);
return;
}
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。
// 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径
// (filelib-web 的 paths.relative=false 保证了这点)。
await app.register(fastifyStatic, {
root: join(uiDir, "_filelib"),
prefix: "/_filelib/",
decorateReply: true,
});
for (const name of TOP_LEVEL_FILES) {
if (!existsSync(join(uiDir, name))) continue;
app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
}
// SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
// 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
reply.type("text/html; charset=utf-8").send(indexHtml);
app.get("/app", sendIndex);
app.get("/app/*", sendIndex);
app.get("/database", sendIndex);
app.get("/database/*", sendIndex);
}