|
11 | 11 | import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify'; |
12 | 12 | import fastifyRateLimit from '@fastify/rate-limit'; |
13 | 13 | import fs from 'node:fs/promises'; |
14 | | -import { statSync, watch, type FSWatcher } from 'node:fs'; |
15 | | -import fastifyStatic from '@fastify/static'; |
| 14 | +import { watch, type FSWatcher } from 'node:fs'; |
16 | 15 | import fastifyWebsocket from '@fastify/websocket'; |
17 | 16 | import fastifyCors from '@fastify/cors'; |
18 | 17 | import crypto from 'node:crypto'; |
@@ -51,6 +50,7 @@ import { AuditLogger } from './audit.js'; |
51 | 50 | import { MetricsCollector } from './metrics.js'; |
52 | 51 |
|
53 | 52 | import { registerHookRoutes } from './hooks.js'; |
| 53 | +import { registerDashboardStatic } from './plugins/dashboard-static.js'; |
54 | 54 |
|
55 | 55 | import { registerMemoryRoutes } from './memory-routes.js'; |
56 | 56 |
|
@@ -131,57 +131,6 @@ declare module 'fastify' { |
131 | 131 | // Tightened in #1924: adds frame-ancestors, base-uri, form-action, object-src. |
132 | 132 | // 'unsafe-inline' remains on style-src because Tailwind / xterm inject inline styles; |
133 | 133 | // script-src deliberately excludes 'unsafe-inline' and 'unsafe-eval'. |
134 | | -const DASHBOARD_CSP = [ |
135 | | - "default-src 'self'", |
136 | | - "script-src 'self'", |
137 | | - "style-src 'self' 'unsafe-inline'", |
138 | | - "img-src 'self' data:", |
139 | | - "font-src 'self' data:", |
140 | | - "connect-src 'self' ws: wss: https://registry.npmjs.org", |
141 | | - "frame-ancestors 'none'", |
142 | | - "frame-src 'none'", |
143 | | - "base-uri 'self'", |
144 | | - "form-action 'self'", |
145 | | - "object-src 'none'", |
146 | | - "worker-src blob 'self'", |
147 | | -].join('; '); |
148 | | - |
149 | | -const DASHBOARD_RESPONSE_HEADERS = { |
150 | | - 'X-Frame-Options': 'DENY', |
151 | | - 'X-Content-Type-Options': 'nosniff', |
152 | | - 'Referrer-Policy': 'strict-origin-when-cross-origin', |
153 | | - 'Content-Security-Policy': DASHBOARD_CSP, |
154 | | -} as const; |
155 | | - |
156 | | -function applyDashboardResponseHeaders(reply: FastifyReply): void { |
157 | | - for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) { |
158 | | - reply.header(header, value); |
159 | | - } |
160 | | -} |
161 | | - |
162 | | -function normalizeDashboardStaticPath(dashboardRoot: string, pathname: string): string { |
163 | | - const urlLike = pathname.replace(/\\/g, '/'); |
164 | | - if (urlLike === '/' || urlLike === '/index.html' || urlLike.startsWith('/dashboard/') || urlLike.startsWith('/assets/')) { |
165 | | - return urlLike.replace(/^\/(?:dashboard\/)?/, ''); |
166 | | - } |
167 | | - const normalized = path.normalize(pathname); |
168 | | - const relative = path.isAbsolute(normalized) |
169 | | - ? path.relative(dashboardRoot, normalized) |
170 | | - : normalized.replace(/^[\\/]+/, ''); |
171 | | - return relative.split(path.sep).join('/'); |
172 | | -} |
173 | | - |
174 | | -function dashboardCacheControl(dashboardRoot: string, pathname: string): string { |
175 | | - const relative = normalizeDashboardStaticPath(dashboardRoot, pathname); |
176 | | - const basename = path.posix.basename(relative); |
177 | | - if (relative === '' || relative === 'index.html' || basename.endsWith('.html')) { |
178 | | - return 'no-cache, no-store, must-revalidate'; |
179 | | - } |
180 | | - if (relative.startsWith('assets/') && /-[A-Za-z0-9_-]{6,}\.[A-Za-z0-9]+$/.test(basename)) { |
181 | | - return 'public, max-age=31536000, immutable'; |
182 | | - } |
183 | | - return 'public, max-age=0, must-revalidate'; |
184 | | -} |
185 | 134 |
|
186 | 135 | // Config loaded at startup; env vars override file values |
187 | 136 | let config: Config; |
@@ -1393,88 +1342,8 @@ async function main(): Promise<void> { |
1393 | 1342 | }); |
1394 | 1343 |
|
1395 | 1344 |
|
1396 | | - // #127: Serve dashboard static files (Issue #105) — graceful if missing |
1397 | | - // Issue #539: Dashboard is copied into dist/dashboard/ during build |
1398 | | - // Issue #1699: Validates index.html presence for clearer diagnostics |
1399 | | - const dashboardRoot = path.join(__dirname, "dashboard"); |
1400 | | - const dashboardEnabled = config.dashboardEnabled !== false; |
1401 | | - let dashboardAvailable = false; |
1402 | | - if (dashboardEnabled) { |
1403 | | - try { |
1404 | | - await fs.access(path.join(dashboardRoot, 'index.html')); |
1405 | | - dashboardAvailable = true; |
1406 | | - } catch { |
1407 | | - logger.warn({ |
1408 | | - component: 'server', |
1409 | | - operation: 'dashboard_static_unavailable', |
1410 | | - errorCode: 'DASHBOARD_DIR_MISSING', |
1411 | | - attributes: { |
1412 | | - dashboardRoot, |
1413 | | - hint: 'Run "npm run build" to populate dist/dashboard/', |
1414 | | - }, |
1415 | | - }); |
1416 | | - } |
1417 | | - } |
1418 | | - |
1419 | | - if (dashboardAvailable) { |
1420 | | - await app.register(fastifyStatic, { |
1421 | | - root: dashboardRoot, |
1422 | | - prefix: "/dashboard/", |
1423 | | - // #2345: Prevent send() from overwriting our Cache-Control with its own default. |
1424 | | - cacheControl: false, |
1425 | | - // #146: Cache hashed assets aggressively, no-cache for index.html |
1426 | | - setHeaders: (reply, pathname) => { |
1427 | | - for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) { |
1428 | | - reply.setHeader(header, value); |
1429 | | - } |
1430 | | - reply.setHeader('Cache-Control', dashboardCacheControl(dashboardRoot, pathname)); |
1431 | | - |
1432 | | - // Defensive: ensure Content-Length is present and correct for static assets |
1433 | | - // Only set header when not already provided by the static plugin (avoid interfering with compression plugins). |
1434 | | - try { |
1435 | | - if (!reply.getHeader('Content-Length')) { |
1436 | | - const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, ''); |
1437 | | - const full = path.join(dashboardRoot, rel); |
1438 | | - const st = statSync(full); |
1439 | | - reply.setHeader('Content-Length', String(st.size)); |
1440 | | - } |
1441 | | - } catch { |
1442 | | - // ignore: let the static plugin handle headers if stat fails |
1443 | | - } |
1444 | | - }, |
1445 | | - }); |
1446 | | - } |
1447 | | - |
1448 | | - // Issue #3076: Redirect root / to /dashboard/ when dashboard is available |
1449 | | - if (dashboardAvailable) { |
1450 | | - app.get('/', async (_req, reply) => { |
1451 | | - return reply.redirect('/dashboard/'); |
1452 | | - }); |
1453 | | - } |
1454 | | - |
1455 | | - // Issue #3092: Serve manifest.json at root for PWA install (no auth required). |
1456 | | - if (dashboardAvailable) { |
1457 | | - app.get('/manifest.json', async (_req, reply) => { |
1458 | | - const manifestPath = path.join(dashboardRoot, 'manifest.json'); |
1459 | | - try { |
1460 | | - const data = await fs.readFile(manifestPath, 'utf-8'); |
1461 | | - reply.header('Content-Type', 'application/manifest+json'); |
1462 | | - reply.header('Cache-Control', 'public, max-age=3600'); |
1463 | | - return reply.send(data); |
1464 | | - } catch { |
1465 | | - return reply.status(404).send({ error: 'manifest.json not found' }); |
1466 | | - } |
1467 | | - }); |
1468 | | - } |
1469 | | - |
1470 | | - // SPA fallback for dashboard routes (Issue #105) |
1471 | | - app.setNotFoundHandler(async (req, reply) => { |
1472 | | - if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) { |
1473 | | - applyDashboardResponseHeaders(reply); |
1474 | | - return reply.sendFile("index.html", dashboardRoot); |
1475 | | - } |
1476 | | - return reply.status(404).send({ error: "Not found" }); |
1477 | | - }); |
| 1345 | + // #3154: Dashboard static serving extracted to plugins/dashboard-static.ts |
| 1346 | + await registerDashboardStatic(app, { enabled: config.dashboardEnabled !== false }); |
1478 | 1347 | await container.assertHealthy(); |
1479 | 1348 | await listenWithRetry(app, config.port, config.host, config.stateDir); |
1480 | 1349 | pidFilePath = await writePidFile(config.stateDir); |
|
0 commit comments