OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / ap-followers.js

122 lines · javascript ref:
// ap-followers — read the ActivityPub side of a DID's follower graph.
//
// THE POINT. favela-web's follower list shows follows it has indexed from atproto. The same identity
// also has an ActivityPub actor (ap-bridge), and people follow THAT. Those followers were invisible
// here, so a page claiming to list "followers" was quietly listing only some of them.
//
// ★ THE BINDING IS THE ACTOR'S OWN CLAIM, NOT OUR CONFIG. We fetch the actor document and read
// `alsoKnownAs`; only if it names the DID being viewed do we attribute its followers to that profile.
// Configuring "actor X belongs to DID Y" by hand would let a typo attribute strangers' followers to
// someone, and would drift the moment the actor changed.
//
// ⚠ `alsoKnownAs` is a CLAIM, not proof — the same lesson handles taught us (a DID can assert any
// handle; only bidirectional verification settles it). The rigorous check is that the DID's own
// document points BACK at the actor. We do not do that here, and this is only safe because the actor
// URL is OUR OWN bridge, supplied by the operator via config — a trusted endpoint describing identities
// we already host. Do NOT reuse this to ingest a stranger's actor without adding the reverse check.
//
// FAILS SOFT, ALWAYS. This is an optional, additional source over a network hop to another host. If
// ap-bridge is down, slow, or returns nonsense, the follower page must still render its atproto
// followers. An enrichment that can break the page it enriches is worse than the gap it fills.

const DEFAULT_TIMEOUT = 2500
const CACHE_MS = 60_000

// "https://prsm.space/users/fleeky" -> "@fleeky@prsm.space" — the form a fediverse user recognises as
// their own address. Purely derived from the URL; we do not fetch each follower (a page of them would
// be a page of cross-host requests, and the address is the part that identifies them anyway).
export function actorHandle (uri) {
  try {
    const u = new URL(uri)
    const segs = u.pathname.split('/').filter(Boolean)
    const name = segs[segs.length - 1]
    if (!name) return null
    return `@${name}@${u.host}`
  } catch { return null }
}

function isHttps (s) {
  try { return new URL(s).protocol === 'https:' } catch { return false }
}

export function createApFollowers ({ base = null, doFetch = null, timeoutMs = DEFAULT_TIMEOUT, cacheMs = CACHE_MS } = {}) {
  const enabled = !!(base && doFetch)
  const cache = new Map() // key -> { at, value }

  const withDeadline = (p) => Promise.race([
    p,
    new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs))
  ])

  async function getJson (url) {
    const hit = cache.get(url)
    if (hit && Date.now() - hit.at < cacheMs) return hit.value
    let value = null
    try {
      const res = await withDeadline(doFetch(url, { headers: { accept: 'application/activity+json' } }))
      if (res && res.ok) value = await res.json()
    } catch { value = null }
    cache.set(url, { at: Date.now(), value })
    return value
  }

  return {
    get enabled () { return enabled },

    // -> { did, actor, followersUrl } | null. The actor's self-description, including which DID it
    // claims to be. Cached, because every follower-page render would otherwise re-fetch it.
    async identity () {
      if (!enabled) return null
      const doc = await getJson(base.replace(/\/+$/, '') + '/ap/actor')
      if (!doc || typeof doc !== 'object') return null
      const aka = Array.isArray(doc.alsoKnownAs) ? doc.alsoKnownAs : (doc.alsoKnownAs ? [doc.alsoKnownAs] : [])
      const did = aka.find((v) => typeof v === 'string' && v.startsWith('did:')) || null
      if (!did) return null
      return { did, actor: doc.id || null, followersUrl: typeof doc.followers === 'string' ? doc.followers : null }
    },

    // The set of rkeys ap-bridge has actually PROJECTED, read from its outbox — the observed half of
    // the reach display (lib/reach.js). One fetch covers a whole page of posts.
    //
    // Returns null (not an empty Set) when the AP side is unconfigured or unreachable, because
    // "nothing projected" and "could not ask" must not render the same way. Object ids are
    // `<base>/ap/objects/<rkey>`, matching ap-bridge's objectIdFor().
    async projectedRkeys (did) {
      if (!enabled || !did) return null
      const id = await this.identity()
      if (!id || id.did !== did) return null
      const coll = await getJson(base.replace(/\/+$/, '') + '/ap/outbox')
      if (!coll || !Array.isArray(coll.orderedItems)) return null
      const set = new Set()
      for (const it of coll.orderedItems) {
        const uri = typeof it === 'string' ? it : (it && (typeof it.id === 'string' ? it.id : null))
        const m = uri && /\/ap\/objects\/([^/?#]+)$/.exec(uri)
        if (m) set.add(m[1])
      }
      return set
    },

    // Followers of `did`, or [] when this actor is not that DID. Returns rows shaped for the follower
    // list: no `did` (we hold no DID for a fediverse account), a `url` to their own profile, and a
    // `source` label so the page can say where the row came from rather than blending it in.
    async followersOf (did) {
      if (!enabled || !did) return []
      const id = await this.identity()
      // NOT our DID -> not our followers. Silently returning the wrong actor's followers would be the
      // worst possible failure here.
      if (!id || id.did !== did || !id.followersUrl) return []
      const coll = await getJson(id.followersUrl)
      const items = coll && Array.isArray(coll.orderedItems) ? coll.orderedItems : []
      const out = []
      for (const it of items) {
        // Items may be bare URI strings or embedded objects; take the id either way, and require https
        // so a malformed collection can never put a javascript: or http: URL into an href.
        const uri = typeof it === 'string' ? it : (it && typeof it.id === 'string' ? it.id : null)
        if (!uri || !isHttps(uri)) continue
        out.push({ did: null, url: uri, displayName: actorHandle(uri) || uri, handle: null, verified: false, source: 'fediverse' })
      }
      return out
    }
  }
}