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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
92 lines · 3.5 KB Raw History
// An account's picture, from the account.
//
// A profile record is an ordinary public record in the account's own repo, and
// the picture is a blob beside it. Both are served by that account's own PDS,
// which is a host this app is already talking to: reading a member's records,
// or asking an authority about a space, means contacting it either way.
//
// That is the whole point of reading it here rather than from an appview. An
// appview would answer faster and for more accounts, but asking one tells it
// which accounts this browser is looking at, and the avatar URL it returns
// points at a CDN, so the picture itself would be a second disclosure. Neither
// is worth a decoration.

import { repoHost } from '#/lib/credential.js';
import { nsidDomain } from '#/lib/format.js';
import { resolveIdentity } from '#/lib/identity.js';

const PROFILE_COLLECTION = 'app.bsky.actor.profile';

/**
 * @typedef {Object} Profile
 * @property {string|null} avatar - a URL this page may use as an image source
 * @property {string|null} displayName
 */

/**
 * The profile an account publishes, or empty where it publishes none. An
 * account that has never used a microblogging app has no such record, which is
 * not an error and reads as no picture.
 *
 * @param {string} did
 * @returns {Promise<Profile>}
 */
export async function profileOf(did) {
  const empty = { avatar: null, displayName: null };
  try {
    const host = await repoHost(did);
    const url = new URL(`${host}/xrpc/com.atproto.repo.getRecord`);
    url.searchParams.set('repo', did);
    url.searchParams.set('collection', PROFILE_COLLECTION);
    url.searchParams.set('rkey', 'self');
    const response = await fetch(url);
    if (!response.ok) return empty;

    const value = (await response.json())?.value ?? {};
    const cid = value.avatar?.ref?.$link;
    const blob = new URL(`${host}/xrpc/com.atproto.sync.getBlob`);
    blob.searchParams.set('did', did);
    blob.searchParams.set('cid', String(cid));

    return {
      // An image source needs no CORS, so the blob route serves it directly.
      avatar: typeof cid === 'string' ? blob.toString() : null,
      displayName:
        typeof value.displayName === 'string' && value.displayName
          ? value.displayName
          : null,
    };
  } catch {
    return empty;
  }
}

/**
 * The picture belonging to the app a space type names.
 *
 * An NSID reverses to a domain, and these domains are atproto handles: the
 * account behind `my.bulletin.board` is `bulletin.my`. So the app's icon is
 * just an account's picture, read the same way and from the same place as
 * anybody else's, which is that account's own PDS.
 *
 * This is why it is not a favicon. A favicon means asking the app's web server,
 * or an icon service that has seen every domain anyone asked it about, and
 * either one is told which apps' spaces this browser is reading. Resolving the
 * handle costs one DNS lookup through the resolver this app already uses, and
 * the picture then comes from a PDS, which is the kind of host it talks to
 * anyway.
 *
 * @param {string} spaceType - the NSID a space declares
 * @returns {Promise<Profile>}
 */
export async function appProfileOf(spaceType) {
  const empty = { avatar: null, displayName: null };
  try {
    // resolveIdentity checks the document claims the handle back, so a domain
    // whose DNS names somebody else's DID lends this app nobody's picture.
    const { did } = await resolveIdentity(nsidDomain(spaceType));
    return await profileOf(did);
  } catch {
    return empty;
  }
}