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

grain.social/grain

photography-focused photo sharing platform

grain.social
1 collaborator
0
2
144 lines · 5.9 KB Raw History
// Where a group is run. A group's DID document names its PDS, and on
// the opensocial design the host *is* that PDS: the fyi.opensocial.*
// methods are served from the same origin, and the host's own identity — the
// audience a service-auth token must name — is its did:web at that origin.
// Nothing here is opensocial-specific beyond that convention: resolve the DID,
// read the PDS, ask the PDS who it is.
const plcUrl = process.env.DID_PLC_URL ?? "https://plc.directory";
const cache = new Map<string, { url: string; did: string }>();

export async function groupHostFor(did: string): Promise<{ url: string; did: string }> {
  const hit = cache.get(did);
  if (hit) return hit;
  const doc = await fetch(
    did.startsWith("did:web:")
      ? `https://${did.slice("did:web:".length)}/.well-known/did.json`
      : `${plcUrl}/${did}`,
  ).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`could not resolve ${did}`))));
  const url: string | undefined = doc.service?.find(
    (s: any) => s.id === "#atproto_pds",
  )?.serviceEndpoint;
  if (!url) throw new Error(`${did} has no PDS`);
  const hostDoc = await fetch(`${url}/.well-known/did.json`).then((r) => (r.ok ? r.json() : null));
  const hostDid: string | undefined = hostDoc?.id;
  if (!hostDid) throw new Error(`${url} does not identify itself as a group host`);
  const out = { url: url.replace(/\/$/, ""), did: hostDid };
  cache.set(did, out);
  return out;
}

/** What a group's host lists about it: the public part of its meta space. */
export interface GroupListing {
  displayName?: string;
  description?: string;
  url?: string;
  joinPolicy?: string;
  /** Present when the group has an avatar; see `avatarUrl`. */
  avatar?: string;
  avatarUrl?: string;
  rules?: { rkey: string; title: string; text?: string }[];
}

/**
 * A group's profile and rules, as its host lists them.
 *
 * They live in the group's meta space, and a space cannot be read without
 * signing in as somebody — nor indexed, since nothing in a space reaches a
 * firehose. The host reads the space as the group and lists what its
 * access record makes public, unauthenticated, so this is the one read that
 * works for every viewer. Not part of the standard: a host that does not list
 * its groups yields nothing here, and the group falls back to its grain
 * profile. Cached, because a profile does not move often and a group page asks
 * on every visit.
 *
 * `fresh` skips the cache and refills it. The profile is edited on the host,
 * not here, so grain never learns it changed; whoever is acting as the group
 * is the one who just changed it, and they should not see it stale.
 */
const listingCache = new Map<string, { listing?: GroupListing; at: number }>();
const LISTING_TTL_MS = 10 * 60_000;

export async function groupListing(
  did: string,
  opts: { fresh?: boolean } = {},
): Promise<GroupListing | undefined> {
  const hit = listingCache.get(did);
  if (!opts.fresh && hit && Date.now() - hit.at < LISTING_TTL_MS) return hit.listing;
  let listing: GroupListing | undefined;
  try {
    const host = await groupHostFor(did);
    const res = await fetch(`${host.url}/xrpc/fyi.opensocial.listGroups`);
    const body = (await res.json()) as { groups?: (GroupListing & { did: string })[] };
    const found = body.groups?.find((c) => c.did === did);
    if (found) {
      const { did: _, ...rest } = found;
      // The host serves a group's images itself, renderably; the CID is
      // only a cache-buster there.
      listing = {
        ...rest,
        ...(rest.avatar
          ? { avatarUrl: `${host.url}/img/${did}/avatar?v=${rest.avatar.slice(-12)}` }
          : {}),
      };
    }
  } catch {
    // A host that will not answer is not an error worth a page for: the group
    // shows what grain itself knows.
  }
  listingCache.set(did, { listing, at: Date.now() });
  return listing;
}

/** Call a fyi.opensocial.* procedure on the group's host as the viewer,
 *  with service auth minted by the viewer's own PDS. */
export async function callGroupHost(
  pds: (
    nsid: string,
    options?: { params?: Record<string, string> },
  ) => Promise<Record<string, unknown>>,
  group: string,
  lxm: string,
  body: Record<string, unknown>,
): Promise<{ ok: boolean; status: number; body: any }> {
  const host = await groupHostFor(group);
  const { token } = (await pds("com.atproto.server.getServiceAuth", {
    params: { aud: host.did, lxm },
  })) as { token: string };
  const res = await fetch(`${host.url}/xrpc/${lxm}`, {
    method: "POST",
    headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
    body: JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  return { ok: res.ok, status: res.status, body: json };
}

/**
 * The group host this Grain creates groups on, if it creates any.
 *
 * Joining works with any host: a group's DID document says where it lives.
 * Creating one needs a host that has agreed to provision groups for Grain,
 * which is configuration — GROUP_HOST_URL — not something to discover. The
 * domain a new handle ends in is the host's own answer, from describeServer.
 */
let provisioning:
  | Promise<{ url: string; did: string; handleDomain: string } | undefined>
  | undefined;
export function provisioningHost() {
  const url = process.env.GROUP_HOST_URL?.replace(/\/$/, "");
  if (!url) return Promise.resolve(undefined);
  provisioning ??= (async () => {
    const [hostDoc, described] = await Promise.all([
      fetch(`${url}/.well-known/did.json`).then((r) => (r.ok ? r.json() : null)),
      fetch(`${url}/xrpc/com.atproto.server.describeServer`).then((r) => (r.ok ? r.json() : null)),
    ]);
    const did: string | undefined = hostDoc?.id;
    const handleDomain: string | undefined = described?.availableUserDomains?.[0];
    if (!did || !handleDomain) throw new Error(`${url} does not describe itself as a group host`);
    return { url, did, handleDomain };
  })().catch((err) => {
    provisioning = undefined;
    throw err;
  });
  return provisioning;
}