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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
240 lines · 8.7 KB Raw History
/**
 * Git hosting as a server extension: the platform passes the factory to
 * core's `extensions` option. The extension always guards writes to repo
 * and config records (branch protection) and serves the dev.pdsjs.git XRPC
 * methods; file browsing and read-only smart HTTP under /git/ are the parts
 * the options turn on and off.
 *
 * This module must stay free of node-only imports; it runs on Cloudflare
 * Workers.
 */

import { cborDecode, cborDecodeJson } from '@pdsjs/core/repo';
/** @typedef {import('./hosting.js').GitSpaceAdmin} GitSpaceAdmin */
import { createGitBrowser } from './browser.js';
import { createConstellationDiscovery, noDiscovery } from './discovery.js';
import { createGitHosting } from './hosting.js';
import { createGitHttp } from './http.js';
import { GIT_IDENTITY_COLLECTION, GIT_REPO_COLLECTION } from './lexicon.js';
import { createGitMethods } from './methods.js';
import { createGitWriteGuard, GIT_CONFIG_COLLECTION } from './rules.js';

/**
 * The part of the space admin port git hosting needs, built on first use
 * over the host's space storage from @pdsjs/spaces, an optional peer
 * imported only then.
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} spaceStorage
 * @param {() => Promise<string|null>} getDid
 * @returns {GitSpaceAdmin}
 */
function lazySpaceAdmin(spaceStorage, getDid) {
  /** @type {Promise<import('@pdsjs/core/ports').SpaceAdminPort>|null} */
  let admin = null;
  const load = () => {
    admin ??= import('@pdsjs/spaces/admin').then(({ createSpaceAdmin }) =>
      createSpaceAdmin({ spaceStorage, getDid }),
    );
    return admin;
  };
  return {
    createSpace: async (opts) => (await load()).createSpace(opts),
    createRecord: async (space, collection, rkey, record) =>
      (await load()).createRecord(space, collection, rkey, record),
    putRecord: async (space, collection, rkey, record) =>
      (await load()).putRecord(space, collection, rkey, record),
    getRecord: async (space, collection, rkey) =>
      (await load()).getRecord(space, collection, rkey),
    deleteRecord: async (space, collection, rkey) =>
      (await load()).deleteRecord(space, collection, rkey),
    removeMember: async (space, did) => (await load()).removeMember(space, did),
  };
}

/** The collections every git extension owns, guard or full. */
const GIT_COLLECTIONS = [
  GIT_REPO_COLLECTION,
  GIT_CONFIG_COLLECTION,
  GIT_IDENTITY_COLLECTION,
  'dev.pdsjs.git.checkRequest',
];

/**
 * The largest bundle chain the server downloads whole to answer a read. A
 * repository pushed before object indexes existed opens this way; one with
 * indexes reads ranges and is never held whole.
 */
const DEFAULT_EAGER_LIMIT = 64 * 1024 * 1024;

/** Opened repositories the server keeps between reads. */
const OPEN_REPOSITORIES = 4;

/**
 * The blob readers over the host's blob store, whole and ranged, that the
 * guard's ancestry check and the browser's space reads share.
 * @param {import('@pdsjs/core/ports').ExtensionHost} host
 * @returns {{
 *   getBlob: (cid: string) => Promise<Uint8Array|null>,
 *   getBlobRange: ((cid: string, start: number, end: number) => Promise<Uint8Array|null>)|undefined,
 * }}
 */
function buildBlobReaders(host) {
  const { blobs } = host;
  /** @param {string} cid */
  const getBlob = async (cid) => {
    const did = await host.getDid();
    if (!did) return null;
    const result = await blobs.get(did, cid);
    if (!result) return null;
    return result.data instanceof Uint8Array
      ? result.data
      : new Uint8Array(/** @type {ArrayBuffer} */ (result.data));
  };
  // A store that reads ranges lets the guard's ancestry check and the
  // browser read a repository lazily. getStream takes inclusive positions.
  /** @type {((cid: string, start: number, end: number) => Promise<Uint8Array|null>)|undefined} */
  const getBlobRange = blobs.getStream
    ? async (cid, start, end) => {
        const did = await host.getDid();
        if (!did) return null;
        const found = await blobs.getStream?.(did, cid, {
          start,
          end: end - 1,
        });
        if (!found) return null;
        return new Uint8Array(await new Response(found.body).arrayBuffer());
      }
    : undefined;
  return { getBlob, getBlobRange };
}

