~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
204 lines · 6.9 KB Raw History
// @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,
      },
    },
  );
}