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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
156 lines · 5.4 KB Raw History
// @pdsjs/core/http - Request and response mechanics every handler shares:
// CORS, cookies, HTML responses and draining a body a handler will not read.

// CORS headers for cross-origin requests
export const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  // Range lets a browser page read single objects out of a git bundle blob
  // with com.atproto.sync.getBlob.
  'Access-Control-Allow-Headers':
    'Content-Type, Authorization, DPoP, atproto-accept-labelers, atproto-proxy, x-bsky-topics, x-bsky-is-beta-user, Range',
  // Browser clients must be able to READ these response headers. Without
  // exposing DPoP-Nonce, a browser OAuth client cannot see the nonce the server
  // hands back with `use_dpop_nonce` and so cannot retry — login fails on it.
  // Atproto-Space-Mac carries the MAC a browser syncer checks a space read by.
  'Access-Control-Expose-Headers':
    'DPoP-Nonce, WWW-Authenticate, Atproto-Space-Mac',
};

/**
 * The CORS headers answering one preflight. A browser names the headers it
 * wants in Access-Control-Request-Headers, and the reply allows exactly those.
 * Thus a client sending a header this server has never heard of still gets
 * through, and the Bluesky app does not break each time it adds one. The
 * allowance grants nothing on its own: the origin is `*`, so the browser sends
 * no credentials, and the request that follows still has to authenticate.
 * @param {Request} request
 * @returns {Record<string, string>}
 */
export function preflightHeaders(request) {
  const requested = request.headers.get('Access-Control-Request-Headers');
  if (!requested) return corsHeaders;
  return { ...corsHeaders, 'Access-Control-Allow-Headers': requested };
}

/**
 * Parse a single-range `Range` header against a known length. Returns null
 * when there is no range to honour, and the string 'unsatisfiable' for a
 * range that falls outside the file, which is a 416 rather than an error.
 * @param {string|null} header
 * @param {number} size
 * @returns {{start: number, end: number}|null|'unsatisfiable'}
 */
export function parseByteRange(header, size) {
  if (!header) return null;
  const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
  if (!match) return null;
  const [, rawStart, rawEnd] = match;
  if (rawStart === '' && rawEnd === '') return null;
  if (size === 0) return 'unsatisfiable';

  let start;
  let end;
  if (rawStart === '') {
    // A suffix range asks for the last N bytes.
    const length = Number(rawEnd);
    if (length === 0) return 'unsatisfiable';
    start = Math.max(0, size - length);
    end = size - 1;
  } else {
    start = Number(rawStart);
    end = rawEnd === '' ? size - 1 : Number(rawEnd);
  }
  if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
  if (start >= size || start > end) return 'unsatisfiable';
  return { start, end: Math.min(end, size - 1) };
}

/**
 * Add CORS headers to response
 * @param {Response} response
 * @returns {Response}
 */
export function addCorsHeaders(response) {
  // WebSocket upgrade responses (101) can't be reconstructed
  // webSocket is a Cloudflare-specific property
  if (response.status === 101 || /** @type {any} */ (response).webSocket) {
    return response;
  }

  const newHeaders = new Headers(response.headers);
  for (const [key, value] of Object.entries(corsHeaders)) {
    newHeaders.set(key, value);
  }
  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: newHeaders,
  });
}

/**
 * Consume a request body that a handler is about to reject unread.
 * Workers runtimes raise an uncaught error when a response is sent while
 * the request stream is still readable.
 * @param {Request} request
 * @returns {Promise<void>}
 */
export async function drainRequestBody(request) {
  if (!request.body) return;
  try {
    await request.arrayBuffer();
  } catch {
    // Body already consumed or aborted
  }
}

/**
 * Form posts are only accepted from this server's own pages. `SameSite=Lax`
 * already keeps the session cookie off cross-site posts; this rejects the
 * request outright rather than treating it as signed out.
 * @param {Request} request
 * @param {URL} url
 * @returns {boolean}
 */
export function isSameOriginPost(request, url) {
  const origin = request.headers.get('origin');
  return !origin || origin === `${url.protocol}//${url.host}`;
}

/**
 * Read one cookie from a request's Cookie header.
 * @param {Request} request
 * @param {string} name
 * @returns {string|null}
 */
export function readCookie(request, name) {
  const header = request.headers.get('cookie');
  if (!header) return null;
  for (const pair of header.split(';')) {
    const eq = pair.indexOf('=');
    if (eq === -1) continue;
    if (pair.slice(0, eq).trim() === name) {
      return decodeURIComponent(pair.slice(eq + 1).trim());
    }
  }
  return null;
}

/**
 * An HTML response for the account pages. These carry per-account data and a
 * session cookie, so they must never be cached.
 * @param {string} html
 * @param {number} [status=200]
 * @param {string|null} [setCookie=null]
 * @returns {Response}
 */
export function htmlResponse(html, status = 200, setCookie = null) {
  const headers = new Headers({
    'Content-Type': 'text/html; charset=utf-8',
    'Cache-Control': 'no-store',
    'Referrer-Policy': 'same-origin',
  });
  if (setCookie) headers.set('Set-Cookie', setCookie);
  return new Response(html, { status, headers });
}