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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
98 lines · 3.5 KB Raw History
// @pdsjs/sites - the account page's read-only view of deployed sites
//
// Fulfills core's SiteBrowserPort. Everything comes from the deploy records
// alone: sizes are summed from blob-ref metadata, so listing sites never
// reads a blob.

import { cborDecode } from '@pdsjs/core/repo';
import { isValidSiteName } from './hostname.js';
import { SITE_COLLECTION } from './lexicons.js';
import { loadSite } from './manifest.js';

/** Upper bound on sites listed; far above anything a subdomain-per-site
 * scheme can use in practice. */
const SITE_LIMIT = 500;

/**
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').ActorStoragePort} ctx.actorStorage
 * @param {() => Promise<string|null>} ctx.getDid - the hosted account's DID
 * @param {string} ctx.siteDomain - apex domain sites serve under
 * @param {string} [ctx.apexName] - site name bound to the apex (default "home")
 * @returns {import('./ports.js').SiteBrowserPort}
 */
export function createSiteBrowser(ctx) {
  const { actorStorage, getDid, siteDomain } = ctx;
  if (!actorStorage) throw new Error('createSiteBrowser requires actorStorage');
  if (!getDid) throw new Error('createSiteBrowser requires getDid');
  if (!siteDomain) throw new Error('createSiteBrowser requires siteDomain');
  const apexName = ctx.apexName || 'home';

  return {
    domain: siteDomain,
    apexName,
    async listSites() {
      /** @type {import('./ports.js').SiteRow[]} */
      const rows = [];
      /** @type {string|null} */
      let cursor = null;
      while (rows.length < SITE_LIMIT) {
        const page = await actorStorage.listRecords(
          SITE_COLLECTION,
          cursor,
          100,
        );
        for (const record of page.records) {
          const name = record.uri.split('/').pop() || '';
          const value = /** @type {{files?: unknown, createdAt?: unknown}} */ (
            cborDecode(
              record.value instanceof Uint8Array
                ? record.value
                : new Uint8Array(record.value),
            )
          );
          const files = Array.isArray(value.files) ? value.files : [];
          let totalSize = 0;
          for (const entry of files) {
            const size = /** @type {{blob?: {size?: unknown}}} */ (entry)?.blob
              ?.size;
            if (typeof size === 'number') totalSize += size;
          }
          rows.push({
            name,
            url:
              name === apexName
                ? `https://${siteDomain}`
                : `https://${name}.${siteDomain}`,
            fileCount: files.length,
            totalSize,
            deployedAt:
              typeof value.createdAt === 'string' ? value.createdAt : null,
            cid: record.cid,
            servable: name === apexName || isValidSiteName(name),
          });
        }
        if (!page.cursor) break;
        cursor = page.cursor;
      }
      return rows;
    },

    async listFiles(name) {
      const did = await getDid();
      if (!did) return null;
      // The served view of the site, base record folded in, so the list
      // matches what the hostname answers path for path.
      const manifest = await loadSite(actorStorage, did, name);
      if (!manifest) return null;
      return [...manifest.files.entries()]
        .map(([path, file]) => ({
          path,
          size: file.size,
          contentType: file.contentType || file.mimeType,
          cid: file.cid,
        }))
        .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
    },
  };
}