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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
408 lines · 13.9 KB Raw History
/**
 * The /account/api/sites and /account/api/installs routes: the deployed
 * sites, and the apps installed from manifests, each behind the owner's
 * account session. The install listing and its settings need no site
 * domain; previewing, installing and renaming need the installer, which
 * does.
 *
 * This module must stay free of node-only imports; it runs on Cloudflare
 * Workers.
 */

import {
  cborDecode,
  cborEncodeDagCbor,
  cidToString,
  createCid,
  toDisplayValue,
} from '@pdsjs/core/repo';

/**
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').ExtensionHost} ctx.host
 * @param {import('./ports.js').SiteBrowserPort|null} ctx.browser - Absent, the sites listing reports the feature off and the site routes refuse.
 * @param {import('./ports.js').SiteInstallerPort|null} ctx.installer - Absent, previewing, installing and renaming report installs off.
 * @returns {Record<string, import('@pdsjs/core/ports').ExtensionRoute>}
 */
export function createSiteAccountRoutes({ host, browser, installer }) {
  const {
    accountApi,
    actorStorage,
    createCommit,
    hostname,
    reapOrphanedBlobs,
  } = host;

  /**
   * GET /account/api/sites - The deployed sites, each marked as an app
   * install's frontend or a published app's artifact where it is one.
   * @param {Request} request @param {URL} url
   */
  async function handleApiSites(request, url) {
    return accountApi(request, url, async (did) => {
      if (!browser) return { enabled: false, sites: [] };

      // A site can be an app install's frontend, or the artifact a local
      // manifest publishes. The page shows installs in their own section
      // and marks artifacts, so the roles need naming here.
      const installedNames = new Set();
      for (const record of (
        await actorStorage.listRecords('dev.pdsjs.app.install', null, 100)
      ).records) {
        const value = /** @type {{site?: unknown}} */ (
          cborDecode(
            record.value instanceof Uint8Array
              ? record.value
              : new Uint8Array(record.value),
          )
        );
        if (typeof value.site === 'string') installedNames.add(value.site);
      }
      /** @type {Map<string, string>} */
      const publishedBy = new Map();
      for (const record of (
        await actorStorage.listRecords('dev.pdsjs.app.manifest', null, 100)
      ).records) {
        const value = /** @type {{site?: {uri?: unknown}}} */ (
          toDisplayValue(
            cborDecode(
              record.value instanceof Uint8Array
                ? record.value
                : new Uint8Array(record.value),
            ),
          )
        );
        if (typeof value.site?.uri === 'string') {
          publishedBy.set(value.site.uri, record.uri);
        }
      }

      const sites = (await browser.listSites()).map((site) => {
        const manifestUri = publishedBy.get(
          `at://${did}/dev.pdsjs.site.deploy/${site.name}`,
        );
        return {
          ...site,
          installed: installedNames.has(site.name),
          publishedApp: Boolean(manifestUri),
          manifestUri: manifestUri || null,
        };
      });

      return {
        enabled: true,
        domain: browser.domain,
        apexName: browser.apexName,
        // The PDS answering at the site apex shadows the apex site; the
        // sites page warns when it detects this.
        apexShadowed:
          (hostname || '').split(':')[0].toLowerCase() ===
          browser.domain.toLowerCase(),
        sites,
      };
    });
  }

  /**
   * GET /account/api/installs - Installed apps, from dev.pdsjs.app.install
   * records: the site each serves as, the manifest it pins, and the
   * settings schema snapshot with current values.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstalls(request, url) {
    return accountApi(request, url, async () => {
      const page = await actorStorage.listRecords(
        'dev.pdsjs.app.install',
        null,
        100,
      );
      const installs = [];
      for (const record of page.records) {
        const value = /** @type {Record<string, unknown>} */ (
          cborDecode(
            record.value instanceof Uint8Array
              ? record.value
              : new Uint8Array(record.value),
          )
        );
        const iconRef = /** @type {{ref?: {$link?: string}}|undefined} */ (
          value.icon
        );
        const iconCid =
          iconRef?.ref instanceof Uint8Array
            ? cidToString(iconRef.ref)
            : typeof iconRef?.ref?.$link === 'string'
              ? iconRef.ref.$link
              : null;
        installs.push({
          uri: record.uri,
          name: typeof value.name === 'string' ? value.name : null,
          site: typeof value.site === 'string' ? value.site : null,
          iconUrl: iconCid
            ? `/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(record.uri.split('/')[2])}&cid=${iconCid}`
            : null,
          manifest: toDisplayValue(value.manifest),
          settings: Array.isArray(value.settings) ? value.settings : [],
          config:
            value.config && typeof value.config === 'object'
              ? value.config
              : {},
          createdAt:
            typeof value.createdAt === 'string' ? value.createdAt : null,
        });
      }
      return { installs };
    });
  }

  /**
   * POST /account/api/installs/preview - The install sheet's view of a
   * manifest at-uri: name, author, icon, settings, what it will copy.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstallPreview(request, url) {
    return accountApi(request, url, async (_did, body) => {
      if (!installer) throw new Error('Installs are not enabled.');
      const uri = typeof body?.uri === 'string' ? body.uri.trim() : '';
      if (!uri) throw new Error('Name the app to preview.');
      return installer.preview(uri);
    });
  }

  /**
   * POST /account/api/installs/install - Install or update an app from its
   * manifest at-uri. The installer copies blobs with local hashing and
   * writes every record in one commit.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstallRun(request, url) {
    return accountApi(
      request,
      url,
      async (_did, body) => {
        if (!installer) throw new Error('Installs are not enabled.');
        const uri = typeof body?.uri === 'string' ? body.uri.trim() : '';
        if (!uri) throw new Error('Name the app to install.');
        return installer.install(uri, {
          site: typeof body?.site === 'string' ? body.site : undefined,
          config:
            body?.config && typeof body.config === 'object'
              ? /** @type {Record<string, unknown>} */ (body.config)
              : undefined,
        });
      },
      { write: true },
    );
  }

  /**
   * POST /account/api/installs/rename - Move an install's site to another
   * subdomain label. The site record moves byte for byte, so its CID and
   * the manifest pin are untouched.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstallRename(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        if (!installer) throw new Error('Installs are not enabled.');
        const uri = typeof body?.uri === 'string' ? body.uri : '';
        const site = typeof body?.site === 'string' ? body.site.trim() : '';
        if (!uri.startsWith(`at://${did}/dev.pdsjs.app.install/`)) {
          throw new Error('Name the install to rename.');
        }
        if (!site) throw new Error('Name the new subdomain.');
        return installer.rename(uri, site);
      },
      { write: true },
    );
  }

  /**
   * GET /account/api/installs/updates - For each install, whether the
   * developer's manifest moved past the pinned version. An unreachable
   * developer is omitted rather than failing the check for the rest.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstallUpdates(request, url) {
    return accountApi(request, url, async () => {
      if (!installer) return { updates: {} };
      const page = await actorStorage.listRecords(
        'dev.pdsjs.app.install',
        null,
        100,
      );
      /** @type {Record<string, {changed: boolean, currentCid: string}>} */
      const updates = {};
      await Promise.all(
        page.records.map(async (record) => {
          const value =
            /** @type {{manifest?: {uri?: string, cid?: string}}} */ (
              toDisplayValue(
                cborDecode(
                  record.value instanceof Uint8Array
                    ? record.value
                    : new Uint8Array(record.value),
                ),
              )
            );
          const uri = value.manifest?.uri;
          const cid = value.manifest?.cid;
          if (typeof uri !== 'string' || typeof cid !== 'string') return;
          try {
            updates[record.uri] = await installer.checkUpdate(uri, cid);
          } catch {}
        }),
      );
      return { updates };
    });
  }

  /**
   * POST /account/api/installs/config - Replace an install's chosen
   * settings. Values are checked against the record's own schema snapshot:
   * only declared keys, each of its declared type.
   * @param {Request} request @param {URL} url
   */
  async function handleApiInstallConfig(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const uri = typeof body?.uri === 'string' ? body.uri : '';
        const config = body?.config;
        if (!uri.startsWith(`at://${did}/dev.pdsjs.app.install/`)) {
          throw new Error('Name the install to configure.');
        }
        if (!config || typeof config !== 'object' || Array.isArray(config)) {
          throw new Error('Send the chosen settings as an object.');
        }
        const stored = await actorStorage.getRecord(uri);
        if (!stored) throw new Error('No such install.');
        const value = /** @type {Record<string, unknown>} */ (
          cborDecode(
            stored.value instanceof Uint8Array
              ? stored.value
              : new Uint8Array(stored.value),
          )
        );
        const schema = Array.isArray(value.settings) ? value.settings : [];
        for (const [key, chosen] of Object.entries(config)) {
          const field =
            /** @type {{key?: string, type?: string, options?: unknown[]}|undefined} */ (
              schema.find((f) => /** @type {{key?: string}} */ (f)?.key === key)
            );
          if (!field) throw new Error(`No such setting: ${key}`);
          if (field.type === 'boolean' && typeof chosen !== 'boolean') {
            throw new Error(`Setting ${key} takes true or false.`);
          }
          if (field.type === 'string' && typeof chosen !== 'string') {
            throw new Error(`Setting ${key} takes a string.`);
          }
          if (
            field.type === 'select' &&
            !(Array.isArray(field.options) && field.options.includes(chosen))
          ) {
            throw new Error(`Setting ${key} is not one of its options.`);
          }
        }

        const updated = { ...value, config };
        const encoded = cborEncodeDagCbor(updated);
        const cid = cidToString(await createCid(encoded));
        const rkey = uri.split('/').pop() || '';
        await actorStorage.putBlock(cid, encoded);
        await actorStorage.putRecord(
          uri,
          cid,
          'dev.pdsjs.app.install',
          rkey,
          encoded,
        );
        await createCommit(did, [{ action: 'update', uri, cid }]);
        return {};
      },
      { write: true },
    );
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiSiteFiles(request, url) {
    return accountApi(request, url, async () => {
      const name = url.searchParams.get('name') || '';
      if (!name) throw new Error('Name the site to list.');
      if (!browser) throw new Error('Sites are not enabled.');
      const files = await browser.listFiles(name);
      if (!files) throw new Error('No such site.');
      return { files };
    });
  }

  /** @param {Request} request @param {URL} url */
  async function handleApiSiteDelete(request, url) {
    return accountApi(
      request,
      url,
      async (did, body) => {
        const name = typeof body?.name === 'string' ? body.name : '';
        if (!name) throw new Error('Name the site to delete.');
        if (!browser) throw new Error('Sites are not enabled.');

        const uri = `at://${did}/dev.pdsjs.site.deploy/${name}`;
        if (!(await actorStorage.getRecord(uri))) {
          throw new Error('No such site.');
        }

        // The same sequence deleteRecord runs: unlink, delete, commit, then
        // reap this site's now-orphaned blobs (guarded against a stale link
        // table, so blobs other records reference survive).
        await actorStorage.unlinkBlobsFromRecord(uri);
        await actorStorage.deleteRecord(uri);
        await createCommit(did, [{ action: 'delete', uri }]);
        await reapOrphanedBlobs(did, Date.now() + 1000);
        return {};
      },
      { write: true },
    );
  }

  return {
    '/account/api/sites': {
      method: 'GET',
      handler: handleApiSites,
    },
    '/account/api/installs': {
      method: 'GET',
      handler: handleApiInstalls,
    },
    '/account/api/installs/config': {
      method: 'POST',
      handler: handleApiInstallConfig,
    },
    '/account/api/installs/preview': {
      method: 'POST',
      handler: handleApiInstallPreview,
    },
    '/account/api/installs/install': {
      method: 'POST',
      handler: handleApiInstallRun,
    },
    '/account/api/installs/updates': {
      method: 'GET',
      handler: handleApiInstallUpdates,
    },
    '/account/api/installs/rename': {
      method: 'POST',
      handler: handleApiInstallRename,
    },
    '/account/api/sites/files': {
      method: 'GET',
      handler: handleApiSiteFiles,
    },
    '/account/api/sites/delete': {
      method: 'POST',
      handler: handleApiSiteDelete,
    },
  };
}