// @pdsjs/sites - path resolution and header assembly // // Pure request-shaping logic for the site handler: turning a request path // into a manifest lookup, and a manifest entry into response headers. No // storage access happens here. /** Fallback Content-Types by file extension, for blobs stored without a * useful mime type by deploy tools other than the CLI. Text types carry * utf-8 because the blob itself cannot say. */ const EXTENSION_TYPES = new Map([ ['html', 'text/html; charset=utf-8'], ['css', 'text/css; charset=utf-8'], ['js', 'text/javascript; charset=utf-8'], ['mjs', 'text/javascript; charset=utf-8'], ['json', 'application/json; charset=utf-8'], ['svg', 'image/svg+xml'], ['txt', 'text/plain; charset=utf-8'], ['xml', 'application/xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['gif', 'image/gif'], ['webp', 'image/webp'], ['avif', 'image/avif'], ['ico', 'image/x-icon'], ['pdf', 'application/pdf'], ['wasm', 'application/wasm'], ['map', 'application/json'], ['webmanifest', 'application/manifest+json'], ['woff', 'font/woff'], ['woff2', 'font/woff2'], ['ttf', 'font/ttf'], ['otf', 'font/otf'], ['mp4', 'video/mp4'], ['webm', 'video/webm'], ['mp3', 'audio/mpeg'], ]); const DEFAULT_CACHE_CONTROL = 'public, max-age=60, must-revalidate'; /** Sites serve over HTTPS only; a year of HSTS stops browsers from trying * plain HTTP at all. Browsers ignore the header on an HTTP response, so it * is harmless in local development. */ const STRICT_TRANSPORT_SECURITY = 'max-age=31536000'; /** Mime types the upload sniffer cannot produce and generic clients rarely * set; anything equal to or starting with these is treated as unhelpful and * the extension map wins. */ const GENERIC_MIME = 'application/octet-stream'; /** * Resolve a request pathname against a site manifest. * * @param {string} pathname - raw request pathname, still percent-encoded * @param {import('./manifest.js').SiteManifest} manifest * @returns {{kind: 'file', path: string, file: import('./manifest.js').SiteFile, status: number} * | {kind: 'redirect', location: string} * | {kind: 'notFound'} * | {kind: 'badRequest'}} */ export function resolvePath(pathname, manifest) { let decoded; try { decoded = decodeURIComponent(pathname); } catch { return { kind: 'badRequest' }; } if (decoded.includes('\\') || decoded.includes('\0')) { return { kind: 'badRequest' }; } const segments = decoded.split('/'); if (segments.includes('..')) return { kind: 'badRequest' }; let path = decoded.replace(/^\/+/, ''); if (path === '' || path.endsWith('/')) path += 'index.html'; const exact = manifest.files.get(path); if (exact) return { kind: 'file', path, file: exact, status: 200 }; // A directory requested without its trailing slash: redirect so relative // URLs inside the page resolve against the right base. if (!decoded.endsWith('/') && manifest.files.has(`${path}/index.html`)) { return { kind: 'redirect', location: `${pathname}/` }; } if (manifest.fallback) { const fallback = manifest.files.get(manifest.fallback); if (fallback) { return { kind: 'file', path: manifest.fallback, file: fallback, status: 200, }; } } return { kind: 'notFound' }; } /** * The Content-Type to serve a file with: the manifest override wins, then a * useful stored blob mime, then the extension, then octet-stream. * @param {string} path * @param {import('./manifest.js').SiteFile} file * @returns {string} */ export function contentTypeFor(path, file) { if (file.contentType) return file.contentType; if (file.mimeType && file.mimeType !== GENERIC_MIME) return file.mimeType; const dot = path.lastIndexOf('.'); const ext = dot === -1 ? '' : path.slice(dot + 1).toLowerCase(); return EXTENSION_TYPES.get(ext) || GENERIC_MIME; } /** * The token an origin-templated file carries where the serving origin goes. * A site file may name the origin it is served from, such as an OAuth client * registration, which the deploy cannot know. The file holds this token and * the server fills it in from the request. */ export const ORIGIN_PLACEHOLDER = '__PDSJS_ORIGIN__'; /** * Response headers for a file, shared by GET, HEAD, and 304 responses. * @param {string} path * @param {import('./manifest.js').SiteFile} file * @returns {Headers} */ export function fileHeaders(path, file) { return new Headers({ 'Content-Type': contentTypeFor(path, file), 'Cache-Control': file.cacheControl || DEFAULT_CACHE_CONTROL, ETag: `"${file.cid}"`, 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': STRICT_TRANSPORT_SECURITY, // Site content is public. The open origin lets another site's page use // a font or fetch a file from here, the same as any static file host. 'Access-Control-Allow-Origin': '*', }); } /** * Whether an If-None-Match header matches a file's ETag. * @param {string|null} header * @param {string} cid - the file's blob CID (unquoted ETag value) * @returns {boolean} */ export function etagMatches(header, cid) { if (!header) return false; if (header.trim() === '*') return true; return header.split(',').some((candidate) => { const tag = candidate.trim().replace(/^W\//, ''); return tag === `"${cid}"`; }); } /** Media types a repo blob may serve under its stored mime. Anything else * serves as octet-stream: HTML or XML rendering on the site origin would * let any blob in the repo put a page on it. */ const BLOB_MIME_ALLOWED = /^(image|video|audio|font)\//; /** * Response headers for a repo blob served by CID. Content is addressed by * the path, so the response is immutable, and every one is sandboxed: an * SVG can script, and the CSP applies when a blob is navigated to while * leaving img/video/font uses untouched. * @param {string} mimeType - the blob's stored mime * @param {string} cid * @returns {Headers} */ export function repoBlobHeaders(mimeType, cid) { const safe = BLOB_MIME_ALLOWED.test(mimeType) ? mimeType : 'application/octet-stream'; return new Headers({ 'Content-Type': safe, 'Cache-Control': 'public, max-age=31536000, immutable', ETag: `"${cid}"`, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; sandbox", 'Strict-Transport-Security': STRICT_TRANSPORT_SECURITY, 'Access-Control-Allow-Origin': '*', }); } /** * The built-in 404 page: minimal, uncacheable, and unmistakably not the * PDS's JSON error shape. * @returns {Response} */ export function notFoundResponse() { return new Response( '
Nothing is deployed here.
', { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Strict-Transport-Security': STRICT_TRANSPORT_SECURITY, }, }, ); }