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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
259 lines · 9.5 KB Raw History
// @pdsjs/sites - the site request handler
//
// Built by a platform package from ports and config, passed to core as
// `config.siteHandler`, and invoked for every request whose hostname is not
// the PDS hostname. Returning null hands the request back to normal PDS
// dispatch; any Response is final — core adds no CORS headers, no DPoP
// nonce, nothing.

import { parseByteRange } from '@pdsjs/core/http';
import { isSpaceRecordUri } from '@pdsjs/core/repo';
import { hostnameToSite } from './hostname.js';
import { loadSite } from './manifest.js';
import {
  etagMatches,
  fileHeaders,
  notFoundResponse,
  ORIGIN_PLACEHOLDER,
  repoBlobHeaders,
  resolvePath,
} from './serve.js';

/**
 * @typedef {Object} SiteHandlerContext
 * @property {import('@pdsjs/core/ports').ActorStoragePort} actorStorage
 * @property {import('@pdsjs/core/ports').BlobPort} blobs
 * @property {() => Promise<string|null>} getDid - the hosted account's DID
 * @property {string} siteDomain - apex domain sites serve under
 * @property {string} [apexName] - site name bound to the apex (default "home")
 */

/**
 * Build the site handler.
 * @param {SiteHandlerContext} ctx
 * @returns {(request: Request, url: URL) => Promise<Response|null>}
 */
