chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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(
'<!doctype html><meta charset="utf-8"><title>Not found</title><h1>404</h1><p>Nothing is deployed here.</p>',
{
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,
},
},
);
}