chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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;
}
}