// 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} */ 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} */ 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; } }