/**
 * The branch-protection write guard, built over the host's storage. The
 * ancestry check reads the repository's own records and bundle blobs, so
 * the guard needs reads, not writes.
 * @param {import('@pdsjs/core/ports').ExtensionHost} host
 */
function buildWriteGuard(host) {
  const { actorStorage, spaceStorage } = host;
  const { getBlob, getBlobRange } = buildBlobReaders(host);
  return createGitWriteGuard({
    getRecord: async (space, collection, rkey) => {
      if (space) {
        if (!spaceStorage) return null;
        const row = await spaceStorage.getSpaceRecord(space, collection, rkey);
        return row ? cborDecodeJson(row.value) : null;
      }
      const did = await host.getDid();
      if (!did) return null;
      const record = await actorStorage.getRecord(
        `at://${did}/${collection}/${rkey}`,
      );
      if (!record) return null;
      return cborDecode(
        record.value instanceof Uint8Array
          ? record.value
          : new Uint8Array(record.value),
      );
    },
    getBlob,
    getBlobRange,
  });
}

/**
 * The branch-protection write guard alone, without the routes, the smart
 * HTTP endpoint or the file browser. A server whose git hosting runs
 * elsewhere mounts this and keeps the enforceable half: protected branches
 * refuse force pushes and deletion at the record write, wherever the reads
 * are served from.
 * @returns {import('@pdsjs/core/ports').ExtensionFactory}
 */
export function createGitGuardExtension() {
  return (host) => ({
    name: 'git',
    collections: GIT_COLLECTIONS,
    writeGuard: buildWriteGuard(host),
    features: { git: true, gitBrowse: false },
  });
}

/**
 * @param {Object} [options]
 * @param {boolean} [options.http] - serve read-only git smart HTTP under /git/
 * @param {boolean} [options.browse] - serve the reads of the bundle chain (default on)
 * @param {number} [options.eagerLimit] - the largest bundle chain to download whole for a read, in bytes; a larger repository without indexes answers RepositoryTooLarge
 * @param {import('./discovery.js').DiscoveryPort|null} [options.discovery] - what finds the pull requests, reviews and issues from accounts a repository's config does not name. By default the public Constellation index; null to read only the listed voices.
 * @returns {import('@pdsjs/core/ports').ExtensionFactory}
 */
export function createGitExtension({
  http = false,
  browse = true,
  eagerLimit = DEFAULT_EAGER_LIMIT,
  discovery,
} = {}) {
  return (host) => {
    const { spaceStorage } = host;

    // The account page is the owner's own surface, so a repository held in
    // a space reads from storage rather than the authenticated space
    // endpoints. A store that reads ranges lets the browser read a space
    // repository lazily; getStream takes inclusive positions.
    const browser = browse
      ? createGitBrowser({
          xrpc: host.xrpc,
          cacheSize: OPEN_REPOSITORIES,
          maxEagerBytes: eagerLimit,
          spaceReader: spaceStorage
            ? {
                getRecord: async (space, rkey) => {
                  const row = await spaceStorage.getSpaceRecord(
                    space,
                    'dev.pdsjs.git.repo',
                    rkey,
                  );
                  return row
                    ? { cid: row.cid, value: cborDecodeJson(row.value) }
                    : null;
                },
                ...buildBlobReaders(host),
              }
            : undefined,
        })
      : null;

    // Reads go through the server's own fetch, so the handler needs no
    // storage access of its own.
    const gitHttp = http ? createGitHttp({ xrpc: host.xrpc }) : null;

    const hosting = createGitHosting({
      host,
      httpEnabled: http,
      spaceBrowser: host.spaceBrowser ?? null,
      spaceAdmin: spaceStorage
        ? lazySpaceAdmin(spaceStorage, host.getDid)
        : null,
    });

    return {
      name: 'git',
      collections: GIT_COLLECTIONS,
      writeGuard: buildWriteGuard(host),
      routes: createGitMethods({
        host,
        browser,
        hosting,
        discovery:
          discovery === null
            ? noDiscovery()
            : (discovery ??
              createConstellationDiscovery({
                userAgent: `pds.js @pdsjs/git (${host.hostname ?? 'a pds.js server'})`,
              })),
      }),
      features: { git: true, gitBrowse: Boolean(browser) },
      stats: (count) => ({ gitRepos: count(GIT_REPO_COLLECTION) }),
      ...(gitHttp
        ? { prefixes: ['/git'], handle: (request) => gitHttp.handle(request) }
        : {}),
    };
  };
}