OverviewFilesCommitsIssuesDocsReleasesPeers

favela-bridge / lib / profile.js

135 lines · javascript ref:
// profile.js — what a favela profile becomes on bsky. Pure: no I/O, no clock, no throw.
//
// The fediverse got a profile projection from the start (ap-bridge's buildActor reads name, summary,
// icon and image). bsky never did: favela-bridge projected POSTS and nothing else, so a person could
// set a display name, a bio, an avatar and a banner, watch all four reach Mastodon, and find their
// bsky profile still empty with nothing anywhere reporting a failure. There was no bug to find —
// the projection had simply never been written.
//
// ⛔ IT DECIDES, IT DOES NOT UPLOAD. Images are named by oid here and resolved to blob refs by the
// caller, because uploading is I/O and this stays testable without a repo, a socket or a network.
//
// ⛔ AND IT NEVER TRUNCATES. `plan.js` withholds a post rather than cutting it at 300 — publishing
// something the author did not write, under their name, with nothing to say it was cut. A field is
// the same: an over-long display name is OMITTED and reported, and the rest of the profile still
// goes. Dropping one field is recoverable; publishing half a sentence as though it were the whole
// one is not.

import { strip } from 'favela-markdown/lib/strip.js'

// app.bsky.actor.profile: displayName maxGraphemes 64, description maxGraphemes 256.
export const MAX_NAME_GRAPHEMES = 64
export const MAX_DESCRIPTION_GRAPHEMES = 256

// ⛔ png and jpeg ONLY. favela accepts gif and webp too (avatars.js types from magic bytes and
// refuses only SVG), and bsky's lexicon does not — so an avatar this peer holds happily can be one
// bsky will not take. Reported rather than converted: re-encoding somebody's picture to fit a
// destination is a decision about their content, not a projection of it.
export const BSKY_IMAGE_TYPES = ['image/png', 'image/jpeg']

// ⛔ AND A SIZE GATE, which was missing entirely. app.bsky.actor.profile caps avatar and banner at
// 1,000,000 bytes — HALF the post-embed ceiling, and different in kind: a post image accepts
// image/*, a profile picture accepts png and jpeg only. Without this an oversized avatar was sent
// and refused by the PDS, which reads from here as a picture that simply never appears.
export const MAX_PROFILE_IMAGE_BYTES = 1000000

const graphemesOf = (s) => [...String(s || '')].length

// TOTAL, like everything else here: a bio that will not parse degrades to its own text rather than
// taking the profile down with it.
function plainText (md) {
  try {
    const r = strip(md)
    return ((r && r.text) || '').trim()
  } catch {
    return String(md || '').trim()
  }
}

/**
 * planProfile({ name, bio, avatar, banner }) -> { project, record, images, losses }
 *
 * `avatar` and `banner` are `{ oid, mimeType, size }` or null — what the peer holds.
 * `images` names which oids the caller must resolve to blob refs, keyed by the field they fill.
 *
 * TOTAL: never throws. An unreadable profile projects nothing rather than a wrong something.
 */
export function planProfile ({ name = null, bio = null, avatar = null, banner = null } = {}) {
  const losses = []
  const record = { $type: 'app.bsky.actor.profile' }
  const images = {}

  const n = typeof name === 'string' ? name.trim() : ''
  if (n) {
    if (graphemesOf(n) > MAX_NAME_GRAPHEMES) {
      losses.push('display name omitted — ' + graphemesOf(n) + ' graphemes against a limit of ' + MAX_NAME_GRAPHEMES)
    } else {
      record.displayName = n
    }
  }

  // ⛔ STRIPPED, because a bsky description is PLAIN TEXT. The bio is markdown — that is the whole
  // premise of favela-markdown — and handing it over raw publishes `the **dev** box` with the
  // asterisks showing. Measured on the live profile before this: bsky's own AppView rendered them
  // literally. `strip` is the same projection every other plain-text destination already uses.
  const d = typeof bio === 'string' ? plainText(bio) : ''
  if (d) {
    if (graphemesOf(d) > MAX_DESCRIPTION_GRAPHEMES) {
      losses.push('description omitted — ' + graphemesOf(d) + ' graphemes against a limit of ' + MAX_DESCRIPTION_GRAPHEMES)
    } else {
      record.description = d
    }
  }

  for (const [field, img] of [['avatar', avatar], ['banner', banner]]) {
    if (!img || !img.oid) continue
    if (!BSKY_IMAGE_TYPES.includes(img.mimeType)) {
      losses.push(field + ' omitted — bsky takes ' + BSKY_IMAGE_TYPES.join(' or ') + ', this is ' + (img.mimeType || 'untyped'))
      continue
    }
    if (typeof img.size === 'number' && img.size > MAX_PROFILE_IMAGE_BYTES) {
      losses.push(field + ' omitted — ' + img.size + ' bytes against a profile limit of ' + MAX_PROFILE_IMAGE_BYTES)
      continue
    }
    images[field] = img
  }

  // ⛔ AN EMPTY RECORD IS NOT A PROFILE. Writing `{$type}` alone would replace whatever is there
  // with nothing — a projection that erases. If a peer has published no profile, there is nothing
  // to project and the right act is to leave the destination alone.
  const project = !!(record.displayName || record.description || Object.keys(images).length)
  return { project, record, images, losses }
}

/**
 * Is the profile we would write already the one that is there?
 *
 * ⛔ COMPARED AGAINST THE REPO, never against a local note of what we last sent. A projector that
 * trusts its own memory writes when it should not and stays silent when it should not — the shape
 * that let ap-bridge deliver into a follower relationship that existed only in our own file.
 *
 * Blob refs compare by their CID: the bytes are content-addressed, so an identical picture yields an
 * identical ref and a changed one cannot collide with it.
 */
export function sameProfile (desired, actual) {
  if (!desired || !actual) return false
  const norm = (r) => JSON.stringify({
    displayName: r.displayName || null,
    description: r.description || null,
    avatar: cidOf(r.avatar),
    banner: cidOf(r.banner)
  })
  return norm(desired) === norm(actual)
}

function cidOf (blob) {
  if (!blob) return null
  if (typeof blob === 'string') return blob
  if (blob.ref && typeof blob.ref === 'object' && blob.ref.$link) return blob.ref.$link
  if (typeof blob.ref === 'string') return blob.ref
  if (blob.cid) return blob.cid
  return null
}

export default { planProfile, sameProfile }