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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
493 lines · 16.0 KB Raw History
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '#/components/atoms/button.jsx';
import { Input } from '#/components/atoms/input.jsx';
import { Label } from '#/components/atoms/label.jsx';
import { Textarea } from '#/components/atoms/textarea.jsx';
import { OptionRow } from '#/components/molecules/option-row.jsx';
import { PageNotice } from '#/components/molecules/page-notice.jsx';
import { RefSelect } from '#/components/molecules/ref-select.jsx';
import { Screen } from '#/components/molecules/screen.jsx';
import { SettingsPanel } from '#/components/molecules/settings-panel.jsx';
import {
  account,
  branchRefs,
  defaultRef,
  loadRepos,
  repoRecord,
  shortRef,
} from '#/lib/git.js';
import { resolveIdentity } from '#/lib/identity.js';
import { useNavigate } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
import {
  deleteRepo,
  packageKey,
  publishedPackages,
  renameRepo,
  updateSettings,
} from '#/lib/settings.js';

/** How each registry reads beside a package name. */
const REGISTRY_LABELS = { npm: 'npm package', oci: 'container image' };

/** The branch names one repository record holds. */
const branchesOf = (record) =>
  branchRefs(record).map((ref) => shortRef(ref.name));

/**
 * What the repository is, and which branch a visitor lands on. Both are
 * fields of the repository record, which every push rewrites, so the server
 * reads the record and patches the two rather than replacing it.
 */
function GeneralPanel({ record, write }) {
  const branches = branchesOf(record);
  // The branch a visitor lands on today: the one the record names, or main
  // where it names none. A repository whose named branch is gone falls back
  // to the first it has, so the picker never shows a branch that is not
  // there.
  const landing = branches.includes(defaultRef(record))
    ? defaultRef(record)
    : (branches[0] ?? '');
  const [description, setDescription] = useState(record.description ?? '');
  const [defaultBranch, setDefaultBranch] = useState(landing);

  const save = () =>
    write({
      description: description.trim(),
      // An empty repository has no branch to name, and sending an empty one
      // would clear the branch a first push is about to create.
      ...(branches.length > 0 ? { defaultBranch } : {}),
    });

  return (
    <SettingsPanel
      title="General"
      note="What the repository is, and the branch a visitor lands on."
      value={{ description: description.trim(), defaultBranch }}
      onSave={save}
    >
      <div>
        <Label htmlFor="settings-description">Description</Label>
        <Textarea
          id="settings-description"
          value={description}
          onChange={(event) => setDescription(event.target.value)}
          placeholder="What this repository is for"
          rows={2}
          maxLength={3000}
          className="resize-none"
        />
      </div>
      <div>
        <Label htmlFor="settings-default-branch">Default branch</Label>
        {branches.length === 0 ? (
          <p className="text-[12.5px] text-faint">
            Branches appear here after the first push.
          </p>
        ) : (
          <RefSelect
            repo={record}
            value={defaultBranch}
            onSelect={setDefaultBranch}
            tags={false}
          />
        )}
      </div>
    </SettingsPanel>
  );
}

/**
 * Which branches the write guard defends. The rules are in the config record
 * beside the repository, which no push touches, so a protection outlives
 * every push it refuses.
 */
function BranchPanel({ record, write }) {
  const branches = branchesOf(record);
  const listed = record.protectedBranches ?? [];
  // A protected name whose branch is gone stays listed, so saving an
  // unrelated change does not drop the protection with it.
  const known = [...new Set([...branches, ...listed])].sort();
  const [chosen, setChosen] = useState(new Set(listed));

  const toggle = (/** @type {string} */ branch) =>
    setChosen((current) => {
      const next = new Set(current);
      if (next.has(branch)) next.delete(branch);
      else next.add(branch);
      return next;
    });

  const wanted = [...chosen].sort();

  return (
    <SettingsPanel
      title="Branch protection"
      note="A protected branch refuses a force push and refuses deletion. This server enforces that on the copy it holds."
      value={wanted}
      onSave={() => write({ protectedBranches: wanted })}
    >
      {known.length === 0 ? (
        <p className="text-[12.5px] text-faint">
          Branches appear here after the first push.
        </p>
      ) : (
        <div className="flex flex-col">
          {known.map((branch) => (
            <OptionRow
              key={branch}
              id={`protect-${branch}`}
              checked={chosen.has(branch)}
              onCheckedChange={() => toggle(branch)}
              name={branch}
              note={
                branches.includes(branch) ? undefined : 'no branch by this name'
              }
            />
          ))}
        </div>
      )}
    </SettingsPanel>
  );
}

/**
 * The account whose check records this repository trusts. A check is a
 * statement in its author's own repo, so anybody may publish one about a
 * public repository; naming one account is how a reader decides whose runs
 * are this repository's.
 */
