OverviewFilesCommitsIssuesDocsReleasesPeers

favela-peer / lib / avatars.js

157 lines · javascript ref:
// Avatars — bytes in the overlay, an oid on the User.
//
// ⛔ NOT a record, and not a data: URI in anyone's frontmatter.
//
// A display name and a picture are properties of an ACCOUNT, not of something you published.
// `NAMING.md` §2 is explicit that a name must never appear in a record — a name is reclaimable, so
// a name baked into immutable bytes can later denote a different person. The same argument covers
// the picture: it changes, and rewriting every record on a change is exactly what a mutable
// registry row exists to avoid.
//
// So this follows pear-git-v3 exactly: `profilePicture` is a hyper-overlay CONTENT HASH stored as a
// plain string on the user record, and the bytes live in the overlay like any other content.
//
// ── Why the overlay and not a blob store ──────────────────────────────────────────────────────
//
// There is no blob store. The overlay already stores files by content hash and replicates them by
// hash — it is what every record's bytes already ride. An avatar is a file; nothing new is needed.
//
// ── The write order ───────────────────────────────────────────────────────────────────────────
//
// Hash first, then name the file by its hash, then register. The oid cannot be known before the
// bytes exist, so writing straight to the final name would mean guessing it.
//
// ⛔ PATHS, not bytes. An earlier version took base64 in and handed base64 back over the control
// socket, with a comment beside it saying the bytes travel over the overlay — true between PEERS
// and false for the local hop, which encoded a file to cross a unix socket to a process on the same
// machine that wrote it straight back to disk. `registerFile` takes a path; so does this.

import fs from '#node:fs'
import { join } from '#node:path'
import b4a from 'b4a'

// 512 KiB. An avatar rides the socket as base64 on the way in and is re-fetched by peers on the way
// out, so it is capped for both reasons. Stated in bytes of IMAGE, not of base64.
export const MAX_BYTES = 512 * 1024

// ⛔ A BANNER IS NOT A THUMBNAIL, and capping it like one is why banners silently never worked. The
// avatar cap above is a POLICY for a picture shown at thumbnail size beside every byline; a banner is
// a wide header — Bluesky's own spec is 3000×1000 — and at any reasonable quality it does not fit in
// 512 KiB. Measured on a live identity: `icon` was set on the fediverse actor and `image` was null,
// and the atproto profile had no banner either, because the upload had thrown before either
// projection ever saw one.
//
// ⚠ 1,000,000 EXACTLY, because that is favela-bridge's MAX_PROFILE_IMAGE_BYTES — bsky's ceiling for a
// profile image. Accepting a banner this node could not project would move the failure from a message
// at upload time, which a person can act on, to a "loss" line in a projection log they never read.
// Mastodon's default is higher, so this is the binding constraint of the two destinations.
export const BANNER_MAX_BYTES = 1000000

// Typed from the MAGIC BYTES, never from a filename or a client-supplied content-type. An extension
// is a claim by whoever named the file; the first four bytes are the fact.
//
// ⛔ SVG is not here on purpose: it is a document that can carry script and fetch remote resources.
// png, jpeg, gif and webp cannot express either.
export function sniff (bytes) {
  if (!bytes || bytes.length < 12) return null
  const b = bytes
  if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
  if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
  if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
  if (b4a.toString(b.subarray(0, 4)) === 'RIFF' && b4a.toString(b.subarray(8, 12)) === 'WEBP') return 'image/webp'
  return null
}

export class Avatars {
  constructor (storage, overlay) {
    this.dir = join(storage, 'avatars')
    this.overlay = overlay
  }

  _path (oid) { return join(this.dir, oid) }

  /**
   * Store an image that already exists on disk, returning `{ oid, mimeType, size }`.
   *
   * ⛔ Refuses anything that is not an inert raster image, by its bytes. The caller may be relaying
   * a browser upload, so nothing it says about the file is trusted.
   */
  async put (srcPath, { maxBytes = MAX_BYTES, kind = 'avatar' } = {}) {
    if (typeof srcPath !== 'string' || !srcPath) throw new Error('an ' + kind + ' needs a path')
    let bytes
    try { bytes = fs.readFileSync(srcPath) } catch { throw new Error('no file at ' + srcPath) }
    if (!bytes.length) throw new Error('that file is empty')

    // ⚠ This cap is a POLICY, not a transport limit — an avatar is shown at thumbnail size and is
    // re-fetched by every peer that renders a byline, so a large one is waste rather than an error
    // in the plumbing. Files posted in a record have no cap; there is nothing to cap.
    //
    // ⛔ THE CAP IS THE CALLER'S, because the same store holds two different kinds of picture. It was
    // fixed at the avatar's, so a banner — a wide header, not a thumbnail — was refused at a size no
    // banner is, and the profile kept an avatar and no banner with nothing downstream able to say why.
    if (bytes.length > maxBytes) {
      throw new Error('that ' + kind + ' is ' + Math.round(bytes.length / 1024) + ' KiB and the cap is ' +
        Math.round(maxBytes / 1024) + ' KiB — scale it down first')
    }
    const mimeType = sniff(bytes)
    if (!mimeType) {
      throw new Error('that is not a png, jpeg, gif or webp — those are the four an avatar may be, ' +
        'and the check is on the bytes rather than the file name')
    }

    fs.mkdirSync(this.dir, { recursive: true })
    const h = await this.overlay.hashFile(srcPath)
    const oid = h && h.contentHash
    if (!oid) throw new Error('the overlay could not hash the image')
    // Named by its hash, then registered. Identical images collapse to one file, across every
    // account this node holds.
    const dest = this._path(oid)
    fs.copyFileSync(srcPath, dest)
    await this.overlay.registerFile('/avatars/' + oid, dest, { size: bytes.length })

    return { oid, mimeType, size: bytes.length }
  }

  /**
   * Make sure the image is on local disk and say where it is: `{ path, mimeType, size }`, or null.
   *
   * ⛔ Returns null rather than throwing when it cannot be had. An avatar that has not replicated
   * yet is a missing picture, not a broken page — the same distinction the record log makes between
   * absent and unresolved.
   */
  async get (oid) {
    if (typeof oid !== 'string' || !/^[0-9a-f]{8,128}$/i.test(oid)) return null
    const p = this._path(oid)

    const describe = (at = p) => {
      try {
        const bytes = fs.readFileSync(at)
        const mimeType = sniff(bytes)
        return mimeType ? { path: at, mimeType, size: bytes.length } : null
      } catch {
        return null
      }
    }

    const local = describe()
    if (local) return local

    try {
      fs.mkdirSync(this.dir, { recursive: true })
      const r = await this.overlay.fetchFile(oid, { destPath: p })
      if (!r) return null
      // ⛔ DESCRIBE WHERE IT ACTUALLY IS. On a LOCAL hit the overlay returns the path it already
      // holds the bytes at — which may be the OTHER store's directory, since Files and Avatars
      // fetch the same content hash from the same overlay and differ only in where they cache.
      // Re-reading our own expected path meant a file the overlay had was reported missing: on
      // ashatron, `file-get` for its own avatar answered {found:false} while the bytes sat in
      // avatars/<oid>, and every consumer downstream faithfully rendered no picture.
      return describe(r.destPath || p)
    } catch {
      return null
    }
  }
}

export default Avatars