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