OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / blobs.js

145 lines · javascript ref:
// blobs.js — media. Profile avatar/banner and post image attachments.
//
// ★ WHY WE PROXY RATHER THAN LINK STRAIGHT AT THE MEMBRANE. The atproto side already serves every
// blob at `pds.7rnx.net/xrpc/com.atproto.sync.getBlob`, so the cheapest thing would be to put that
// URL in the AS2. Two reasons not to:
//   1. A published URL is effectively PERMANENT — remote servers cache and re-fetch it for years.
//      Emitting someone else's endpoint means we can never change how blobs are served without
//      breaking every object we ever published. On our own host it stays our decision.
//   2. It couples image rendering to the membrane being current, and the membrane is the component
//      with the known liveness stall.
// The backend can later become the peer's `get-blob` RPC with no change to any published URL.
//
// Blobs are immutable and content-addressed, so caching is trivially safe and the cache header is
// aggressive on purpose.

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

// ⚠ Lives in as2-project.js now, beside objectIdFor: it is AS2 vocabulary — a URL this bridge
// serves — and the favela path needs it WITHOUT this module's atproto blob-ref handling behind it.
import { blobUrl } from './as2-project.js'
export { blobUrl }

// atproto blob refs are `{ $type:'blob', ref:{ $link:<cid> }, mimeType, size }`, but older records
// carry a bare cid string. Accept both — a profile that fails to project because of a legacy shape
// would silently lose the avatar.
export function blobCid (ref) {
  if (!ref) return null
  if (typeof ref === 'string') return ref
  if (typeof ref.ref === 'string') return ref.ref
  if (ref.ref && typeof ref.ref.$link === 'string') return ref.ref.$link
  if (typeof ref.cid === 'string') return ref.cid
  return null
}

export const blobMime = (ref) => (ref && typeof ref.mimeType === 'string') ? ref.mimeType : 'application/octet-stream'

// app.bsky.actor.profile -> the AS2 actor fields Mastodon renders.
export function projectProfile ({ profile, host }) {
  if (!profile) return {}
  const out = {}
  if (typeof profile.displayName === 'string' && profile.displayName) out.name = profile.displayName
  if (typeof profile.description === 'string') out.summary = profile.description

  const avatar = blobCid(profile.avatar)
  if (avatar) out.icon = { type: 'Image', mediaType: blobMime(profile.avatar), url: blobUrl({ host, cid: avatar }) }

  const banner = blobCid(profile.banner)
  if (banner) out.image = { type: 'Image', mediaType: blobMime(profile.banner), url: blobUrl({ host, cid: banner }) }

  return out
}

// app.bsky.feed.post embed -> AS2 attachment.
// Only images. Video/external/record embeds are deliberately not projected: each needs its own
// decision (a bare link for an external card is worse than nothing, and a quote-post has no agreed
// AS2 form), and emitting a broken attachment is worse than emitting none.
export function projectAttachments ({ record, host }) {
  const embed = record && record.embed
  if (!embed) return []

  // `app.bsky.embed.images` directly, or nested under `media` in a recordWithMedia embed.
  const images = embed.images || (embed.media && embed.media.images)
  if (!Array.isArray(images)) return []

  const out = []
  for (const img of images) {
    const cid = blobCid(img && img.image)
    if (!cid) continue
    const doc = {
      type: 'Document',
      mediaType: blobMime(img.image),
      url: blobUrl({ host, cid })
    }
    // Alt text is an accessibility obligation, not a nicety — carry it whenever the record has it.
    if (typeof img.alt === 'string' && img.alt) doc.name = img.alt
    if (img.aspectRatio && img.aspectRatio.width && img.aspectRatio.height) {
      doc.width = img.aspectRatio.width
      doc.height = img.aspectRatio.height
    }
    out.push(doc)
  }
  return out
}

// Fetch blob bytes for the proxy. Content-addressed, so the CID is checked by the source; we do not
// re-verify here (that would need the full CAR/CID machinery for no benefit over an https fetch to
// our own membrane).
export function createBlobFetcher ({ did, pdsUrl, doFetch }) {
  return async function fetchBlob (cid) {
    if (!/^[a-z0-9]+$/i.test(cid)) return null // path-safety: CIDs are alphanumeric
    const url = `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`
    const res = await doFetch(url, { headers: { accept: '*/*', 'user-agent': 'ap-bridge' }, binary: true })
    if (!res.ok) return null
    return { body: res.bytes ? await res.bytes() : await res.text(), contentType: res.headers['content-type'] || 'application/octet-stream' }
  }
}


/**
 * Blobs for a user whose feed this bridge HOLDS.
 *
 * ⛔ NOT createBlobFetcher. That one fetches by CID over HTTP from `config.pdsUrl` — the primary's
 * atproto store — and a registered user's avatar is a favela oid in the shared overlay. Asking the
 * wrong store returned nothing, so the actor advertised an icon that 404d, which a client renders
 * as no avatar at all. Same failure shape as the profile: served confidently, wrong quietly.
 *
 * ⚠ The type comes from the BYTES. favela-peer sniffs it from the file's head rather than trusting
 * a filename or what a record claims, and this passes that through unchanged.
 */
export function blobFetcherFor ({ files, readFile, log = () => {} }) {
  return async function fetchBlob (oid) {
    // ⛔ EVERY null SAYS WHY. Without this the route answers 404 for four different reasons — a
    // malformed oid, no Files at all, the overlay not having it, and an unreadable file — and they
    // are indistinguishable from outside. That cost an evening on one avatar: the fetcher failed
    // silently and the only visible symptom was a missing picture.
    const id = String(oid || '')
    if (!files) { log({ msg: 'blob: no files store for this user', oid: id.slice(0, 12) }); return null }
    if (!/^[0-9a-f]{8,128}$/i.test(id)) { log({ msg: 'blob: not an oid', oid: id.slice(0, 24) }); return null }

    let info
    try {
      info = await files.get(id)
    } catch (err) {
      log({ msg: 'blob: files.get threw', oid: id.slice(0, 12), error: err.message })
      return null
    }
    if (!info || !info.path) {
      // The overlay could not produce the bytes: nobody serving them, or the fetch timed out.
      log({ msg: 'blob: overlay has no bytes', oid: id.slice(0, 12) })
      return null
    }

    try {
      const body = await readFile(info.path)
      log({ msg: 'blob served', oid: id.slice(0, 12), bytes: body.length, type: info.mimeType })
      return { body, contentType: info.mimeType || 'application/octet-stream' }
    } catch (err) {
      // On disk a moment ago and unreadable now: say we do not have it rather than serve a truncated
      // image, which a client caches for a year under an immutable header.
      log({ msg: 'blob: on disk but unreadable', oid: id.slice(0, 12), path: info.path, error: err.message })
      return null
    }
  }
}