static.js (1878B)
1 // src/lib/static.js - the interface's static assets 2 // 3 // Pages are rendered by the server, so the only static files are the 4 // stylesheet and the vendored htmx build. An allowlist is used rather than 5 // a general static file server: nothing can be requested that is not named 6 // here, which makes path traversal impossible by construction rather than 7 // by careful escaping. 8 9 import fs from 'node:fs/promises'; 10 import path from 'node:path'; 11 import crypto from 'node:crypto'; 12 import { fileURLToPath } from 'node:url'; 13 14 const HERE = path.dirname(fileURLToPath(import.meta.url)); 15 export const ASSET_DIR = path.resolve(HERE, '../../assets'); 16 17 const FILES = { 18 '/style.css': { file: 'style.css', type: 'text/css; charset=utf-8' }, 19 '/vendor/htmx.min.js': { file: 'vendor/htmx.min.js', type: 'text/javascript; charset=utf-8' }, 20 '/vendor/htmx-LICENSE.txt': { file: 'vendor/htmx-LICENSE.txt', type: 'text/plain; charset=utf-8' }, 21 }; 22 23 export default async function staticRoutes(fastify, { dir = ASSET_DIR } = {}) { 24 // Read once at startup. These are small and never change at runtime. 25 const cache = new Map(); 26 27 for (const [route, entry] of Object.entries(FILES)) { 28 let body; 29 try { 30 body = await fs.readFile(path.join(dir, entry.file)); 31 } catch { 32 // A deployment that does not ship the assets simply has no styling. 33 continue; 34 } 35 const etag = `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 32)}"`; 36 cache.set(route, { body, type: entry.type, etag }); 37 } 38 39 for (const [route, entry] of cache) { 40 fastify.get(route, async (req, reply) => { 41 if (req.headers['if-none-match'] === entry.etag) return reply.code(304).send(); 42 reply.header('content-type', entry.type); 43 reply.header('etag', entry.etag); 44 reply.header('cache-control', 'no-cache'); 45 return reply.send(entry.body); 46 }); 47 } 48 }