function RunnerPanel({ record, write }) {
  const [runner, setRunner] = useState(record.runner ?? '');

  const save = async () => {
    const wanted = runner.trim();
    if (!wanted) return write({ runner: null });
    // A handle is what an owner knows their runner by, and the record holds
    // the DID a handle can be moved away from.
    const { did } = await resolveIdentity(wanted);
    await write({ runner: did });
  };

  return (
    <SettingsPanel
      title="Checks"
      note="The account this repository takes its check runs from. Leave it empty for no checks."
      value={runner.trim()}
      onSave={save}
    >
      <div>
        <Label htmlFor="settings-runner">Check runner</Label>
        <Input
          id="settings-runner"
          value={runner}
          onChange={(event) => setRunner(event.target.value)}
          placeholder="runner.example.com"
          className="font-mono"
        />
        <p className="mt-1.5 text-[12.5px] text-faint">
          A handle or a DID. The runner's results open in the Checks section.
        </p>
      </div>
    </SettingsPanel>
  );
}

/**
 * What this repository builds. A registry record names no repository and
 * atproto has no reverse index, so this list is the only thing connecting
 * the two.
 */
function PackagesPanel({ record, write }) {
  const linked = record.packages ?? [];
  const { data: published } = useQuery({
    queryKey: ['published-packages'],
    queryFn: publishedPackages,
    staleTime: Number.POSITIVE_INFINITY,
  });
  // A linked package that is no longer published stays listed, so saving an
  // unrelated change does not drop the link with it.
  const choices = [
    ...(published ?? []),
    ...linked.filter(
      (entry) =>
        !(published ?? []).some(
          (option) => packageKey(option) === packageKey(entry),
        ),
    ),
  ].sort((a, b) => packageKey(a).localeCompare(packageKey(b)));
  const [chosen, setChosen] = useState(new Set(linked.map(packageKey)));

  const toggle = (/** @type {string} */ key) =>
    setChosen((current) => {
      const next = new Set(current);
      if (next.has(key)) next.delete(key);
      else next.add(key);
      return next;
    });

  const wanted = choices.filter((entry) => chosen.has(packageKey(entry)));

  return (
    <SettingsPanel
      title="Packages"
      note="What this repository builds. The git browser lists a linked package beside the repository, with the command that installs it."
      value={wanted}
      onSave={() => write({ packages: wanted })}
    >
      {choices.length === 0 ? (
        <p className="text-[12.5px] text-faint">
          Packages appear here after the first publish to this account's
          registries.
        </p>
      ) : (
        <div className="flex flex-col">
          {choices.map((entry) => (
            <OptionRow
              key={packageKey(entry)}
              id={`package-${packageKey(entry)}`}
              checked={chosen.has(packageKey(entry))}
              onCheckedChange={() => toggle(packageKey(entry))}
              name={entry.name}
              note={REGISTRY_LABELS[entry.registry] ?? entry.registry}
            />
          ))}
        </div>
      )}
    </SettingsPanel>
  );
}

/**
 * Renaming the repository. The record key is its identity, so the issues,
 * pull requests and checks are unmoved. What moves is the address, which is
 * why this says so rather than leaving it to be discovered by a push that
 * fails.
 *
 * A repository in a space keeps its name: the space is what an address
 * resolves to and is keyed by that name.
 */
function RenamePanel({ repo, space, session, queryClient }) {
  const navigate = useNavigate();
  const [name, setName] = useState(repo);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  const rename = async (/** @type {{preventDefault: () => void}} */ event) => {
    event.preventDefault();
    setBusy(true);
    setError('');
    try {
      await renameRepo(session, repo, name.trim());
      await loadRepos();
      await queryClient.invalidateQueries();
      navigate(`/${encodeURIComponent(name.trim())}/settings`);
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    }
    setBusy(false);
  };

  if (space) {
    return (
      <SettingsPanel
        title="Repository name"
        note="A private repository is addressed by the space holding it, and a space is keyed by the name it was made under. This one keeps the name it has."
        value={repo}
        onSave={async () => {}}
      >
        <p className="font-mono text-[13px] text-faint">{repo}</p>
      </SettingsPanel>
    );
  }

  return (
    <form
      onSubmit={rename}
      className="overflow-hidden rounded-lg border border-border bg-card"
    >
      <div className="border-border border-b px-4 py-3">
        <h3 className="font-semibold text-[14px] leading-tight">
          Repository name
        </h3>
        <p className="mt-1 text-[12.5px] text-muted-foreground">
          The issues, pull requests and checks stay with the repository. The
          address changes, so every clone has to be re-pointed with git remote
          set-url.
        </p>
      </div>
      <div className="flex flex-col gap-4 px-4 py-4">
        <div>
          <Label htmlFor="settings-name">Name</Label>
          <div className="flex items-center gap-2">
            <span className="shrink-0 text-[13.5px] text-faint">
              {account.handle} /
            </span>
            <Input
              id="settings-name"
              value={name}
              onChange={(event) => setName(event.target.value)}
              pattern="[A-Za-z0-9._~\-]{1,512}"
              autoComplete="off"
              className="font-mono"
            />
          </div>
        </div>
      </div>
      <div className="flex flex-wrap items-center gap-3 border-border border-t bg-muted px-4 py-3">
        <Button
          type="submit"
          size="sm"
          disabled={busy || !name.trim() || name.trim() === repo}
        >
          {busy ? 'Renaming…' : 'Rename'}
        </Button>
        {error && (
          <span className="text-[12.5px] text-destructive">{error}</span>
        )}
      </div>
    </form>
  );
}

