openvibers/openvibe.media Commits

Avatars: an object a profile points at cannot be deleted (#330)

OP OpenVibers committed 6 days ago · 8d59fef1c0 · 14 files, +378 −23

* Avatars: an object a profile points at cannot be deleted

A cleanup of public screenshot copies deleted eleven pictures people were
using as avatars. Media had no idea which object a profile still pointed at.

media_avatar_refs records each profile slot (product + ref) and the avatar
object it points at. model.softDelete refuses an avatar with an unreleased
reference, and DELETE answers 409 media.object.avatar_in_use, so a cleanup
can no longer take the picture out from under a working profile. Replacing a
picture releases the previous reference in the same transaction; account
deletion releases the person's references before erasing their objects.

POST /internal/avatar-adopt and /internal/avatar-release write the reference,
and /internal/avatar-ingest records it when given product and ref. Ingest also
reads our own /o/<id>, /p/<slug>/screenshot and /f/<key> sources from local
storage instead of fetching them, reuses an avatar the subject already owns,
and stores a 1600x500 shape:'banner' crop.

Test: test/avatar-guard.test.js (a screenshot used as an avatar becomes an
avatar object, deleting the screenshot leaves it working, the in-use avatar
is refused, replacing releases the old reference, an account erase releases
first). account-data's table ratchet exempts media_avatar_refs with its
reason.

* openvibe-contracts v0.302.0 (media.avatar.adopt and media.avatar.release exist there)

* README and STATUS name openvibe-contracts v0.302.0

* Merge remote-tracking branch 'origin/main' into avatars-own-objects-media

# Conflicts:
#	README.md

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3e8e10c..9e925a3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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, a
native public object, good-copy, 1 MiB+ and held objects are refused with the reason). For a legacy VOD/clip the v1
ddiff --git a/README.md b/README.md
iindex eb73284..689cf07 100644
---- a/README.md
++++ b/README.md
@@@ -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
the 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 tools
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
restart; 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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 2546679..8e7365b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex e8432b0..a013555 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -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 object
ddiff --git a/migrations/0020_avatar_refs.sql b/migrations/0020_avatar_refs.sql
nnew file mode 100644
iindex 0000000..d5ffb41
---- /dev/null
++++ b/migrations/0020_avatar_refs.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex f8e4385..d4bf5ec 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 64836ff..92fbcb6 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 802d0a8..d43c9fe 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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; };
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex 7d6325b..4a82bdb 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -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 = fetc
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 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, sourceHos
function 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 };
ddiff --git a/server/index.js b/server/index.js
iindex 7146667..3400922 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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;
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex 98edc63..4db9049 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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 quota
await 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,
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 28dbf28..b565831 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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');
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex a205df9..dc84813 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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',
ddiff --git a/test/avatar-guard.test.js b/test/avatar-guard.test.js
nnew file mode 100644
iindex 0000000..530747a
---- /dev/null
++++ b/test/avatar-guard.test.js
@@@ -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); });