export function createSiteHandler(ctx) {
  const { actorStorage, blobs, getDid, siteDomain } = ctx;
  if (!actorStorage) throw new Error('createSiteHandler requires actorStorage');
  if (!blobs) throw new Error('createSiteHandler requires blobs');
  if (!getDid) throw new Error('createSiteHandler requires getDid');
  if (!siteDomain) throw new Error('createSiteHandler requires siteDomain');
  const apexName = ctx.apexName || 'home';

  return async (request, url) => {
    const resolved = hostnameToSite(url.hostname, siteDomain, apexName);
    if (!resolved) return null;

    try {
      return await serve(request, url, resolved);
    } catch (err) {
      console.error(`site request failed: ${url.hostname}${url.pathname}`, err);
      // A website origin: plain text, never the PDS's JSON error shape
      return new Response('Something went wrong serving this site.', {
        status: 500,
        headers: { 'Content-Type': 'text/plain; charset=utf-8' },
      });
    }
  };

  /**
   * @param {Request} request
   * @param {URL} url
   * @param {{site: string}|{notFound: true}} resolved
   * @returns {Promise<Response>}
   */
  async function serve(request, url, resolved) {
    // The apex doubles as the account handle; every claimed hostname
    // answers handle verification so resolution never depends on which
    // subdomain a verifier picked. A browser app resolves a handle by
    // fetching this path from the handle's own hostname, so it is the one
    // path on a site origin that carries CORS headers.
    if (url.pathname === '/.well-known/atproto-did') {
      const cors = {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
      };
      if (request.method === 'OPTIONS') {
        return new Response(null, { status: 204, headers: cors });
      }
      const did = await getDid();
      return new Response(did || 'User not found', {
        status: did ? 200 : 404,
        headers: { ...cors, 'Content-Type': 'text/plain; charset=utf-8' },
      });
    }

    if (request.method !== 'GET' && request.method !== 'HEAD') {
      // OPTIONS included: a static site has no preflightable API, and the
      // PDS's permissive CORS headers must not appear on this origin.
      return new Response(null, {
        status: 405,
        headers: { Allow: 'GET, HEAD' },
      });
    }

    if ('notFound' in resolved) return notFoundResponse();

    const did = await getDid();
    if (!did) return notFoundResponse();

    // Any repo blob, addressed by CID under a reserved dot-prefix no
    // deployed file can claim (the CLI never uploads dotfiles). The same
    // bytes are public at com.atproto.sync.getBlob; this alias is
    // immutable and edge-cacheable, so a client-rendered site can show
    // repo media without a manifest entry per file.
    const blobMatch = url.pathname.match(/^\/\.blobs\/([a-z2-7]{16,})$/);
    if (blobMatch) {
      const cid = blobMatch[1];
      if (etagMatches(request.headers.get('if-none-match'), cid)) {
        // Answered before the blob read, so the mime is unknown; the
        // client's cached Content-Type stands on a 304.
        const headers = repoBlobHeaders('application/octet-stream', cid);
        headers.delete('Content-Type');
        return new Response(null, { status: 304, headers });
      }

      // A blob only space records reference is private data, absent here
      // exactly as it is absent from com.atproto.sync.getBlob.
      const uris = actorStorage.listBlobRecordUris
        ? await actorStorage.listBlobRecordUris(cid)
        : [];
      if (uris.length > 0 && uris.every(isSpaceRecordUri)) {
        return notFoundResponse();
      }

      // The metadata row carries the size, so ranges resolve and HEAD
      // answers before any bytes move. Without the row the whole object
      // goes out buffered.
      const meta = actorStorage.getBlob
        ? await actorStorage.getBlob(cid)
        : null;
      if (meta) {
        const headers = repoBlobHeaders(meta.mimeType, cid);
        headers.set('Accept-Ranges', 'bytes');
        if (request.method === 'HEAD') {
          headers.set('Content-Length', String(meta.size));
          return new Response(null, { headers });
        }
        const range = parseByteRange(request.headers.get('range'), meta.size);
        if (range === 'unsatisfiable') {
          return new Response(null, {
            status: 416,
            headers: {
              'Content-Range': `bytes */${meta.size}`,
              'Accept-Ranges': 'bytes',
            },
          });
        }
        /** @type {BodyInit} */
        let body;
        if (blobs.getStream) {
          const found = await blobs.getStream(did, cid, range ?? undefined);
          if (!found) return notFoundResponse();
          body = found.body;
        } else {
          const blob = await blobs.get(did, cid);
          if (!blob) return notFoundResponse();
          body = /** @type {BodyInit} */ (
            range ? blob.data.subarray(range.start, range.end + 1) : blob.data
          );
        }
        const length = range ? range.end - range.start + 1 : meta.size;
        headers.set('Content-Length', String(length));
        if (range) {
          headers.set(
            'Content-Range',
            `bytes ${range.start}-${range.end}/${meta.size}`,
          );
        }
        return new Response(body, { status: range ? 206 : 200, headers });
      }

      const blob = await blobs.get(did, cid);
      if (!blob) return notFoundResponse();
      const headers = repoBlobHeaders(blob.mimeType, cid);
      if (request.method === 'HEAD') {
        headers.set('Content-Length', String(blob.data.length));
        return new Response(null, { headers });
      }
      return new Response(/** @type {BodyInit} */ (blob.data), { headers });
    }

    const manifest = await loadSite(actorStorage, did, resolved.site);
    if (!manifest) {
      // The www of the site domain redirects to the apex unless a site
      // named www is deployed, matching what static hosts do by default.
      if (resolved.site === 'www') {
        return new Response(null, {
          status: 301,
          headers: {
            Location: `https://${siteDomain}${url.pathname}${url.search}`,
          },
        });
      }
      return notFoundResponse();
    }

    const target = resolvePath(url.pathname, manifest);
    if (target.kind === 'badRequest') {
      return new Response('Bad request path.', {
        status: 400,
        headers: { 'Content-Type': 'text/plain; charset=utf-8' },
      });
    }
    if (target.kind === 'redirect') {
      return new Response(null, {
        status: 301,
        headers: { Location: target.location + url.search },
      });
    }
    if (target.kind === 'notFound') return notFoundResponse();

    // A file that names the origin it is served from: the deploy could not
    // know the origin, so it left a token the request fills in. The bytes
    // differ per origin, so the blob cid no longer names them and neither an
    // ETag nor a stored length holds.
    if (target.file.originTemplate) {
      const blob = await blobs.get(did, target.file.cid);
      if (!blob) return notFoundResponse();
      const origin = `${url.protocol}//${url.host}`;
      const filled = new TextEncoder().encode(
        new TextDecoder()
          .decode(blob.data)
          .replaceAll(ORIGIN_PLACEHOLDER, origin),
      );
      const templated = fileHeaders(target.path, target.file);
      templated.delete('ETag');
      templated.set('Content-Length', String(filled.length));
      if (request.method === 'HEAD') {
        return new Response(null, {
          status: target.status,
          headers: templated,
        });
      }
      return new Response(filled, {
        status: target.status,
        headers: templated,
      });
    }

    const headers = fileHeaders(target.path, target.file);

    if (etagMatches(request.headers.get('if-none-match'), target.file.cid)) {
      return new Response(null, { status: 304, headers });
    }

    if (request.method === 'HEAD') {
      headers.set('Content-Length', String(target.file.size));
      return new Response(null, { status: target.status, headers });
    }

    const blob = await blobs.get(did, target.file.cid);
    if (!blob) return notFoundResponse();
    return new Response(/** @type {BodyInit} */ (blob.data), {
      status: target.status,
      headers,
    });
  }
}