/**
 * Deleting the repository, which no other panel can be mistaken for: its own
 * surround, its own colour, and the name typed out before the button works.
 *
 * What goes is stated rather than implied. A reader who wants the history
 * afterwards has their clone or nothing.
 */
function DangerPanel({ repo, space, session, queryClient }) {
  const navigate = useNavigate();
  const [typed, setTyped] = useState('');
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState('');

  const remove = async (/** @type {{preventDefault: () => void}} */ event) => {
    event.preventDefault();
    setBusy(true);
    setError('');
    try {
      await deleteRepo(session, repo, space ?? '');
      await loadRepos();
      await queryClient.invalidateQueries();
      navigate('/');
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
      setBusy(false);
    }
  };

  return (
    <form
      onSubmit={remove}
      className="overflow-hidden rounded-lg border border-destructive/40 bg-card"
    >
      <div className="border-destructive/40 border-b px-4 py-3">
        <h3 className="font-semibold text-[14px] text-destructive leading-tight">
          Delete this repository
        </h3>
        <p className="mt-1 text-[12.5px] text-muted-foreground">
          The record, its settings and every bundle blob go. The history is gone
          from this server, and a clone somebody already took is the only copy
          left.
          {space
            ? ' The people you added lose their access with it.'
            : ' Anyone cloning it gets nothing.'}
        </p>
      </div>
      <div className="flex flex-col gap-4 px-4 py-4">
        <div>
          <Label htmlFor="settings-confirm">
            Type <span className="font-mono text-foreground">{repo}</span> to
            confirm
          </Label>
          <Input
            id="settings-confirm"
            value={typed}
            onChange={(event) => setTyped(event.target.value)}
            autoComplete="off"
            className="font-mono"
          />
        </div>
      </div>
      <div className="flex flex-wrap items-center gap-3 border-destructive/40 border-t bg-muted px-4 py-3">
        <Button
          type="submit"
          size="sm"
          variant="destructive"
          disabled={busy || typed !== repo}
        >
          {busy ? 'Deleting…' : 'Delete this repository'}
        </Button>
        {error && (
          <span className="text-[12.5px] text-destructive">{error}</span>
        )}
      </div>
    </form>
  );
}

/**
 * Everything about a repository that its owner sets rather than pushes.
 *
 * The settings are the owner's, so the section is theirs alone: the tab
 * appears for their sign-in and nobody else's, and every write runs on their
 * own PDS under their own grant. Each group saves on its own, because the
 * server takes one setting per request and leaves the rest as it found them.
 */
export function SettingsPage({ repo, space }) {
  const record = repoRecord(repo, space);
  const { session } = useSession();
  const queryClient = useQueryClient();
  const mine = Boolean(session && session.did === account.did);

  if (!record) return <PageNotice>No such repository.</PageNotice>;
  if (!mine) {
    return (
      <PageNotice>
        Settings write into {account.handle}'s own repo, so they need this
        account's sign-in rather than permission from anybody else. Sign in as{' '}
        {account.handle} from the bar above.
      </PageNotice>
    );
  }

  /** @param {import('#/lib/settings.js').SettingsChange} changes */
  const write = async (changes) => {
    await updateSettings(
      /** @type {import('#/lib/oauth.js').Session} */ (session),
      repo,
      space ?? '',
      changes,
    );
    // Every screen reads the repository from what the listing put in hand, so
    // the save is not finished until that listing is read again.
    await loadRepos();
    await queryClient.invalidateQueries();
  };

  return (
    <Screen>
      <div className="flex max-w-3xl flex-col gap-4 px-(--gutter) py-5">
        <GeneralPanel record={record} write={write} />
        <BranchPanel record={record} write={write} />
        <RunnerPanel record={record} write={write} />
        <PackagesPanel record={record} write={write} />
        <RenamePanel
          repo={repo}
          space={space}
          session={session}
          queryClient={queryClient}
        />
        <DangerPanel
          repo={repo}
          space={space}
          session={session}
          queryClient={queryClient}
        />
      </div>
    </Screen>
  );
}