openvibers/openvibe.media Commits
Avatars: an object a profile points at cannot be deleted (#330)
Changes
@@@ -2,6 +2,15 @@## Unreleased++- An avatar a person's profile points at cannot be deleted out from under them (incident 2026-11-06: a cleanup of++ public screenshot copies deleted eleven avatars). `media_avatar_refs` (migration `0020_avatar_refs.sql`) records++ each profile slot → object; `model.softDelete` refuses an avatar with an unreleased reference and `DELETE` answers++ 409 `media.object.avatar_in_use`. The internal `POST /internal/avatar-adopt` and `/internal/avatar-release` routes++ write it, and `/internal/avatar-ingest` records it when given `product` and `ref`. Replacing a picture releases the++ previous reference in the same transaction; account deletion releases the person's references first. Ingest also++ reads our own `/o/<id>` and `/p/<slug>/screenshot` sources locally instead of fetching them, reuses an avatar the++ subject already owns, and stores a 1600×500 `shape: 'banner'` crop. Test: `test/avatar-guard.test.js`.++- Operator retirement for an object with no good copy (`scripts/no-good-copy-report.js --retire <med_id> [--apply]`):the dry run changes nothing; only an object in the report, not public and under 1 MiB is retired (a public v1 row, anative public object, good-copy, 1 MiB+ and held objects are refused with the reason). For a legacy VOD/clip the v1@@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgexport and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay andthe account and revocation subscriptions)- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host--- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.91.0 (tokens, events outbox, usage readings, per-actor limits),++- `openvibe-contracts` v0.302.0, `openvibe-sdk` v0.91.0 (tokens, events outbox, usage readings, per-actor limits),`openvibe-shared` v4.122.0, pinned by release tarball## Capabilities@@@ -129,7 +129,7 @@ server/storage.orphans.scan (monthly storage orphan report, service-wide, report only),storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)client-ip.js trust proxy = loopback; req.ip is the only client address--(openvibe-shared v4.122.0, openvibe-contracts v0.294.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)++(openvibe-shared v4.122.0, openvibe-contracts v0.302.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator toolsscripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals@@@ -778,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does notrestart; afterwards `sudo ovhost rollback media --to <sha>`.<!-- versions:start -->--- openvibe-contracts: v0.294.0++- openvibe-contracts: v0.302.0- openvibe-sdk: v0.91.0- openvibe-shared: v4.122.0<!-- versions:end -->@@@ -6,7 +6,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg","port": 4100,"domain": "openvibe.media",-- "contracts": "openvibe-contracts v0.294.0",++ "contracts": "openvibe-contracts v0.302.0","sdk": "openvibe-sdk v0.91.0","shared": "openvibe-shared v4.122.0","capabilities": [@@@ -65,6 +65,24 @@ Community service principal (`svc:community`, capability `media.object.delete`),retire the old public object of a screenshot paste it has re-hosted privately(`server/objects/legacy-paste-retire.js`).++### Avatars a profile points at++++An avatar object (`kind avatar`) is a person's picture on a product. Each profile slot records the object it++points at in `media_avatar_refs` (`product` `network` for Network's `users.avatar_url`, `live` for Live's own++copy and a channel banner; `ref` names the slot, e.g. the person's Network id). The internal++`POST /internal/avatar-adopt` and `/internal/avatar-release` routes write it, and `POST /internal/avatar-ingest`++records it in the same call when it is given `product` and `ref`.++++`model.softDelete` refuses an avatar with an unreleased reference: `DELETE` answers 409++`media.object.avatar_in_use` naming the product and slot, so a cleanup cannot take the picture out from under a++working profile (incident 2026-11-06). Replacing the picture releases the previous reference in the same++transaction, and account deletion releases the person's references before erasing their objects. An operator path++that has to delete one anyway passes `allowInUse` to `softDelete`; no HTTP route does.++++`POST /internal/avatar-ingest` also answers our own objects without fetching them: an `openvibe.media/o/<id>` or++`/p/<slug>/screenshot` source is read from local storage, and an avatar already owned by the subject is reused++(`reused: true`) rather than copied. `shape: 'banner'` stores a 1600×500 crop instead of the 512×512 avatar.++Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.### How a row maps to its objectnnew file mode 100644@@@ -0,0 +1,23 @@++-- phase: expand++-- A person's profile picture is a native avatar object (kind 'avatar'), but nothing in Media knew which object a++-- profile still pointed at: a cleanup could soft-delete the object behind a live avatar and no service was told++-- (incident 2026-11-06, 11 avatars went this way). This table records the reference a product's profile holds, so++-- model.softDelete can refuse to delete an avatar while it is in use. A row is released, never deleted: the history++-- says which objects a person's picture moved through.++CREATE TABLE IF NOT EXISTS media_avatar_refs (++ id bigserial PRIMARY KEY,++ object_id text COLLATE "C" NOT NULL, -- med_… the profile points at++ subject text COLLATE "C", -- usr_… of the person when the product knows it++ product text COLLATE "C" NOT NULL, -- 'network' (users.avatar_url), 'live' (users.avatar_url, channel banner)++ ref text COLLATE "C" NOT NULL, -- the person's id in that product's own space++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ released_at text COLLATE "C"++);++++-- One live picture per person per product; a product may hold several kinds of slot, so the ref names the slot.++CREATE UNIQUE INDEX IF NOT EXISTS media_avatar_refs_active++ ON media_avatar_refs (product, ref) WHERE released_at IS NULL;++CREATE INDEX IF NOT EXISTS media_avatar_refs_object++ ON media_avatar_refs (object_id) WHERE released_at IS NULL;++CREATE INDEX IF NOT EXISTS media_avatar_refs_subject++ ON media_avatar_refs (subject) WHERE released_at IS NULL;@@@ -16,7 +16,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0","pg": "^8.23.0",@@@ -2122,9 +2122,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.294.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",-- "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",++ "version": "0.302.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",++ "integrity": "sha512-gvlRn+DRjGrNJVMefWZ+Qx7wgSQeCq7bbXLi4pdB9fyxqMyc8bUuYbvBmwbZND0YuB/vf/mOq60BBbd2GMWxzA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -20,7 +20,7 @@"express": "^4.19.2","iovalkey": "^0.4.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0","pg": "^8.23.0",@@@ -52,6 +52,9 @@ async function exportPart(db, subjects) {/** Erase what the subjects own → { erased, retained }. Each object in its own step: a hold stops only that one. */async function erase(db, subjects) {const model = require('./objects/model');++ // The account is gone, so no profile points at its pictures any more: release the avatar references first, or++ // the in-use guard (model.softDelete) would keep the person's own avatar forever.++ for (const s of subjects) await model.releaseAvatar({ subject: s });const erased = {};const retained = {};const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };@@@ -2,8 +2,15 @@// ═══════════════════════════════════════════════════════════════// Avatar ingestion — every avatar on the network is a file HERE.//--// POST /internal/avatar-ingest { url, user_id, username, subject } (internal key + loopback only)--// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes }++// POST /internal/avatar-ingest { url, user_id, username, subject, product?, ref?, shape? } (service token + loopback only)++// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes, reused? }++//++// Every source becomes an avatar object the person owns, so a profile never points at another product's object:++// • an openvibe.media address for an object or paste screenshot is read from local storage (no self-fetch)++// • an object that is already an avatar owned by this subject is reused, not copied again++// • anything else is fetched with the precautions below++// With `product` + `ref` the profile's reference is recorded (model.adoptAvatar), which is what makes the delete++// guard (model.softDelete) refuse a cleanup that would break a working profile. `shape: 'banner'` keeps a wide crop.//// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:@@@ -28,6 +35,9 @@ const crypto = require('crypto');const sharp = require('sharp');const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;++// The house shapes: a square picture everywhere a person appears; a wide one for a channel banner.++const SHAPES = { avatar: { width: SIZE, height: SIZE }, banner: { width: 1600, height: 500 } };++const PRODUCT_RE = /^[a-z][a-z0-9-]{1,20}$/;// The address rule is openvibe-shared/egress's (the strictest union the network's copies drifted from):// it also refuses the IPv6 forms that wrap a private IPv4 address (v4-compatible, 6to4, Teredo, NAT64),@@@ -91,13 +101,57 @@ async function safeFetchImage(rawUrl, { resolver = resolvePublic, fetcher = fetcthrow new Error('Too many redirects');}--/** Bytes → a clean square WebP. Throws when the bytes are not a decodable picture. */--async function toAvatar(buf) {++/** Bytes → a clean WebP in the requested house shape. Throws when the bytes are not a decodable picture. */++async function toAvatar(buf, { shape = 'avatar' } = {}) {++ const size = SHAPES[shape] || SHAPES.avatar;const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });const meta = await img.metadata();if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');-- const out = await img.rotate().resize(SIZE, SIZE, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();-- return { buffer: out, width: SIZE, height: SIZE };++ const out = await img.rotate().resize(size.width, size.height, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();++ return { buffer: out, width: size.width, height: size.height, shape };++}++++/** The bytes of one of our own objects, from its local copy. null when there is none (a remote-only object). */++async function localBytes(obj) {++ if (!obj) return null;++ const model = require('../objects/model');++ const locs = await model.listLocations(obj.id);++ for (const l of locs) {++ if (l.provider !== 'local' || !l.key) continue;++ try { return fs.readFileSync(l.key); } catch { /* moved to a remote tier */ }++ }++ return null;++}++++/**++ * What a source URL names on this server, if anything: `{ obj }` for an object, `{ buffer }` when its bytes are++ * local, or null to fetch it over the network. Options: `{ subject }` (the person the picture is for), `{ fetch }`.++ * A source that is already this person's avatar object is returned as `{ obj, reuse: true }` so it is not copied++ * again on every profile save.++ */++async function readLocalSource(rawUrl, { subject = null } = {}) {++ const model = require('../objects/model');++ let u; try { u = new URL(String(rawUrl || '')); } catch { return null; }++ if (u.hostname !== new URL(require('../config').publicUrl).hostname) return null;++ let obj = null;++ const m = /^\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})$/.exec(u.pathname);++ if (m) obj = await model.getObject(m[1]);++ else {++ const p = /^\/p\/([A-Za-z0-9][A-Za-z0-9_-]{1,80})\/screenshot\/?$/.exec(u.pathname);++ if (p) obj = await model.getObjectByLegacyRef(model.legacyRef('live', 'paste', p[1])) || await model.getObjectByLegacyRef(model.legacyRef('live', 'avatar', p[1]));++ const f = /^\/f\/(.+)$/.exec(u.pathname);++ if (f) {++ const row = await require('../db/database').get('SELECT object_id FROM files WHERE key = ?', [decodeURIComponent(f[1])]);++ if (row && row.object_id) obj = await model.getObject(row.object_id);++ }++ }++ if (!obj || obj.lifecycle_status !== 'ready') return null;++ // A private picture is copied only for the person it belongs to (their profile is already showing it); for++ // anyone else the remote path is tried and the private object refuses, so a copy never publishes it.++ if (obj.visibility === 'private' && obj.owner_subject !== subject) return null;++ if (obj.kind === 'avatar' && subject && obj.owner_subject === subject) return { obj, reuse: true };++ const buffer = await localBytes(obj);++ return buffer ? { obj, buffer } : null;}/**@@@ -157,12 +211,29 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHosfunction createIngestHandler({ db, config, log = console }) {return async function avatarIngest(req, res) {try {-- const { url, user_id, username, subject } = req.body || {};++ const { url, user_id, username, subject, product = null, ref = null, shape = 'avatar' } = req.body || {};if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });-- const raw = await safeFetchImage(url);-- const pic = await toAvatar(raw);++ if (!SHAPES[shape]) return res.status(400).json({ ok: false, error: 'shape must be avatar or banner' });++ if (product && (!PRODUCT_RE.test(String(product)) || ref == null || String(ref).length > 64)) {++ return res.status(400).json({ ok: false, error: 'product and ref must name the profile slot' });++ }++ const model = require('../objects/model');++ const adopt = async (objectId) => {++ if (!product) return;++ await model.adoptAvatar({ objectId, subject: SUBJECT_RE.test(String(subject || '')) ? subject : null, product: String(product), ref: String(ref) });++ };++ // Our own object: the bytes are here, and a picture that is already this person's avatar is reused as it is.++ const local = await readLocalSource(url, { subject });++ if (local && local.reuse) {++ await adopt(local.obj.id);++ return res.json({ ok: true, object_id: local.obj.id, url: `${config.publicUrl}/o/${local.obj.id}`,++ width: null, height: null, bytes: Number(local.obj.size_bytes) || 0, reused: true });++ }++ const raw = local && local.buffer ? local.buffer : await safeFetchImage(url);++ const pic = await toAvatar(raw, { shape });const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();-- const id = await storeAvatar({ db, pic, subject, username, sourceHost, userId: user_id });++ const id = await storeAvatar({ db, pic, subject, username, sourceHost });++ await adopt(id);res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });} catch (err) {log.warn('[Avatar ingest]', err.message);@@@ -171,4 +242,42 @@ function createIngestHandler({ db, config, log = console }) {};}--module.exports = { createIngestHandler, storeAvatar, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };++/** Record that a profile slot points at an avatar object (a route of last resort: ingest does this itself). */++function createAdoptHandler({ log = console } = {}) {++ return async function avatarAdopt(req, res) {++ try {++ const { object_id, subject = null, product, ref } = req.body || {};++ if (!object_id || !product || ref == null) return res.status(400).json({ ok: false, error: 'object_id, product and ref are required' });++ if (!PRODUCT_RE.test(String(product)) || String(ref).length > 64) return res.status(400).json({ ok: false, error: 'bad product or ref' });++ const model = require('../objects/model');++ const obj = await model.resolveObject(String(object_id));++ if (!obj) return res.status(404).json({ ok: false, error: 'No such object' });++ if (obj.kind !== 'avatar') return res.status(422).json({ ok: false, error: 'Only an avatar object can be a profile picture' });++ if (obj.lifecycle_status !== 'ready') return res.status(409).json({ ok: false, error: `Object is ${obj.lifecycle_status}` });++ if (subject && obj.owner_subject && subject !== obj.owner_subject) return res.status(422).json({ ok: false, error: 'That object belongs to someone else' });++ const row = await model.adoptAvatar({ objectId: obj.id, subject: subject || obj.owner_subject || null, product: String(product), ref: String(ref) });++ res.json({ ok: true, ref: row });++ } catch (err) {++ log.warn('[Avatar adopt]', err.message);++ res.status(500).json({ ok: false, error: 'Could not record the reference' });++ }++ };++}++++/** Drop a profile slot's reference (the picture was removed) or one object's references. */++function createReleaseHandler({ log = console } = {}) {++ return async function avatarRelease(req, res) {++ try {++ const { object_id = null, product = null, ref = null, subject = null } = req.body || {};++ if (!object_id && !(product && ref != null) && !subject) return res.status(400).json({ ok: false, error: 'object_id, product+ref or subject required' });++ const model = require('../objects/model');++ const released = await model.releaseAvatar({ objectId: object_id, product, ref, subject });++ res.json({ ok: true, released });++ } catch (err) {++ log.warn('[Avatar release]', err.message);++ res.status(500).json({ ok: false, error: 'Could not release the reference' });++ }++ };++}++++module.exports = { createIngestHandler, createAdoptHandler, createReleaseHandler, storeAvatar, safeFetchImage, readLocalSource, toAvatar, resolvePublic, isPublicAddress, SHAPES };@@@ -162,8 +162,12 @@ const ready = (async () => {// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the// route (server/service-guard.js). The handler stores native avatar objects.++ const avatars = require('./avatars/ingest');app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),-- require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));++ avatars.createIngestHandler({ db: require('./db/database'), config }));++ // A profile slot points at an avatar object: this is what makes softDelete refuse to break a working profile.++ app.post('/internal/avatar-adopt', require('./service-guard').guard('media.avatar.adopt'), avatars.createAdoptHandler());++ app.post('/internal/avatar-release', require('./service-guard').guard('media.avatar.release'), avatars.createReleaseHandler());}// ── Internal: retiring one legacy Live paste screenshot for OpenVibe.Community (see// server/objects/legacy-paste-retire.js). Same service-token guard and loopback rule as above;@@@ -36,6 +36,16 @@ class HeldError extends Error {constructor(objectId) { super('media object is under a retention hold'); this.code = 'media.object.held'; this.objectId = objectId; }}++/** An avatar a profile still points at. The caller must release the reference (or the owner replace it) first. */++class AvatarInUseError extends Error {++ constructor(ref) {++ super(`media object is in use as a profile picture (${ref.product} ${ref.ref})`);++ this.code = 'media.object.avatar_in_use';++ this.objectId = ref.object_id;++ this.ref = ref;++ }++}++// ── Small helpers ────────────────────────────────────────────function mimeFor(p, fallback = 'application/octet-stream') {@@@ -602,14 +612,60 @@ async function releaseHold(holdId, releasedBy = null) {});}++// ── Avatars a profile points at (migrations/0014_avatar_refs.sql) ──++//++// A product's profile (Network's users.avatar_url, Live's users.avatar_url / a channel banner) records the media++// object it points at here, through the internal avatar routes. softDelete refuses an avatar that still has an++// unreleased reference, so a cleanup can never take the picture out from under a working profile. Replacing an++// avatar releases the previous reference in the same transaction that records the new one.++++/** The active reference to this object, if any. */++async function avatarRef(objectId) {++ if (!objectId) return null;++ return await db.get(`SELECT * FROM media_avatar_refs WHERE object_id = ? AND released_at IS NULL ORDER BY id DESC LIMIT 1`, [objectId]);++}++++async function avatarRefsForSubject(subject) {++ if (!subject) return [];++ return await db.all(`SELECT * FROM media_avatar_refs WHERE subject = ? AND released_at IS NULL`, [subject]);++}++++/** Point `product`'s slot `ref` at `objectId`: the previous picture for the same slot is released with it. */++async function adoptAvatar({ objectId, subject = null, product, ref }) {++ return await db.getDb().tx(async () => {++ await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE product = ? AND ref = ? AND released_at IS NULL`, [product, ref]);++ await db.run(`INSERT INTO media_avatar_refs (object_id, subject, product, ref) VALUES (?, ?, ?, ?)`, [objectId, subject || null, product, ref]);++ return await db.get(`SELECT * FROM media_avatar_refs WHERE product = ? AND ref = ? AND released_at IS NULL`, [product, ref]);++ });++}++++/** Clear a slot (the person removed the picture) or one object's references. → how many rows were released. */++async function releaseAvatar({ product = null, ref = null, objectId = null, subject = null } = {}) {++ const conds = ['released_at IS NULL']; const params = [];++ if (product) { conds.push('product = ?'); params.push(product); }++ if (ref != null) { conds.push('ref = ?'); params.push(String(ref)); }++ if (objectId) { conds.push('object_id = ?'); params.push(objectId); }++ if (subject) { conds.push('subject = ?'); params.push(subject); }++ if (conds.length === 1) return 0;++ const r = await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE ${conds.join(' AND ')}`, params);++ return r.changes || 0;++}++// ── Native objects: soft delete, restore, purge, quota ───────function objectFilePath(obj) {return path.join(config.objects.path, obj.app_id, obj.id);}--async function softDelete(obj, { by = null } = {}) {++async function softDelete(obj, { by = null, allowInUse = false } = {}) {if (await isHeld(obj.id)) throw new HeldError(obj.id);++ // A profile still points at this avatar. Only the product that owns the reference (through its internal route)++ // or an operator with a moderation reason may go on; Media cannot tell who is "the owner's own replacement"++ // from a service token, so it refuses and the product releases the reference first.++ if (!allowInUse && obj.kind === 'avatar') {++ const ref = await avatarRef(obj.id);++ if (ref) throw new AvatarInUseError(ref);++ }const md = parseJson(obj.metadata, {});md.pre_delete_status = obj.lifecycle_status;md.retention_until = new Date(Date.now() + config.objects.retentionDays * 864e5).toISOString();@@@ -618,6 +674,7 @@ async function softDelete(obj, { by = null } = {}) {await db.getDb().tx(async () => {await db.run(`UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = ov_now(), metadata = ?, updated_at = ov_now()WHERE id = ?`, [JSON.stringify(md), obj.id]);++ await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE object_id = ? AND released_at IS NULL`, [obj.id]);await require('./namespaces').settle(obj.id); // an upload deleted before it completed holds no quotaawait events.recordObjectChanges(); // media.object.deleted commits with the delete});@@@ -752,9 +809,10 @@ async function objectPublic(obj, { locations = true } = {}) {}module.exports = {-- KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,++ KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError, AvatarInUseError,mimeFor, parseJson, legacyRef, parseLegacyRef, thumbFileFromUrl,getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,++ avatarRef, avatarRefsForSubject, adoptAvatar, releaseAvatar,createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,vodProjection, clipProjection, fileProjection, thumbnailProjection,syncVod, syncClip, syncFile, sync, safeSync, withObject, withObjectOrRow, afterTierMove,@@@ -738,6 +738,11 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:res.json(await model.objectPublic(gone));} catch (err) {if (err.code === 'media.object.held' || /retention hold/.test(err.message)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');++ if (err.code === 'media.object.avatar_in_use') {++ return problem(res, 409, 'media.object.avatar_in_use',++ `This avatar is still a profile picture (${err.ref.product} ${err.ref.ref}); the profile releases it first`,++ { product: err.ref.product, ref: err.ref.ref });++ }console.error('[Objects] delete error:', err.message);problem(res, 500, 'media.object.delete_failed', 'Failed to delete object');}@@@ -29,6 +29,7 @@ const path = require('path');media_access_keys: 'project-scoped S3 keys; the project owns them and created_by is the issuer audit trail',config_snapshots: 'the configuration audit trail, attributed to the staff member who changed it',media_holds: 'a moderation, DMCA or legal hold is kept until it is released',++ media_avatar_refs: 'which object a profile picture points at: erase() releases the person\'s references first, and the objects they name are erased separately',media_jobs: 'operational job history, without the person\'s content',media_namespaces: 'a namespace belongs to an app or project, not a person',pastes: 'a frozen table Community owns and erases itself',nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++// Avatars that a person's profile points at (incident 2026-11-06: a cleanup of public screenshot copies deleted++// eleven pictures people were using as avatars). A profile's picture is a native avatar object owned by them, and++// the object it points at cannot be soft-deleted while the reference stands:++// - a screenshot object becomes an avatar object (a copy, local bytes, no self-fetch), owned by the subject;++// - deleting the screenshot afterwards leaves the avatar working;++// - deleting the avatar is 409 media.object.avatar_in_use until the profile releases the reference;++// - replacing the picture releases the previous reference with the new one (the old object then deletes);++// - ingesting the person's own avatar again reuses it instead of copying;++// - shape 'banner' keeps a wide crop;++// - erasing an account releases its references first, so its own avatar is deleted.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const sharp = require('sharp');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-guard-'));++ process.env.OBJECTS_PATH = path.join(tmp, 'objects');++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++ const { ids } = require('openvibe-contracts');++ const db = require('../server/db/database');++ const config = require('../server/config');++ const model = require('../server/objects/model');++ const namespaces = require('../server/objects/namespaces');++ const ingest = require('../server/avatars/ingest');++++ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });++ await db.ensureRootNamespace(await db.getApp('live'));++ await namespaces.ensure(await db.getApp('live'), 'live.avatars');++ await require('../server/events').initWriter();++++ const ANA = `usr_${ids.ulid()}`;++ const png = await sharp({ create: { width: 900, height: 300, channels: 3, background: '#3b82f6' } }).png().toBuffer();++ const store = async (o, bytes) => {++ const id = await model.createObject({ app_id: 'live', namespace: 'live.avatars', visibility: 'public', lifecycle_status: 'ready',++ mime_type: 'image/png', size_bytes: bytes.length, createdMs: Date.now(), ...o });++ const key = model.objectFilePath(await model.getObject(id));++ fs.mkdirSync(path.dirname(key), { recursive: true }); fs.writeFileSync(key, bytes);++ await model.upsertLocation(id, { provider: 'local', key, state: 'present', size_bytes: bytes.length, verified: true });++ await model.updateObject(id, { canonical_provider: 'local', canonical_key: key });++ return id;++ };++++ // The screenshot a person picked as their picture: someone else's object, in a product that may clean it up.++ const shotId = await store({ kind: 'screenshot', owner_subject: `usr_${ids.ulid()}`, metadata: { filename: 'paste.png' } }, png);++++ const app = express();++ app.use(express.json());++ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config }));++ app.post('/internal/avatar-adopt', ingest.createAdoptHandler());++ app.post('/internal/avatar-release', ingest.createReleaseHandler());++ app.use('/api/v2/:app/objects', require('../server/objects/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, {++ method, headers: { 'content-type': 'application/json', authorization: 'Bearer live-key' }, body: body ? JSON.stringify(body) : undefined,++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++++ try {++ // 1) A screenshot becomes an avatar object owned by the person, read from local bytes (no network fetch).++ const first = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${shotId}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });++ assert.strictEqual(first.status, 200, JSON.stringify(first.body));++ const avatarId = first.body.object_id;++ assert.match(avatarId, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.notStrictEqual(avatarId, shotId, 'a copy, not the screenshot itself');++ const av = await model.getObject(avatarId);++ assert.deepStrictEqual([av.kind, av.owner_subject, av.visibility, av.lifecycle_status], ['avatar', ANA, 'unlisted', 'ready']);++ const ref = await model.avatarRef(avatarId);++ assert.deepStrictEqual([ref.product, ref.ref, ref.subject], ['network', '513', ANA]);++ const avatarFile = async (id) => (await model.listLocations(id)).find((l) => l.provider === 'local').key;++ const avBytes = fs.readFileSync(await avatarFile(avatarId));++ assert.deepStrictEqual(['webp', 512, 512], await sharp(avBytes).metadata().then((m) => [m.format, m.width, m.height]));++++ // 2) Deleting the screenshot afterwards leaves the avatar working: it is a copy.++ const delShot = await call('DELETE', `/api/v2/live/objects/${shotId}`);++ assert.strictEqual(delShot.status, 200, JSON.stringify(delShot.body));++ assert.strictEqual((await model.getObject(avatarId)).lifecycle_status, 'ready');++ assert.ok(fs.existsSync(await avatarFile(avatarId)), 'the avatar bytes are still there');++++ // 3) The avatar the profile points at cannot be deleted, with a code that says why.++ const refused = await call('DELETE', `/api/v2/live/objects/${avatarId}`);++ assert.strictEqual(refused.status, 409, JSON.stringify(refused.body));++ assert.strictEqual(refused.body.code, 'media.object.avatar_in_use');++ assert.strictEqual((await model.getObject(avatarId)).lifecycle_status, 'ready');++++ // 4) Replacing the picture releases the previous reference; the old object then deletes.++ const second = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${avatarId}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });++ assert.strictEqual(second.body.reused, true, 'the person\'s own avatar is reused, not copied');++ assert.strictEqual(second.body.object_id, avatarId);++ const nextShot = await store({ kind: 'screenshot', owner_subject: ANA }, png);++ const replaced = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${nextShot}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });++ assert.strictEqual(replaced.status, 200, JSON.stringify(replaced.body));++ assert.ok(!await model.avatarRef(avatarId), "the old reference was released with the new one");++ assert.strictEqual((await call('DELETE', `/api/v2/live/objects/${avatarId}`)).status, 200);++++ // 5) An explicit release lets the picture go too.++ const released = await call('POST', '/internal/avatar-release', { product: 'network', ref: '513' });++ assert.deepStrictEqual([released.status, released.body.released], [200, 1]);++ assert.strictEqual((await call('DELETE', `/api/v2/live/objects/${replaced.body.object_id}`)).status, 200);++++ // 6) A banner keeps its wide shape (an avatar is square).++ const bannerShot = await store({ kind: 'screenshot', owner_subject: ANA }, png);++ const banner = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${bannerShot}`, user_id: 461, subject: ANA, product: 'live', ref: '461', shape: 'banner' });++ assert.strictEqual(banner.status, 200, JSON.stringify(banner.body));++ const bm = await sharp(fs.readFileSync(await avatarFile(banner.body.object_id))).metadata();++ assert.deepStrictEqual([bm.width, bm.height], [1600, 500]);++++ // 7) Erasing the account releases the references first, so its own avatar is deleted, not stranded.++ const erase = require('../server/account-data');++ const out = await erase.erase(db.getDb(), [ANA]);++ assert.strictEqual((await model.getObject(banner.body.object_id)).lifecycle_status, 'deleted');++ assert.ok(out.erased.objects >= 1, JSON.stringify(out));++ assert.ok(!await model.avatarRef(banner.body.object_id));++++ console.log('avatar guard: all checks passed');++ } finally {++ server.close();++ fs.rmSync(tmp, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,6 +2,15 @@
@@ -2,6 +2,15 @@
## Unreleased
## Unreleased
+- An avatar a person's profile points at cannot be deleted out from under them (incident 2026-11-06: a cleanup of
+ public screenshot copies deleted eleven avatars). `media_avatar_refs` (migration `0020_avatar_refs.sql`) records
+ each profile slot → object; `model.softDelete` refuses an avatar with an unreleased reference and `DELETE` answers
+ 409 `media.object.avatar_in_use`. The internal `POST /internal/avatar-adopt` and `/internal/avatar-release` routes
+ write it, and `/internal/avatar-ingest` records it when given `product` and `ref`. Replacing a picture releases the
+ previous reference in the same transaction; account deletion releases the person's references first. Ingest also
+ reads our own `/o/<id>` and `/p/<slug>/screenshot` sources locally instead of fetching them, reuses an avatar the
+ subject already owns, and stores a 1600×500 `shape: 'banner'` crop. Test: `test/avatar-guard.test.js`.
+
- Operator retirement for an object with no good copy (`scripts/no-good-copy-report.js --retire <med_id> [--apply]`):
- Operator retirement for an object with no good copy (`scripts/no-good-copy-report.js --retire <med_id> [--apply]`):
the dry run changes nothing; only an object in the report, not public and under 1 MiB is retired (a public v1 row, a
the dry run changes nothing; only an object in the report, not public and under 1 MiB is retired (a public v1 row, a
native public object, good-copy, 1 MiB+ and held objects are refused with the reason). For a legacy VOD/clip the v1
native public object, good-copy, 1 MiB+ and held objects are refused with the reason). For a legacy VOD/clip the v1
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -45,7 +45,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
export and deletion), OpenVibe.Live (`live.lineage.resolve`), OpenVibe.Events (the outbox relay and
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
-- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.91.0 (tokens, events outbox, usage readings, per-actor limits),
+- `openvibe-contracts` v0.302.0, `openvibe-sdk` v0.91.0 (tokens, events outbox, usage readings, per-actor limits),
`openvibe-shared` v4.122.0, pinned by release tarball
`openvibe-shared` v4.122.0, pinned by release tarball
## Capabilities
## Capabilities
@@ -129,7 +129,7 @@ server/
@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
client-ip.js trust proxy = loopback; req.ip is the only client address
-(openvibe-shared v4.122.0, openvibe-contracts v0.294.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)
+(openvibe-shared v4.122.0, openvibe-contracts v0.302.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@ -778,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
@@ -778,7 +778,7 @@ Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not
restart; afterwards `sudo ovhost rollback media --to <sha>`.
restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.294.0
+- openvibe-contracts: v0.302.0
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
- openvibe-shared: v4.122.0
- openvibe-shared: v4.122.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, ffmpeg",
"port": 4100,
"port": 4100,
"domain": "openvibe.media",
"domain": "openvibe.media",
- "contracts": "openvibe-contracts v0.294.0",
+ "contracts": "openvibe-contracts v0.302.0",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
"shared": "openvibe-shared v4.122.0",
"shared": "openvibe-shared v4.122.0",
"capabilities": [
"capabilities": [
@@ -65,6 +65,24 @@ Community service principal (`svc:community`, capability `media.object.delete`),
@@ -65,6 +65,24 @@ Community service principal (`svc:community`, capability `media.object.delete`),
retire the old public object of a screenshot paste it has re-hosted privately
retire the old public object of a screenshot paste it has re-hosted privately
(`server/objects/legacy-paste-retire.js`).
(`server/objects/legacy-paste-retire.js`).
+### Avatars a profile points at
+
+An avatar object (`kind avatar`) is a person's picture on a product. Each profile slot records the object it
+points at in `media_avatar_refs` (`product` `network` for Network's `users.avatar_url`, `live` for Live's own
+copy and a channel banner; `ref` names the slot, e.g. the person's Network id). The internal
+`POST /internal/avatar-adopt` and `/internal/avatar-release` routes write it, and `POST /internal/avatar-ingest`
+records it in the same call when it is given `product` and `ref`.
+
+`model.softDelete` refuses an avatar with an unreleased reference: `DELETE` answers 409
+`media.object.avatar_in_use` naming the product and slot, so a cleanup cannot take the picture out from under a
+working profile (incident 2026-11-06). Replacing the picture releases the previous reference in the same
+transaction, and account deletion releases the person's references before erasing their objects. An operator path
+that has to delete one anyway passes `allowInUse` to `softDelete`; no HTTP route does.
+
+`POST /internal/avatar-ingest` also answers our own objects without fetching them: an `openvibe.media/o/<id>` or
+`/p/<slug>/screenshot` source is read from local storage, and an avatar already owned by the subject is reused
+(`reused: true`) rather than copied. `shape: 'banner'` stores a 1600×500 crop instead of the 512×512 avatar.
+
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
Every v2 route that takes `:id` accepts either form. Native v2 objects have `legacy_ref = NULL`.
### How a row maps to its object
### How a row maps to its object
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+-- phase: expand
+-- A person's profile picture is a native avatar object (kind 'avatar'), but nothing in Media knew which object a
+-- profile still pointed at: a cleanup could soft-delete the object behind a live avatar and no service was told
+-- (incident 2026-11-06, 11 avatars went this way). This table records the reference a product's profile holds, so
+-- model.softDelete can refuse to delete an avatar while it is in use. A row is released, never deleted: the history
+-- says which objects a person's picture moved through.
+CREATE TABLE IF NOT EXISTS media_avatar_refs (
+ id bigserial PRIMARY KEY,
+ object_id text COLLATE "C" NOT NULL, -- med_… the profile points at
+ subject text COLLATE "C", -- usr_… of the person when the product knows it
+ product text COLLATE "C" NOT NULL, -- 'network' (users.avatar_url), 'live' (users.avatar_url, channel banner)
+ ref text COLLATE "C" NOT NULL, -- the person's id in that product's own space
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ released_at text COLLATE "C"
+);
+
+-- One live picture per person per product; a product may hold several kinds of slot, so the ref names the slot.
+CREATE UNIQUE INDEX IF NOT EXISTS media_avatar_refs_active
+ ON media_avatar_refs (product, ref) WHERE released_at IS NULL;
+CREATE INDEX IF NOT EXISTS media_avatar_refs_object
+ ON media_avatar_refs (object_id) WHERE released_at IS NULL;
+CREATE INDEX IF NOT EXISTS media_avatar_refs_subject
+ ON media_avatar_refs (subject) WHERE released_at IS NULL;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -2122,9 +2122,9 @@
@@ -2122,9 +2122,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.294.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
- "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
+ "version": "0.302.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
+ "integrity": "sha512-gvlRn+DRjGrNJVMefWZ+Qx7wgSQeCq7bbXLi4pdB9fyxqMyc8bUuYbvBmwbZND0YuB/vf/mOq60BBbd2GMWxzA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"express": "^4.19.2",
"express": "^4.19.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -52,6 +52,9 @@ async function exportPart(db, subjects) {
@@ -52,6 +52,9 @@ async function exportPart(db, subjects) {
/** Erase what the subjects own → { erased, retained }. Each object in its own step: a hold stops only that one. */
/** Erase what the subjects own → { erased, retained }. Each object in its own step: a hold stops only that one. */
async function erase(db, subjects) {
async function erase(db, subjects) {
const model = require('./objects/model');
const model = require('./objects/model');
+ // The account is gone, so no profile points at its pictures any more: release the avatar references first, or
+ // the in-use guard (model.softDelete) would keep the person's own avatar forever.
+ for (const s of subjects) await model.releaseAvatar({ subject: s });
const erased = {};
const erased = {};
const retained = {};
const retained = {};
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
@@ -2,8 +2,15 @@
@@ -2,8 +2,15 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// Avatar ingestion — every avatar on the network is a file HERE.
// Avatar ingestion — every avatar on the network is a file HERE.
//
//
-// POST /internal/avatar-ingest { url, user_id, username, subject } (internal key + loopback only)
-// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes }
+// POST /internal/avatar-ingest { url, user_id, username, subject, product?, ref?, shape? } (service token + loopback only)
+// → { ok, object_id, url: 'https://openvibe.media/o/<id>', width, height, bytes, reused? }
+//
+// Every source becomes an avatar object the person owns, so a profile never points at another product's object:
+// • an openvibe.media address for an object or paste screenshot is read from local storage (no self-fetch)
+// • an object that is already an avatar owned by this subject is reused, not copied again
+// • anything else is fetched with the precautions below
+// With `product` + `ref` the profile's reference is recorded (model.adoptAvatar), which is what makes the delete
+// guard (model.softDelete) refuse a cleanup that would break a working profile. `shape: 'banner'` keeps a wide crop.
//
//
// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.
// A person may point at a picture anywhere on the web; no OpenVibe site ever serves or stores that address.
// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:
// We fetch it once, on the server, with the precautions a server-side fetch of a user-supplied URL needs:
@@ -28,6 +35,9 @@ const crypto = require('crypto');
@@ -28,6 +35,9 @@ const crypto = require('crypto');
const sharp = require('sharp');
const sharp = require('sharp');
const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
+// The house shapes: a square picture everywhere a person appears; a wide one for a channel banner.
+const SHAPES = { avatar: { width: SIZE, height: SIZE }, banner: { width: 1600, height: 500 } };
+const PRODUCT_RE = /^[a-z][a-z0-9-]{1,20}$/;
// The address rule is openvibe-shared/egress's (the strictest union the network's copies drifted from):
// The address rule is openvibe-shared/egress's (the strictest union the network's copies drifted from):
// it also refuses the IPv6 forms that wrap a private IPv4 address (v4-compatible, 6to4, Teredo, NAT64),
// it also refuses the IPv6 forms that wrap a private IPv4 address (v4-compatible, 6to4, Teredo, NAT64),
@@ -91,13 +101,57 @@ async function safeFetchImage(rawUrl, { resolver = resolvePublic, fetcher = fetc
@@ -91,13 +101,57 @@ async function safeFetchImage(rawUrl, { resolver = resolvePublic, fetcher = fetc
throw new Error('Too many redirects');
throw new Error('Too many redirects');
}
}
-/** Bytes → a clean square WebP. Throws when the bytes are not a decodable picture. */
-async function toAvatar(buf) {
+/** Bytes → a clean WebP in the requested house shape. Throws when the bytes are not a decodable picture. */
+async function toAvatar(buf, { shape = 'avatar' } = {}) {
+ const size = SHAPES[shape] || SHAPES.avatar;
const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
const meta = await img.metadata();
const meta = await img.metadata();
if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');
if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');
- const out = await img.rotate().resize(SIZE, SIZE, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();
- return { buffer: out, width: SIZE, height: SIZE };
+ const out = await img.rotate().resize(size.width, size.height, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();
+ return { buffer: out, width: size.width, height: size.height, shape };
+}
+
+/** The bytes of one of our own objects, from its local copy. null when there is none (a remote-only object). */
+async function localBytes(obj) {
+ if (!obj) return null;
+ const model = require('../objects/model');
+ const locs = await model.listLocations(obj.id);
+ for (const l of locs) {
+ if (l.provider !== 'local' || !l.key) continue;
+ try { return fs.readFileSync(l.key); } catch { /* moved to a remote tier */ }
+ }
+ return null;
+}
+
+/**
+ * What a source URL names on this server, if anything: `{ obj }` for an object, `{ buffer }` when its bytes are
+ * local, or null to fetch it over the network. Options: `{ subject }` (the person the picture is for), `{ fetch }`.
+ * A source that is already this person's avatar object is returned as `{ obj, reuse: true }` so it is not copied
+ * again on every profile save.
+ */
+async function readLocalSource(rawUrl, { subject = null } = {}) {
+ const model = require('../objects/model');
+ let u; try { u = new URL(String(rawUrl || '')); } catch { return null; }
+ if (u.hostname !== new URL(require('../config').publicUrl).hostname) return null;
+ let obj = null;
+ const m = /^\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})$/.exec(u.pathname);
+ if (m) obj = await model.getObject(m[1]);
+ else {
+ const p = /^\/p\/([A-Za-z0-9][A-Za-z0-9_-]{1,80})\/screenshot\/?$/.exec(u.pathname);
+ if (p) obj = await model.getObjectByLegacyRef(model.legacyRef('live', 'paste', p[1])) || await model.getObjectByLegacyRef(model.legacyRef('live', 'avatar', p[1]));
+ const f = /^\/f\/(.+)$/.exec(u.pathname);
+ if (f) {
+ const row = await require('../db/database').get('SELECT object_id FROM files WHERE key = ?', [decodeURIComponent(f[1])]);
+ if (row && row.object_id) obj = await model.getObject(row.object_id);
+ }
+ }
+ if (!obj || obj.lifecycle_status !== 'ready') return null;
+ // A private picture is copied only for the person it belongs to (their profile is already showing it); for
+ // anyone else the remote path is tried and the private object refuses, so a copy never publishes it.
+ if (obj.visibility === 'private' && obj.owner_subject !== subject) return null;
+ if (obj.kind === 'avatar' && subject && obj.owner_subject === subject) return { obj, reuse: true };
+ const buffer = await localBytes(obj);
+ return buffer ? { obj, buffer } : null;
}
}
/**
/**
@@ -157,12 +211,29 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHos
@@ -157,12 +211,29 @@ async function storeAvatar({ db, pic, subject = null, username = null, sourceHos
function createIngestHandler({ db, config, log = console }) {
function createIngestHandler({ db, config, log = console }) {
return async function avatarIngest(req, res) {
return async function avatarIngest(req, res) {
try {
try {
- const { url, user_id, username, subject } = req.body || {};
+ const { url, user_id, username, subject, product = null, ref = null, shape = 'avatar' } = req.body || {};
if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });
if (!url || !user_id) return res.status(400).json({ ok: false, error: 'url and user_id required' });
- const raw = await safeFetchImage(url);
- const pic = await toAvatar(raw);
+ if (!SHAPES[shape]) return res.status(400).json({ ok: false, error: 'shape must be avatar or banner' });
+ if (product && (!PRODUCT_RE.test(String(product)) || ref == null || String(ref).length > 64)) {
+ return res.status(400).json({ ok: false, error: 'product and ref must name the profile slot' });
+ }
+ const model = require('../objects/model');
+ const adopt = async (objectId) => {
+ if (!product) return;
+ await model.adoptAvatar({ objectId, subject: SUBJECT_RE.test(String(subject || '')) ? subject : null, product: String(product), ref: String(ref) });
+ };
+ // Our own object: the bytes are here, and a picture that is already this person's avatar is reused as it is.
+ const local = await readLocalSource(url, { subject });
+ if (local && local.reuse) {
+ await adopt(local.obj.id);
+ return res.json({ ok: true, object_id: local.obj.id, url: `${config.publicUrl}/o/${local.obj.id}`,
+ width: null, height: null, bytes: Number(local.obj.size_bytes) || 0, reused: true });
+ }
+ const raw = local && local.buffer ? local.buffer : await safeFetchImage(url);
+ const pic = await toAvatar(raw, { shape });
const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();
const sourceHost = (() => { try { return new URL(url).hostname; } catch { return null; } })();
- const id = await storeAvatar({ db, pic, subject, username, sourceHost, userId: user_id });
+ const id = await storeAvatar({ db, pic, subject, username, sourceHost });
+ await adopt(id);
res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
res.json({ ok: true, object_id: id, url: `${config.publicUrl}/o/${id}`, width: pic.width, height: pic.height, bytes: pic.buffer.length });
} catch (err) {
} catch (err) {
log.warn('[Avatar ingest]', err.message);
log.warn('[Avatar ingest]', err.message);
@@ -171,4 +242,42 @@ function createIngestHandler({ db, config, log = console }) {
@@ -171,4 +242,42 @@ function createIngestHandler({ db, config, log = console }) {
};
};
}
}
-module.exports = { createIngestHandler, storeAvatar, safeFetchImage, isPublicAddress, toAvatar, resolvePublic };
+/** Record that a profile slot points at an avatar object (a route of last resort: ingest does this itself). */
+function createAdoptHandler({ log = console } = {}) {
+ return async function avatarAdopt(req, res) {
+ try {
+ const { object_id, subject = null, product, ref } = req.body || {};
+ if (!object_id || !product || ref == null) return res.status(400).json({ ok: false, error: 'object_id, product and ref are required' });
+ if (!PRODUCT_RE.test(String(product)) || String(ref).length > 64) return res.status(400).json({ ok: false, error: 'bad product or ref' });
+ const model = require('../objects/model');
+ const obj = await model.resolveObject(String(object_id));
+ if (!obj) return res.status(404).json({ ok: false, error: 'No such object' });
+ if (obj.kind !== 'avatar') return res.status(422).json({ ok: false, error: 'Only an avatar object can be a profile picture' });
+ if (obj.lifecycle_status !== 'ready') return res.status(409).json({ ok: false, error: `Object is ${obj.lifecycle_status}` });
+ if (subject && obj.owner_subject && subject !== obj.owner_subject) return res.status(422).json({ ok: false, error: 'That object belongs to someone else' });
+ const row = await model.adoptAvatar({ objectId: obj.id, subject: subject || obj.owner_subject || null, product: String(product), ref: String(ref) });
+ res.json({ ok: true, ref: row });
+ } catch (err) {
+ log.warn('[Avatar adopt]', err.message);
+ res.status(500).json({ ok: false, error: 'Could not record the reference' });
+ }
+ };
+}
+
+/** Drop a profile slot's reference (the picture was removed) or one object's references. */
+function createReleaseHandler({ log = console } = {}) {
+ return async function avatarRelease(req, res) {
+ try {
+ const { object_id = null, product = null, ref = null, subject = null } = req.body || {};
+ if (!object_id && !(product && ref != null) && !subject) return res.status(400).json({ ok: false, error: 'object_id, product+ref or subject required' });
+ const model = require('../objects/model');
+ const released = await model.releaseAvatar({ objectId: object_id, product, ref, subject });
+ res.json({ ok: true, released });
+ } catch (err) {
+ log.warn('[Avatar release]', err.message);
+ res.status(500).json({ ok: false, error: 'Could not release the reference' });
+ }
+ };
+}
+
+module.exports = { createIngestHandler, createAdoptHandler, createReleaseHandler, storeAvatar, safeFetchImage, readLocalSource, toAvatar, resolvePublic, isPublicAddress, SHAPES };
@@ -162,8 +162,12 @@ const ready = (async () => {
@@ -162,8 +162,12 @@ const ready = (async () => {
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// route (server/service-guard.js). The handler stores native avatar objects.
// route (server/service-guard.js). The handler stores native avatar objects.
+ const avatars = require('./avatars/ingest');
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
- require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
+ avatars.createIngestHandler({ db: require('./db/database'), config }));
+ // A profile slot points at an avatar object: this is what makes softDelete refuse to break a working profile.
+ app.post('/internal/avatar-adopt', require('./service-guard').guard('media.avatar.adopt'), avatars.createAdoptHandler());
+ app.post('/internal/avatar-release', require('./service-guard').guard('media.avatar.release'), avatars.createReleaseHandler());
}
}
// ── Internal: retiring one legacy Live paste screenshot for OpenVibe.Community (see
// ── Internal: retiring one legacy Live paste screenshot for OpenVibe.Community (see
// server/objects/legacy-paste-retire.js). Same service-token guard and loopback rule as above;
// server/objects/legacy-paste-retire.js). Same service-token guard and loopback rule as above;
@@ -36,6 +36,16 @@ class HeldError extends Error {
@@ -36,6 +36,16 @@ class HeldError extends Error {
constructor(objectId) { super('media object is under a retention hold'); this.code = 'media.object.held'; this.objectId = objectId; }
constructor(objectId) { super('media object is under a retention hold'); this.code = 'media.object.held'; this.objectId = objectId; }
}
}
+/** An avatar a profile still points at. The caller must release the reference (or the owner replace it) first. */
+class AvatarInUseError extends Error {
+ constructor(ref) {
+ super(`media object is in use as a profile picture (${ref.product} ${ref.ref})`);
+ this.code = 'media.object.avatar_in_use';
+ this.objectId = ref.object_id;
+ this.ref = ref;
+ }
+}
+
// ── Small helpers ────────────────────────────────────────────
// ── Small helpers ────────────────────────────────────────────
function mimeFor(p, fallback = 'application/octet-stream') {
function mimeFor(p, fallback = 'application/octet-stream') {
@@ -602,14 +612,60 @@ async function releaseHold(holdId, releasedBy = null) {
@@ -602,14 +612,60 @@ async function releaseHold(holdId, releasedBy = null) {
});
});
}
}
+// ── Avatars a profile points at (migrations/0014_avatar_refs.sql) ──
+//
+// A product's profile (Network's users.avatar_url, Live's users.avatar_url / a channel banner) records the media
+// object it points at here, through the internal avatar routes. softDelete refuses an avatar that still has an
+// unreleased reference, so a cleanup can never take the picture out from under a working profile. Replacing an
+// avatar releases the previous reference in the same transaction that records the new one.
+
+/** The active reference to this object, if any. */
+async function avatarRef(objectId) {
+ if (!objectId) return null;
+ return await db.get(`SELECT * FROM media_avatar_refs WHERE object_id = ? AND released_at IS NULL ORDER BY id DESC LIMIT 1`, [objectId]);
+}
+
+async function avatarRefsForSubject(subject) {
+ if (!subject) return [];
+ return await db.all(`SELECT * FROM media_avatar_refs WHERE subject = ? AND released_at IS NULL`, [subject]);
+}
+
+/** Point `product`'s slot `ref` at `objectId`: the previous picture for the same slot is released with it. */
+async function adoptAvatar({ objectId, subject = null, product, ref }) {
+ return await db.getDb().tx(async () => {
+ await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE product = ? AND ref = ? AND released_at IS NULL`, [product, ref]);
+ await db.run(`INSERT INTO media_avatar_refs (object_id, subject, product, ref) VALUES (?, ?, ?, ?)`, [objectId, subject || null, product, ref]);
+ return await db.get(`SELECT * FROM media_avatar_refs WHERE product = ? AND ref = ? AND released_at IS NULL`, [product, ref]);
+ });
+}
+
+/** Clear a slot (the person removed the picture) or one object's references. → how many rows were released. */
+async function releaseAvatar({ product = null, ref = null, objectId = null, subject = null } = {}) {
+ const conds = ['released_at IS NULL']; const params = [];
+ if (product) { conds.push('product = ?'); params.push(product); }
+ if (ref != null) { conds.push('ref = ?'); params.push(String(ref)); }
+ if (objectId) { conds.push('object_id = ?'); params.push(objectId); }
+ if (subject) { conds.push('subject = ?'); params.push(subject); }
+ if (conds.length === 1) return 0;
+ const r = await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE ${conds.join(' AND ')}`, params);
+ return r.changes || 0;
+}
+
// ── Native objects: soft delete, restore, purge, quota ───────
// ── Native objects: soft delete, restore, purge, quota ───────
function objectFilePath(obj) {
function objectFilePath(obj) {
return path.join(config.objects.path, obj.app_id, obj.id);
return path.join(config.objects.path, obj.app_id, obj.id);
}
}
-async function softDelete(obj, { by = null } = {}) {
+async function softDelete(obj, { by = null, allowInUse = false } = {}) {
if (await isHeld(obj.id)) throw new HeldError(obj.id);
if (await isHeld(obj.id)) throw new HeldError(obj.id);
+ // A profile still points at this avatar. Only the product that owns the reference (through its internal route)
+ // or an operator with a moderation reason may go on; Media cannot tell who is "the owner's own replacement"
+ // from a service token, so it refuses and the product releases the reference first.
+ if (!allowInUse && obj.kind === 'avatar') {
+ const ref = await avatarRef(obj.id);
+ if (ref) throw new AvatarInUseError(ref);
+ }
const md = parseJson(obj.metadata, {});
const md = parseJson(obj.metadata, {});
md.pre_delete_status = obj.lifecycle_status;
md.pre_delete_status = obj.lifecycle_status;
md.retention_until = new Date(Date.now() + config.objects.retentionDays * 864e5).toISOString();
md.retention_until = new Date(Date.now() + config.objects.retentionDays * 864e5).toISOString();
@@ -618,6 +674,7 @@ async function softDelete(obj, { by = null } = {}) {
@@ -618,6 +674,7 @@ async function softDelete(obj, { by = null } = {}) {
await db.getDb().tx(async () => {
await db.getDb().tx(async () => {
await db.run(`UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = ov_now(), metadata = ?, updated_at = ov_now()
await db.run(`UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = ov_now(), metadata = ?, updated_at = ov_now()
WHERE id = ?`, [JSON.stringify(md), obj.id]);
WHERE id = ?`, [JSON.stringify(md), obj.id]);
+ await db.run(`UPDATE media_avatar_refs SET released_at = ov_now() WHERE object_id = ? AND released_at IS NULL`, [obj.id]);
await require('./namespaces').settle(obj.id); // an upload deleted before it completed holds no quota
await require('./namespaces').settle(obj.id); // an upload deleted before it completed holds no quota
await events.recordObjectChanges(); // media.object.deleted commits with the delete
await events.recordObjectChanges(); // media.object.deleted commits with the delete
});
});
@@ -752,9 +809,10 @@ async function objectPublic(obj, { locations = true } = {}) {
@@ -752,9 +809,10 @@ async function objectPublic(obj, { locations = true } = {}) {
}
}
module.exports = {
module.exports = {
- KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError,
+ KINDS, VISIBILITIES, LIFECYCLES, HOLD_KINDS, HeldError, AvatarInUseError,
mimeFor, parseJson, legacyRef, parseLegacyRef, thumbFileFromUrl,
mimeFor, parseJson, legacyRef, parseLegacyRef, thumbFileFromUrl,
getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,
getObject, getObjectByLegacyRef, resolveObject, listLocations, listHolds, inheritedHolds, isHeld, isHeldRow,
+ avatarRef, avatarRefsForSubject, adoptAvatar, releaseAvatar,
createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,
createObject, updateObject, upsertLocation, setLocationState, setRelationship, setVariant, getVariant,
vodProjection, clipProjection, fileProjection, thumbnailProjection,
vodProjection, clipProjection, fileProjection, thumbnailProjection,
syncVod, syncClip, syncFile, sync, safeSync, withObject, withObjectOrRow, afterTierMove,
syncVod, syncClip, syncFile, sync, safeSync, withObject, withObjectOrRow, afterTierMove,
@@ -738,6 +738,11 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
@@ -738,6 +738,11 @@ router.delete('/:id', remove, limits('media.object.delete', { minute: 60, hour:
res.json(await model.objectPublic(gone));
res.json(await model.objectPublic(gone));
} catch (err) {
} catch (err) {
if (err.code === 'media.object.held' || /retention hold/.test(err.message)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
if (err.code === 'media.object.held' || /retention hold/.test(err.message)) return problem(res, 409, 'media.object.held', 'Object is under a retention hold');
+ if (err.code === 'media.object.avatar_in_use') {
+ return problem(res, 409, 'media.object.avatar_in_use',
+ `This avatar is still a profile picture (${err.ref.product} ${err.ref.ref}); the profile releases it first`,
+ { product: err.ref.product, ref: err.ref.ref });
+ }
console.error('[Objects] delete error:', err.message);
console.error('[Objects] delete error:', err.message);
problem(res, 500, 'media.object.delete_failed', 'Failed to delete object');
problem(res, 500, 'media.object.delete_failed', 'Failed to delete object');
}
}
@@ -29,6 +29,7 @@ const path = require('path');
@@ -29,6 +29,7 @@ const path = require('path');
media_access_keys: 'project-scoped S3 keys; the project owns them and created_by is the issuer audit trail',
media_access_keys: 'project-scoped S3 keys; the project owns them and created_by is the issuer audit trail',
config_snapshots: 'the configuration audit trail, attributed to the staff member who changed it',
config_snapshots: 'the configuration audit trail, attributed to the staff member who changed it',
media_holds: 'a moderation, DMCA or legal hold is kept until it is released',
media_holds: 'a moderation, DMCA or legal hold is kept until it is released',
+ media_avatar_refs: 'which object a profile picture points at: erase() releases the person\'s references first, and the objects they name are erased separately',
media_jobs: 'operational job history, without the person\'s content',
media_jobs: 'operational job history, without the person\'s content',
media_namespaces: 'a namespace belongs to an app or project, not a person',
media_namespaces: 'a namespace belongs to an app or project, not a person',
pastes: 'a frozen table Community owns and erases itself',
pastes: 'a frozen table Community owns and erases itself',
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+// Avatars that a person's profile points at (incident 2026-11-06: a cleanup of public screenshot copies deleted
+// eleven pictures people were using as avatars). A profile's picture is a native avatar object owned by them, and
+// the object it points at cannot be soft-deleted while the reference stands:
+// - a screenshot object becomes an avatar object (a copy, local bytes, no self-fetch), owned by the subject;
+// - deleting the screenshot afterwards leaves the avatar working;
+// - deleting the avatar is 409 media.object.avatar_in_use until the profile releases the reference;
+// - replacing the picture releases the previous reference with the new one (the old object then deletes);
+// - ingesting the person's own avatar again reuses it instead of copying;
+// - shape 'banner' keeps a wide crop;
+// - erasing an account releases its references first, so its own avatar is deleted.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const sharp = require('sharp');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-avatar-guard-'));
+ process.env.OBJECTS_PATH = path.join(tmp, 'objects');
+ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+ const { ids } = require('openvibe-contracts');
+ const db = require('../server/db/database');
+ const config = require('../server/config');
+ const model = require('../server/objects/model');
+ const namespaces = require('../server/objects/namespaces');
+ const ingest = require('../server/avatars/ingest');
+
+ await db.upsertApp({ app_id: 'live', name: 'OpenVibe.Live', api_key: 'live-key' });
+ await db.ensureRootNamespace(await db.getApp('live'));
+ await namespaces.ensure(await db.getApp('live'), 'live.avatars');
+ await require('../server/events').initWriter();
+
+ const ANA = `usr_${ids.ulid()}`;
+ const png = await sharp({ create: { width: 900, height: 300, channels: 3, background: '#3b82f6' } }).png().toBuffer();
+ const store = async (o, bytes) => {
+ const id = await model.createObject({ app_id: 'live', namespace: 'live.avatars', visibility: 'public', lifecycle_status: 'ready',
+ mime_type: 'image/png', size_bytes: bytes.length, createdMs: Date.now(), ...o });
+ const key = model.objectFilePath(await model.getObject(id));
+ fs.mkdirSync(path.dirname(key), { recursive: true }); fs.writeFileSync(key, bytes);
+ await model.upsertLocation(id, { provider: 'local', key, state: 'present', size_bytes: bytes.length, verified: true });
+ await model.updateObject(id, { canonical_provider: 'local', canonical_key: key });
+ return id;
+ };
+
+ // The screenshot a person picked as their picture: someone else's object, in a product that may clean it up.
+ const shotId = await store({ kind: 'screenshot', owner_subject: `usr_${ids.ulid()}`, metadata: { filename: 'paste.png' } }, png);
+
+ const app = express();
+ app.use(express.json());
+ app.post('/internal/avatar-ingest', ingest.createIngestHandler({ db, config }));
+ app.post('/internal/avatar-adopt', ingest.createAdoptHandler());
+ app.post('/internal/avatar-release', ingest.createReleaseHandler());
+ app.use('/api/v2/:app/objects', require('../server/objects/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, {
+ method, headers: { 'content-type': 'application/json', authorization: 'Bearer live-key' }, body: body ? JSON.stringify(body) : undefined,
+ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+
+ try {
+ // 1) A screenshot becomes an avatar object owned by the person, read from local bytes (no network fetch).
+ const first = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${shotId}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });
+ assert.strictEqual(first.status, 200, JSON.stringify(first.body));
+ const avatarId = first.body.object_id;
+ assert.match(avatarId, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.notStrictEqual(avatarId, shotId, 'a copy, not the screenshot itself');
+ const av = await model.getObject(avatarId);
+ assert.deepStrictEqual([av.kind, av.owner_subject, av.visibility, av.lifecycle_status], ['avatar', ANA, 'unlisted', 'ready']);
+ const ref = await model.avatarRef(avatarId);
+ assert.deepStrictEqual([ref.product, ref.ref, ref.subject], ['network', '513', ANA]);
+ const avatarFile = async (id) => (await model.listLocations(id)).find((l) => l.provider === 'local').key;
+ const avBytes = fs.readFileSync(await avatarFile(avatarId));
+ assert.deepStrictEqual(['webp', 512, 512], await sharp(avBytes).metadata().then((m) => [m.format, m.width, m.height]));
+
+ // 2) Deleting the screenshot afterwards leaves the avatar working: it is a copy.
+ const delShot = await call('DELETE', `/api/v2/live/objects/${shotId}`);
+ assert.strictEqual(delShot.status, 200, JSON.stringify(delShot.body));
+ assert.strictEqual((await model.getObject(avatarId)).lifecycle_status, 'ready');
+ assert.ok(fs.existsSync(await avatarFile(avatarId)), 'the avatar bytes are still there');
+
+ // 3) The avatar the profile points at cannot be deleted, with a code that says why.
+ const refused = await call('DELETE', `/api/v2/live/objects/${avatarId}`);
+ assert.strictEqual(refused.status, 409, JSON.stringify(refused.body));
+ assert.strictEqual(refused.body.code, 'media.object.avatar_in_use');
+ assert.strictEqual((await model.getObject(avatarId)).lifecycle_status, 'ready');
+
+ // 4) Replacing the picture releases the previous reference; the old object then deletes.
+ const second = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${avatarId}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });
+ assert.strictEqual(second.body.reused, true, 'the person\'s own avatar is reused, not copied');
+ assert.strictEqual(second.body.object_id, avatarId);
+ const nextShot = await store({ kind: 'screenshot', owner_subject: ANA }, png);
+ const replaced = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${nextShot}`, user_id: 513, subject: ANA, product: 'network', ref: '513' });
+ assert.strictEqual(replaced.status, 200, JSON.stringify(replaced.body));
+ assert.ok(!await model.avatarRef(avatarId), "the old reference was released with the new one");
+ assert.strictEqual((await call('DELETE', `/api/v2/live/objects/${avatarId}`)).status, 200);
+
+ // 5) An explicit release lets the picture go too.
+ const released = await call('POST', '/internal/avatar-release', { product: 'network', ref: '513' });
+ assert.deepStrictEqual([released.status, released.body.released], [200, 1]);
+ assert.strictEqual((await call('DELETE', `/api/v2/live/objects/${replaced.body.object_id}`)).status, 200);
+
+ // 6) A banner keeps its wide shape (an avatar is square).
+ const bannerShot = await store({ kind: 'screenshot', owner_subject: ANA }, png);
+ const banner = await call('POST', '/internal/avatar-ingest', { url: `${config.publicUrl}/o/${bannerShot}`, user_id: 461, subject: ANA, product: 'live', ref: '461', shape: 'banner' });
+ assert.strictEqual(banner.status, 200, JSON.stringify(banner.body));
+ const bm = await sharp(fs.readFileSync(await avatarFile(banner.body.object_id))).metadata();
+ assert.deepStrictEqual([bm.width, bm.height], [1600, 500]);
+
+ // 7) Erasing the account releases the references first, so its own avatar is deleted, not stranded.
+ const erase = require('../server/account-data');
+ const out = await erase.erase(db.getDb(), [ANA]);
+ assert.strictEqual((await model.getObject(banner.body.object_id)).lifecycle_status, 'deleted');
+ assert.ok(out.erased.objects >= 1, JSON.stringify(out));
+ assert.ok(!await model.avatarRef(banner.body.object_id));
+
+ console.log('avatar guard: all checks passed');
+ } finally {
+ server.close();
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exit(1); });