/** * Minimal self-contained static SPA server (no @fastify/static dependency). Serves the esbuild * output from `root` (control-panel/public/build) with a small content-type map, path-traversal * guard, and an index.html fallback for extension-less GETs (SPA routing). Registered LAST so the * specific /login and /api/* routes always win in Fastify's router. */ import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { resolve, sep, extname } from 'node:path' import type { FastifyPluginAsync, FastifyReply } from 'fastify' const CONTENT_TYPES: Readonly> = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.map': 'application/json; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.webmanifest': 'application/manifest+json', } async function isFile(path: string): Promise { try { return (await stat(path)).isFile() } catch { return false } } async function sendFile(reply: FastifyReply, filePath: string): Promise { const type = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream' await reply.type(type).send(createReadStream(filePath)) } export function buildStaticRoutes(root: string): FastifyPluginAsync { const rootResolved = resolve(root) const indexHtml = resolve(rootResolved, 'index.html') return async (app) => { app.get('/*', async (req, reply) => { // Only GET/HEAD reach here (Fastify method routing); resolve the URL path safely. const urlPath = decodeURIComponent(req.url.split('?')[0] ?? '/') const candidate = resolve(rootResolved, '.' + urlPath) // Path-traversal guard: the resolved target must stay within root. if (candidate !== rootResolved && !candidate.startsWith(rootResolved + sep)) { return reply.code(403).send({ error: 'forbidden' }) } if (urlPath !== '/' && (await isFile(candidate))) { return sendFile(reply, candidate) } // SPA fallback: serve index.html for '/' and any unknown extension-less route. if (await isFile(indexHtml)) { return sendFile(reply, indexHtml) } return reply.code(404).send({ error: 'not found' }) }) } }