import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, extname, join, resolve, sep } from 'node:path'; import type { FastifyInstance, FastifyReply } from 'fastify'; /** * Web 看板静态文件服务(零依赖,手写)。 * web/ 不参与 tsc 构建,直接相对仓库根定位: * dev 时本文件在 src/api/,build 后在 dist/api/,两者到仓库根都是 ../..(与 store/db.ts 同思路)。 */ const HERE = dirname(fileURLToPath(import.meta.url)); const WEB_ROOT = resolve(HERE, '..', '..', 'web'); const MIME: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', '.ico': 'image/x-icon', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', }; async function serveFile(reply: FastifyReply, rawPath: string): Promise { let rel: string; try { rel = decodeURIComponent(rawPath); } catch { return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request'); } if (rel.includes('\0')) { return reply.code(400).type('text/plain; charset=utf-8').send('Bad Request'); } // 路径穿越防护:解析后必须仍落在 WEB_ROOT 之内 const abs = resolve(WEB_ROOT, '.' + sep + rel); if (abs !== WEB_ROOT && !abs.startsWith(WEB_ROOT + sep)) { return reply.code(403).type('text/plain; charset=utf-8').send('Forbidden'); } const file = abs === WEB_ROOT ? join(WEB_ROOT, 'index.html') : abs; try { const buf = await readFile(file); const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream'; // 本地开发工具:禁启发式缓存,每次重新验证(否则浏览器会吃旧 CSS/JS) return reply.header('cache-control', 'no-cache').type(type).send(buf); } catch { return reply.code(404).type('text/plain; charset=utf-8').send('Not Found'); } } /** * 在 Fastify 上注册看板静态路由。 * `/` → web/index.html;`/*` → web/ 下对应文件(/api/**、/ws 等更具体路由优先匹配,不受影响)。 */ export function registerStatic(app: FastifyInstance): void { app.get('/', (_req, reply) => serveFile(reply, 'index.html')); app.get('/*', (req, reply) => { const params = req.params as { '*': string }; return serveFile(reply, params['*'] ?? ''); }); }