conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }