static.js (3584B)
1 // src/lib/static.js - the built application bundle 2 // 3 // The directory is read once at startup and the routes are built from what 4 // was actually found, so nothing can be requested that is not on disk and 5 // path traversal is impossible by construction rather than by escaping. 6 7 import fs from 'node:fs/promises'; 8 import path from 'node:path'; 9 import crypto from 'node:crypto'; 10 import { fileURLToPath } from 'node:url'; 11 12 const HERE = path.dirname(fileURLToPath(import.meta.url)); 13 export const WEB_DIR = path.resolve(HERE, '../../web/dist'); 14 15 const TYPES = { 16 '.css': 'text/css; charset=utf-8', 17 '.html': 'text/html; charset=utf-8', 18 '.ico': 'image/x-icon', 19 '.js': 'text/javascript; charset=utf-8', 20 '.json': 'application/json; charset=utf-8', 21 '.map': 'application/json; charset=utf-8', 22 '.png': 'image/png', 23 '.svg': 'image/svg+xml', 24 '.txt': 'text/plain; charset=utf-8', 25 '.webmanifest': 'application/manifest+json', 26 '.woff': 'font/woff', 27 '.woff2': 'font/woff2', 28 }; 29 30 // Nothing in the bundle is inline, which is what makes this possible, and it 31 // is what keeps a refresh token in local storage from being readable by an 32 // injected script. 33 export function contentSecurityPolicy(connect = []) { 34 return [ 35 "default-src 'self'", 36 "script-src 'self'", 37 "style-src 'self'", 38 "img-src 'self' data:", 39 "font-src 'self'", 40 `connect-src 'self'${connect.length > 0 ? ` ${connect.join(' ')}` : ''}`, 41 "frame-ancestors 'none'", 42 "base-uri 'none'", 43 "form-action 'self'", 44 ].join('; '); 45 } 46 47 async function collect(dir, base = '') { 48 const out = []; 49 let entries; 50 try { 51 entries = await fs.readdir(dir, { withFileTypes: true }); 52 } catch { 53 // A tree with no build simply serves nothing. 54 return out; 55 } 56 57 for (const entry of entries) { 58 const route = `${base}/${entry.name}`; 59 if (entry.isDirectory()) out.push(...await collect(path.join(dir, entry.name), route)); 60 else if (entry.isFile()) out.push({ route, file: path.join(dir, entry.name) }); 61 } 62 return out; 63 } 64 65 export default async function staticRoutes(fastify, { dir = WEB_DIR, csp = contentSecurityPolicy() } = {}) { 66 const cache = new Map(); 67 68 for (const { route, file } of await collect(dir)) { 69 const body = await fs.readFile(file); 70 cache.set(route, { 71 body, 72 type: TYPES[path.extname(file)] ?? 'application/octet-stream', 73 etag: `"${crypto.createHash('sha256').update(body).digest('hex').slice(0, 32)}"`, 74 }); 75 } 76 77 const index = cache.get('/index.html'); 78 79 function serve(reply, entry, req) { 80 if (req.headers['if-none-match'] === entry.etag) return reply.code(304).send(); 81 reply.header('content-type', entry.type); 82 reply.header('etag', entry.etag); 83 reply.header('cache-control', 'no-cache'); 84 if (entry === index) reply.header('content-security-policy', csp); 85 return reply.send(entry.body); 86 } 87 88 for (const [route, entry] of cache) { 89 fastify.get(route, async (req, reply) => serve(reply, entry, req)); 90 if (route === '/index.html') fastify.get('/', async (req, reply) => serve(reply, entry, req)); 91 } 92 93 if (!index) return; 94 95 // The application routes client side, so a deep link is a page request 96 // rather than a missing file. The API answers its own 404s inside its own 97 // prefix, so those are unaffected. 98 fastify.setNotFoundHandler((req, reply) => { 99 if (req.method !== 'GET' || !(req.headers.accept ?? '').includes('text/html')) { 100 return reply.code(404).send({ ok: false, error: { name: 'NotFound', message: 'not found' } }); 101 } 102 return serve(reply, index, req); 103 }); 104 }