openvibers/openvibe.network Commits

Avatars: every picture is copied into the person's own avatar object (#603)

OP OpenVibers committed 7 days ago · f65f1fc3c6 · 7 files, +526 −64

A profile could point at another product's object — usually a Community paste
screenshot — so a cleanup, a deleted paste or a private re-host broke the
picture. Now every source a person chooses (a paste link, an openvibe.media
address, a picture elsewhere on the web) is copied by Media into a native
avatar object owned by them, and that object's address is what users.avatar_url
stores. The source is left untouched, and Media records the profile reference
so it refuses to delete the avatar while the profile points at it.

Live's own copy (users.avatar_url) and a channel banner are reported the same
way: the /internal/user-avatar answer now carries the copied avatar_url, so a
site stores its own object instead of the source. Clearing the picture releases
the reference in Media. Only an openvibe.media address is ever served by
/avatar/<username>, so a legacy off-network row gets the initial.

scripts/migrate-screenshot-avatars.js is the one-off move (dry run by default,
--apply to write): it scans Network's users.avatar_url, Live's own copies and
channels.offline_banner_url, copies each into an avatar object, falls a legacy
Live local path back to the linked Network picture or the initial, updates
Network's users and drains the profile triggers so every service's subject
projection refreshes. It needs DATABASE_URL and, for Live's rows,
--live-db; Opus runs it on the host. Production reality today: 2 screenshot
pictures (one person across Network and Live), 12 screenshot objects behind
existing /o/ avatar URLs, and 1 Live legacy local path.

Tests: avatar.test.js (surface + copy/reuse/push-back + release), and
migrate-screenshot-avatars.test.js (the dry run lists exactly the affected
rows and writes nothing; --apply copies, records product/ref/subject, falls
back, copies a banner wide, and announces network.user.updated).

Changes

ddiff --git a/docs/shared-contracts.md b/docs/shared-contracts.md
iindex c74beadf..5e685677 100644
---- a/docs/shared-contracts.md
++++ b/docs/shared-contracts.md
@@@ -180,23 +180,32 @@ shows their navbar page-view count, labelled as such.
Icon glyphs are optically centred from measured bounds: after adding or editing a glyph run
`node scripts/measure-icons.js <cdp harness>` (in OpenVibe.Shared) to regenerate the offsets table.
--## 12. Avatar — one picture per account, always on openvibe.media
++## 12. Avatar — one picture per account, always the person's own object on openvibe.media
Source of truth: `users.avatar_url` on the Network (`server/profile/avatar.js`). It is only ever an
--`https://openvibe.media/…` address. Clients never supply the stored address:
++`https://openvibe.media/o/med_…` address for a native **avatar object owned by that person**. Whatever is chosen is
++copied first, so a profile never points at another product's object (a Community screenshot cleanup or a deleted
++paste can no longer break someone's picture; incident 2026-11-06). Clients never supply the stored address:
| input to `PUT /api/profile/avatar { source }` | what happens |
|---|---|
--| a paste link or slug (community, live, media) | resolved to `https://openvibe.media/p/<slug>/screenshot`, checked to be an image |
--| an `https://openvibe.media/…` image | checked to be an image |
--| any other https picture link | **imported**: Media fetches it once (`POST /internal/avatar-ingest`: public addresses only, connection pinned to the checked address, ≤ 3 redirects, 8 MB, 10 s), re-encodes to a 512×512 WebP and stores it as an unlisted avatar paste (`app_id = 'network'`) |
++| a paste link or slug (community, live, media) | resolved to `https://openvibe.media/p/<slug>/screenshot` and copied into an avatar object |
++| an `https://openvibe.media/…` image | copied from Media's local storage (no self-fetch); an avatar already owned by the person is reused as it is |
++| any other https picture link | **imported**: Media fetches it once (`POST /internal/avatar-ingest`: public addresses only, connection pinned to the checked address, ≤ 3 redirects, 8 MB, 10 s), re-encodes to a 512×512 WebP and stores it as an unlisted avatar object (`app_id = 'live'`, `<root>.avatars`) owned by the person's subject |
| anything else | refused |
++Every import also records the profile reference in Media (`media_avatar_refs`: product `network`, ref the Network
++user id), and Media refuses to delete an avatar a profile still points at (409 `media.object.avatar_in_use`).
++Replacing the picture releases the previous reference with the new one; `DELETE /api/profile/avatar` releases it
++(`POST /internal/avatar-release`). Live's own copies work the same way (`product live`), as does a channel banner.
++
`DELETE /api/profile/avatar` clears it. `GET https://openvibe.network/avatar/<username>[?s=96]` is public and
--cacheable: 302 to the picture, or a generated initial; usable in any `<img>` on any site.
++cacheable: 302 to the picture, or a generated initial; usable in any `<img>` on any site. Only an openvibe.media
++address is ever redirected to (a legacy off-network row gets the initial).
Sync: the Network pushes changes to Live (`POST /internal/user-avatar` on Live); Live reports its own avatar
--picker's changes to the Network (`POST /internal/user-avatar` on the Network, media addresses only). A change
--that came from a site is not echoed back to it.
++picker's changes to the Network (`POST /internal/user-avatar` on the Network, media addresses only). A change that
++came from a site is not echoed back to it — unless the address changed because it was copied into the person's
++avatar object, in which case the site is told the new address (otherwise it would keep showing the source).
## 13. Navbar for app-style sites (what Live uses)
ddiff --git a/scripts/migrate-screenshot-avatars.js b/scripts/migrate-screenshot-avatars.js
nnew file mode 100644
iindex 00000000..efdebd27
---- /dev/null
++++ b/scripts/migrate-screenshot-avatars.js
@@@ -0,0 +1,245 @@
++#!/usr/bin/env node
++/**
++ * Every profile picture becomes the person's own avatar object (incident 2026-11-06). Before this, an avatar could
++ * be another product's object — usually a Community paste screenshot — so a cleanup, a paste deletion or a private
++ * re-host broke the picture. This copies each one into a native avatar object on openvibe.media, owned by the
++ * person, and records the profile reference in Media so the object can no longer be deleted out from under a
++ * working profile.
++ *
++ * node scripts/migrate-screenshot-avatars.js # dry run: lists every row it would change
++ * node scripts/migrate-screenshot-avatars.js --apply # copy, update and announce
++ * node scripts/migrate-screenshot-avatars.js --live-db <url> # also scan Live's own copies and channel banners
++ *
++ * What it changes, in this order:
++ * 1. Network users.avatar_url — every picture is copied (or reused when it is already the person's avatar
++ * object) through the avatar service, so the profile reference is recorded; a value that is not an https
++ * picture address is cleared to the generated initial. The users trigger records each change, and drain()
++ * emits network.user.updated so every service's subject projection refreshes. The change is pushed to Live.
++ * 2. Live users.avatar_url — Live's own copy of the picture (the service may not be linked to a Network account):
++ * same copy, `product live`, slot `avatar:<live user id>`. A legacy local path (/data/avatars/…, which 404s)
++ * falls back to the linked Network account's picture, or the initial.
++ * 3. Live channels.offline_banner_url — a channel banner is copied with the wide `banner` shape, slot
++ * `banner:<channel id>`.
++ *
++ * Without --live-db steps 2 and 3 are skipped and reported as such (there is a Network account behind most Live
++ * rows, and the push in step 1 already fixes those). It needs Network's DATABASE_URL, Media's internal URL and
++ * Network's signing key, the way the service runs; no secret is printed.
++ */
++'use strict';
++const { parseArgs } = require('./lib/db-ops');
++
++const USAGE = `Move every avatar and channel banner onto the person's own avatar object.
++
++ node scripts/migrate-screenshot-avatars.js dry run: list every row it would change
++ node scripts/migrate-screenshot-avatars.js --apply copy, update and announce
++ node scripts/migrate-screenshot-avatars.js --live-db <url> also scan Live's own copies and channel banners
++
++Options:
++ --live-db <url> Live's PostgreSQL connection string (else LIVE_DATABASE_URL)
++ --apply write (default: dry run)
++ --help this text`;
++
++const AVATAR_OBJECT = /^https:\/\/openvibe\.media\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})$/;
++
++/** 'copy' (an https picture to import) · 'clear' (not a picture address: the initial) */
++function classify(url) {
++ if (!url) return 'none';
++ return /^https:\/\//i.test(String(url)) ? 'copy' : 'clear';
++}
++
++/** A site's own row can fall back to the Network account's picture; that is 'fallback', not a bare clear. */
++function liveAction(url) {
++ const c = classify(url);
++ return c === 'clear' ? 'fallback' : c;
++}
++
++/** The Network rows this script may change: every account with a picture, whatever it points at. */
++async function planNetwork(db) {
++ const rows = await db.prepare('SELECT id, username, avatar_url FROM users WHERE avatar_url IS NOT NULL ORDER BY id').all();
++ return rows.map((r) => ({ product: 'network', id: Number(r.id), username: r.username, before: r.avatar_url, action: classify(r.avatar_url) }));
++}
++
++/** Live's own copies and banners, with the Network account each row is linked to (if any). */
++async function planLive(live) {
++ const [users, banners] = await Promise.all([live.users(), live.banners()]);
++ const out = [];
++ for (const u of users) {
++ const action = liveAction(u.avatar_url);
++ out.push({ product: 'live', slot: `avatar:${u.id}`, id: Number(u.id), username: u.username,
++ before: u.avatar_url, action, networkUserId: u.network_user_id == null ? null : Number(u.network_user_id) });
++ }
++ for (const c of banners) {
++ out.push({ product: 'live', slot: `banner:${c.id}`, id: Number(c.id), channelId: Number(c.id), before: c.offline_banner_url,
++ action: liveAction(c.offline_banner_url), shape: 'banner',
++ networkUserId: c.network_user_id == null ? null : Number(c.network_user_id) });
++ }
++ return out;
++}
++
++/** What a plan row will become, in one line: the dry run is this listing. */
++function describe(p) {
++ const where = p.product === 'network' ? `network users.avatar_url id=${p.id}` : `live ${p.slot}`;
++ if (p.action === 'none') return `${where} unchanged`;
++ if (p.action === 'copy') return `${where} copy ${p.before}`;
++ if (p.action === 'fallback') return `${where} legacy local path ${p.before} → ${p.to || 'the initial'}`;
++ return `${where} not a picture address ${p.before} → the initial`;
++}
++
++function liveAdapter(client) {
++ return {
++ users: async () => (await client.query(
++ `SELECT u.id, u.username, u.avatar_url, la.service_user_id AS network_user_id
++ FROM users u LEFT JOIN linked_accounts la ON la.service = 'network' AND la.user_id = u.id
++ WHERE u.avatar_url IS NOT NULL ORDER BY u.id`)).rows,
++ banners: async () => (await client.query(
++ `SELECT c.id, c.offline_banner_url, la.service_user_id AS network_user_id
++ FROM channels c LEFT JOIN linked_accounts la ON la.service = 'network' AND la.user_id = c.user_id
++ WHERE c.offline_banner_url IS NOT NULL ORDER BY c.id`)).rows,
++ setUserAvatar: async (id, url) => await client.query('UPDATE users SET avatar_url = $1 WHERE id = $2', [url, id]),
++ setBanner: async (id, url) => await client.query('UPDATE channels SET offline_banner_url = $1 WHERE id = $2', [url, id]),
++ };
++}
++
++function createExecutor({ db, config, selfToken, live = null }) {
++ const media = () => ((config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100').replace(/\/$/, '');
++ async function mediaCall(path, cap, body) {
++ const token = selfToken && selfToken('openvibe.media', [cap]);
++ if (!token) throw new Error('Media signing key is not configured (JWT_PRIVATE_KEY)');
++ const r = await fetch(`${media()}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify(body), signal: AbortSignal.timeout(20000) });
++ const j = await r.json().catch(() => ({}));
++ if (!r.ok || j.ok === false) throw new Error(`${path}: ${j.error || `HTTP ${r.status}`}`);
++ return j;
++ }
++ return {
++ mediaCall,
++ /** Step 1: the avatar service copies + records + applies + pushes (the same path the profile form uses). */
++ async network(p) {
++ const svc = require('../server/profile/avatar').createAvatarService({
++ db, config, requireAuth: (_q, _s, n) => n(), selfToken,
++ });
++ if (p.action === 'copy') {
++ const r = await svc.fromSite({ user_id: p.id, avatar_url: p.before, origin: 'network' });
++ if (r.status !== 200) throw new Error(r.error || `HTTP ${r.status}`);
++ return r.changed ? 'copied' : 'already its own avatar object';
++ }
++ await svc.apply(p.id, p.username, null, 'network');
++ await svc.release(p.id);
++ return 'cleared to the initial';
++ },
++ /** Step 2/3: Live's own rows, through Media's ingest (the service is not the caller here). */
++ async liveCopy(p, subject) {
++ const j = await mediaCall('/internal/avatar-ingest', 'media.avatar.ingest', {
++ url: p.before, user_id: p.id, username: p.username || null, subject, product: 'live', ref: p.slot,
++ ...(p.shape ? { shape: p.shape } : {}),
++ });
++ const url = j.url;
++ if (p.shape === 'banner') await live.setBanner(p.channelId, url); else await live.setUserAvatar(p.id, url);
++ if (j.reused) return { result: 'already its own avatar object', url };
++ return { result: 'copied', url };
++ },
++ /** A legacy local path: point Live at the linked Network picture, or the initial. */
++ async liveFallback(p, networkAvatar) {
++ if (networkAvatar) {
++ const m = AVATAR_OBJECT.exec(networkAvatar);
++ if (m) await mediaCall('/internal/avatar-adopt', 'media.avatar.adopt', { object_id: m[1], product: 'live', ref: p.slot });
++ }
++ if (p.shape === 'banner') await live.setBanner(p.channelId, networkAvatar || null); else await live.setUserAvatar(p.id, networkAvatar || null);
++ return { result: networkAvatar ? 'the linked Network picture' : 'the initial', url: networkAvatar || null };
++ },
++ };
++}
++
++async function main(argv = process.argv.slice(2), { db: injected, live: injectedLive, log = console } = {}) {
++ const args = parseArgs(argv, { flags: ['apply', 'help'], values: ['live-db'] });
++ if (args.help) { log.log(USAGE); return 0; }
++
++ let db = injected;
++ if (!db) {
++ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: run this where Network runs (or point it at a copy)');
++ db = await require('openvibe-sdk/db').createDb({ url: process.env.DATABASE_URL, service: 'network-avatar-migration', max: 2 });
++ }
++ let live = injectedLive || null;
++ let closeLive = null;
++ const liveUrl = args['live-db'] || process.env.LIVE_DATABASE_URL;
++ if (!live && liveUrl) {
++ const { Client } = require('pg');
++ const client = new Client({ connectionString: liveUrl });
++ await client.connect();
++ live = liveAdapter(client);
++ closeLive = async () => await client.end();
++ }
++
++ try {
++ const network = await planNetwork(db);
++ const livePlans = live ? await planLive(live) : [];
++ const todo = [...network, ...livePlans].filter((p) => p.action !== 'none');
++
++ if (!args.apply) {
++ for (const p of todo) log.log(describe(p));
++ const count = (list, product) => list.filter((p) => p.product === product && p.action !== 'none').length;
++ log.log(`dry run: ${count(network, 'network')} Network avatar(s), ${live ? count(livePlans, 'live') : 0} Live picture(s)/banner(s)`);
++ if (!live) log.log('Live was not scanned (no --live-db / LIVE_DATABASE_URL); its linked accounts are still fixed by the push in step 1.');
++ log.log('Nothing changed. Re-run with --apply to copy them into avatar objects.');
++ return 0;
++ }
++
++ const config = require('../server/config');
++ const keyPath = process.env.JWT_PRIVATE_KEY || config.jwt.privateKeyPath;
++ const privateKey = (() => { try { return require('fs').readFileSync(keyPath, 'utf8'); } catch { return null; } })();
++ const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });
++ const exec = createExecutor({ db, config, selfToken, live });
++
++ const failures = [];
++ let changed = 0;
++ for (const p of network) {
++ if (p.action === 'none') continue;
++ try { const result = await exec.network(p); changed++; log.log(`${describe(p)} → ${result}`); }
++ catch (err) { failures.push(`network ${p.id}: ${err.message}`); log.error(`network users.avatar_url id=${p.id} failed: ${err.message}`); }
++ }
++
++ // The trigger queued each change; drain() turns it into network.user.updated for every service.
++ if (changed) {
++ try {
++ const queue = require('../server/identity/profile-events');
++ await queue.ensureSchema(db);
++ const n = await queue.drain(db);
++ log.log(`announced ${n} profile update(s) (network.user.updated)`);
++ } catch (err) { failures.push(`profile events: ${err.message}`); log.error(`profile events failed: ${err.message}`); }
++ }
++
++ if (live) {
++ // Read Network's pictures as they are now: a legacy Live row falls back to what Network holds, and a Live
++ // row the push already fixed is read as it is rather than copied a second time from its old source.
++ const now = new Map((await db.prepare('SELECT id, subject_id, avatar_url FROM users').all()).map((u) => [Number(u.id), u]));
++ for (const p of await planLive(live)) {
++ if (p.action === 'none') continue;
++ const account = p.networkUserId != null ? now.get(p.networkUserId) : null;
++ const subject = account && account.subject_id ? account.subject_id : null;
++ try {
++ if (p.action === 'copy') {
++ const out = await exec.liveCopy(p, subject);
++ log.log(`${describe(p)} → ${out.result}${out.url ? ` (${out.url})` : ''}`);
++ } else {
++ const out = await exec.liveFallback(p, account ? account.avatar_url : null);
++ log.log(`${describe({ ...p, action: 'fallback', to: out.url }) } → ${out.result}`);
++ }
++ changed++;
++ } catch (err) { failures.push(`live ${p.slot}: ${err.message}`); log.error(`live ${p.slot} failed: ${err.message}`); }
++ }
++ } else {
++ log.log('Live was not scanned (no --live-db / LIVE_DATABASE_URL); its linked accounts were fixed by the push.');
++ }
++
++ log.log(`done: ${changed} row(s) changed${failures.length ? `, ${failures.length} failed` : ''}`);
++ return failures.length ? 1 : 0;
++ } finally {
++ if (closeLive) await closeLive();
++ }
++}
++
++module.exports = { main, classify, planNetwork, planLive, describe, liveAdapter };
++
++if (require.main === module) {
++ main().then((code) => process.exit(code)).catch((err) => { console.error(err.message); process.exit(2); });
++}
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 1c82e26c..eb15a7cc 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -134,7 +134,8 @@ router.post('/user-avatar', principals.guard('network.avatar.write'), async (req
const svc = req.app.locals.avatarService;
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
const r = await svc.fromSite(req.body || {});
-- res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
++ // avatar_url: the copied avatar object's address, so the site stores its own object rather than the source.
++ res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed, avatar_url: r.avatar_url || null });
});
// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex bfec4498..3596dde0 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -2,11 +2,14 @@
// ═══════════════════════════════════════════════════════════════
// The avatar — one picture per account, for every OpenVibe site.
//
--// Source of truth: users.avatar_url on the Network. A picture is always a file on openvibe.media, usually
--// the screenshot of one of your pastes; nothing else is accepted, so an avatar can never be a tracking
--// pixel on someone else's server, a javascript: URL, or a file that disappears with a third party.
++// Source of truth: users.avatar_url on the Network. A picture is always the person's own avatar object on
++// openvibe.media: whatever is chosen (an upload, a paste screenshot, an openvibe.media address, a picture
++// elsewhere on the web) is copied into a native avatar object owned by them, and the source is left untouched.
++// A profile therefore never points at another product's object, so a Community cleanup or a deleted paste can
++// no longer break someone's picture (incident 2026-11-06). Media records the reference, and refuses to delete
++// an avatar that is still in use.
//
--// PUT /api/profile/avatar { source } a paste link, a paste slug, or an https://openvibe.media/… image
++// PUT /api/profile/avatar { source } a paste link, a paste slug, or any https picture address
// DELETE /api/profile/avatar back to the generated initial
// GET /avatar/:username[?s=96] public, cacheable: 302 to the picture, or an SVG initial.
// Works in any <img> on any site with no API call. With ?s, a media
@@@ -21,20 +24,23 @@ const MEDIA_HOST = 'openvibe.media';
const PASTE_HOSTS = new Set(['openvibe.community', 'openvibe.live', 'openvibe.media', 'pastes.openvibe.tools']);
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{1,80}$/;
--/** '' → clear · a paste link or slug → its screenshot on openvibe.media · an openvibe.media https URL → itself · else null */
++/**
++ * '' → clear · a paste link or slug → its screenshot on openvibe.media · any https picture → itself.
++ * Every non-empty answer is a *source*: Media copies it into the person's own avatar object before it is stored
++ * (see media/ingest), so the address kept here is never the source's.
++ */
function normalizeAvatar(input) {
const raw = String(input == null ? '' : input).trim();
if (!raw) return { url: null };
if (raw.length > 500) return { error: 'That link is too long' };
if (SLUG_RE.test(raw)) return { url: `https://${MEDIA_HOST}/p/${raw}/screenshot`, paste: raw };
-- let u; try { u = new URL(raw); } catch { return { error: 'Use a paste link or an openvibe.media image address' }; }
++ let u; try { u = new URL(raw); } catch { return { error: 'Use a paste link or an https picture address' }; }
if (u.protocol !== 'https:' || u.username || u.password) return { error: 'Avatars must be https addresses' };
const host = u.hostname.toLowerCase();
const m = /^\/p\/([A-Za-z0-9][A-Za-z0-9_-]{1,80})(?:\/(?:screenshot|raw)?)?\/?$/.exec(u.pathname);
if (m && PASTE_HOSTS.has(host)) return { url: `https://${MEDIA_HOST}/p/${m[1]}/screenshot`, paste: m[1] };
-- if (host === MEDIA_HOST) { u.hash = ''; return { url: u.toString() }; }
-- // Anywhere else on the web: never stored, never served. openvibe.media fetches it once and keeps its own copy.
-- return { ingest: u.toString() };
++ if (host === MEDIA_HOST) { u.hash = ''; }
++ return { url: u.toString() };
}
/**
@@@ -64,6 +70,11 @@ async function verifyImage(url, fetchImpl = fetch) {
} catch { return { ok: false, error: 'openvibe.media did not answer in time; try again' }; }
}
++/** Only our own picture host is ever served, whatever an old row holds (a legacy off-network address is never redirected to). */
++function isMediaUrl(url) {
++ try { return new URL(String(url)).hostname.toLowerCase() === MEDIA_HOST; } catch { return false; }
++}
++
const initialSvg = (name, size) => {
const s = Math.max(16, Math.min(512, parseInt(size, 10) || 96));
const ch = (String(name || '?').match(/[A-Za-z0-9]/) || ['?'])[0].toUpperCase();
@@@ -90,30 +101,49 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
body: JSON.stringify({ openvibenetwork_id: userId, username, avatar_url: url }), signal: AbortSignal.timeout(5000) }).catch(() => {});
}
-- /** A picture from elsewhere on the web: openvibe.media fetches it safely, re-encodes it and keeps the copy. */
++ const mediaUrl = () => ((config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100').replace(/\/$/, '');
++
++ /**
++ * Any picture becomes this person's own avatar object: Media reads an openvibe.media source from local storage
++ * (reusing the person's own avatar as it is) and fetches anything else safely, re-encoding it. `product`/`ref`
++ * record the profile reference, which is what stops a cleanup deleting the picture that is in use.
++ */
async function ingest(url, user) {
-- const media = (config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100';
const auth = authHeaders('openvibe.media', 'media.avatar.ingest');
if (!Object.keys(auth).length) return { ok: false, status: 503, error: 'Picture import is not configured on this server' };
try {
-- const r = await fetch(`${media.replace(/\/$/, '')}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },
++ const r = await fetch(`${mediaUrl()}/internal/avatar-ingest`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },
// subject: the picture's owner on Media's object, in the network-wide id space (user_id is Network's own).
-- body: JSON.stringify({ url, user_id: user.id, username: user.username, subject: user.subject_id || null }), signal: AbortSignal.timeout(20000) });
++ body: JSON.stringify({ url, user_id: user.id, username: user.username, subject: user.subject_id || null, product: 'network', ref: String(user.id) }), signal: AbortSignal.timeout(20000) });
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) return { ok: false, status: r.status === 404 ? 503 : 422, error: j.error || 'That picture could not be imported' };
const back = normalizeAvatar(j.url); // trust nothing: the answer must itself be an openvibe.media address
-- if (!back.url) return { ok: false, status: 502, error: 'Picture import returned an unexpected address' };
-- return { ok: true, url: back.url, slug: j.slug };
++ if (!back.url || !/^\/o\/med_/.test(new URL(back.url).pathname)) return { ok: false, status: 502, error: 'Picture import returned an unexpected address' };
++ return { ok: true, url: back.url, slug: j.slug, reused: !!j.reused };
} catch { return { ok: false, status: 504, error: 'openvibe.media did not answer in time; try again' }; }
}
-- /** The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back. */
-- async function apply(userId, username, url, origin) {
++ /** The profile no longer points at its picture: Media drops the reference so the object can be deleted normally. */
++ async function release(userId) {
++ const auth = authHeaders('openvibe.media', 'media.avatar.release');
++ if (!Object.keys(auth).length) return;
++ try {
++ await fetch(`${mediaUrl()}/internal/avatar-release`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...auth },
++ body: JSON.stringify({ product: 'network', ref: String(userId) }), signal: AbortSignal.timeout(5000) });
++ } catch { /* best effort: the object stays protected rather than lost */ }
++ }
++
++ /**
++ * The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back
++ * — unless the stored address changed (a site sent a paste screenshot that is now its own avatar object, and
++ * the site must learn the new address, or it would keep pointing at the source).
++ */
++ async function apply(userId, username, url, origin, { pushBack = false } = {}) {
const before = (await getUrl.get(userId) || {}).avatar_url || null;
if (before === url) return false;
await setUrl.run(url, userId);
try { await db.prepare('INSERT INTO audit_log (user_id, action, details, ip) VALUES (?, ?, ?, ?)').run(userId, 'avatar_change', JSON.stringify({ from: before, to: url, origin }), null); } catch { /* audit is best effort */ }
-- if (origin !== 'live') pushToSites(userId, username, url);
++ if (origin !== 'live' || pushBack) pushToSites(userId, username, url);
return true;
}
@@@ -121,35 +151,50 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
api.put('/', requireAuth, async (req, res) => {
const n = normalizeAvatar(req.body && (req.body.source ?? req.body.avatar_url ?? req.body.url));
if (n.error) return res.status(400).json({ error: n.error });
-- if (n.ingest) {
-- const got = await ingest(n.ingest, req.user);
++ let url = n.url;
++ if (url) {
++ // Always a copy in the person's own avatar object, whatever the source (a paste screenshot, another
++ // product's object, a picture elsewhere). Media reuses the picture when it is already theirs.
++ const got = await ingest(url, req.user);
if (!got.ok) return res.status(got.status || 422).json({ error: got.error });
-- n.url = got.url; n.paste = got.slug;
-- } else if (n.url) { const v = await verifyImage(n.url); if (!v.ok) return res.status(422).json({ error: v.error }); }
-- await apply(req.user.id, req.user.username, n.url, 'network');
-- res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
++ url = got.url; n.paste = got.slug || n.paste;
++ }
++ await apply(req.user.id, req.user.username, url, 'network');
++ res.json({ ok: true, avatar_url: url, paste: n.paste || null });
++ });
++ api.delete('/', requireAuth, async (req, res) => {
++ await apply(req.user.id, req.user.username, null, 'network');
++ await release(req.user.id);
++ res.json({ ok: true, avatar_url: null });
});
-- api.delete('/', requireAuth, async (req, res) => { await apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });
const pub = express.Router();
pub.get('/:username', async (req, res) => {
const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
const row = await byName.get(name);
res.set({ 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400', 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' });
-- const ok = row && row.avatar_url && !!normalizeAvatar(row.avatar_url).url;
++ const ok = row && row.avatar_url && isMediaUrl(row.avatar_url) && !!normalizeAvatar(row.avatar_url).url;
if (ok) return res.redirect(302, sizedAvatar(row.avatar_url, req.query.s));
res.type('image/svg+xml').send(initialSvg(row ? row.username : name, req.query.s));
});
-- /** Live changed someone's picture: adopt it (same rules, no verification round trip for our own media host). */
++ /** Live changed someone's picture: adopt it into an avatar object, and tell that site the new address if it changed. */
async function fromSite(body) {
const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };
-- const n = normalizeAvatar(body.avatar_url); if (n.error || n.ingest) return { status: 422, error: n.error || 'Sites report openvibe.media pictures only' };
-- const u = await db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };
-- return { status: 200, changed: await apply(id, u.username, n.url, String(body.origin || 'live')) };
++ const n = normalizeAvatar(body.avatar_url); if (n.error) return { status: 422, error: n.error };
++ if (n.url && new URL(n.url).hostname !== MEDIA_HOST) return { status: 422, error: 'Sites report openvibe.media pictures only' };
++ const u = await db.prepare('SELECT username, subject_id FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };
++ let url = n.url; let pushBack = false;
++ if (url) {
++ const got = await ingest(url, { id, username: u.username, subject_id: u.subject_id });
++ if (!got.ok) return { status: got.status || 422, error: got.error };
++ pushBack = got.url !== n.url;
++ url = got.url;
++ }
++ return { status: 200, changed: await apply(id, u.username, url, String(body.origin || 'live'), { pushBack }), avatar_url: url };
}
-- return { api, pub, fromSite, apply, log };
++ return { api, pub, fromSite, apply, ingest, release, log };
}
module.exports = { createAvatarService, normalizeAvatar, verifyImage, initialSvg };
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
iindex 7965ea6e..6da5d056 100644
---- a/test/avatar.test.js
++++ b/test/avatar.test.js
@@@ -4,15 +4,15 @@ const express = require('express');
const { getDb } = require('../server/db/database');
const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');
--// What counts as an avatar
++// What counts as an avatar: everything accepted is a *source* Media copies into the person's own avatar object.
const M = 'https://openvibe.media';
assert.equal(normalizeAvatar('teal-holly-41').url, `${M}/p/teal-holly-41/screenshot`, 'a paste slug');
assert.equal(normalizeAvatar('https://openvibe.community/p/teal-holly-41').url, `${M}/p/teal-holly-41/screenshot`, 'a Community paste link');
assert.equal(normalizeAvatar('https://openvibe.live/p/teal-holly-41/').url, `${M}/p/teal-holly-41/screenshot`, 'a Live paste link');
assert.equal(normalizeAvatar(`${M}/f/screenshots/avatar-1.png`).url, `${M}/f/screenshots/avatar-1.png`, 'a media file');
assert.equal(normalizeAvatar('').url, null, 'empty clears it');
--assert.deepEqual(normalizeAvatar('https://example.com/me.png'), { ingest: 'https://example.com/me.png' }, 'a link elsewhere is imported, never stored');
--assert.ok(!normalizeAvatar('https://openvibe.media.evil.example/x.png').url, 'look-alike hosts are not openvibe.media');
++assert.equal(normalizeAvatar('https://example.com/me.png').url, 'https://example.com/me.png', 'a link elsewhere is a source too, copied by Media');
++assert.equal(normalizeAvatar('https://openvibe.media.evil.example/x.png').url, 'https://openvibe.media.evil.example/x.png', 'look-alike hosts are not openvibe.media');
for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https://user:[email protected]/x.png', 'data:image/png;base64,AAAA', '../../etc/passwd'])
assert.ok(normalizeAvatar(bad).error, `rejected: ${bad}`);
@@@ -22,35 +22,80 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
assert.equal((await verifyImage(`${M}/x.png`, img)).ok, true);
assert.equal((await verifyImage(`${M}/x.png`, html)).ok, false, 'a page is not a picture');
++ const A = 'med_01KZK5GRQ088Y3CW1TMEF4Z4RN';
++ const B = 'med_01KZK5GRQ088Y3CW1TMEF4Z4RP';
++ const C = 'med_01KZK5GRQ088Y3CW1TMEF4Z4RQ';
const db = getDb();
-- await db.prepare("INSERT INTO users (id, username, password_hash, avatar_url) VALUES (1, 'Goosely', 'x', NULL), (2, 'legacy', 'x', 'https://evil.example/old.png')").run();
-- const pushes = []; const pushHeaders = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); pushHeaders.push(o.headers); return { ok: true }; };
++ await db.prepare("INSERT INTO users (id, username, password_hash, avatar_url, subject_id) VALUES (1, 'Goosely', 'x', NULL, 'usr_01KZK5GRQ088Y3CW1TMEF4Z4RM'), (2, 'legacy', 'x', 'https://evil.example/old.png', NULL)").run();
++
++ // Media answers the internal routes; Live's push and the test's own HTTP client go through to the real thing.
++ const realFetch = global.fetch;
++ const ingest = [], release = [], pushes = [], pushHeaders = [];
++ global.fetch = async (u, o) => {
++ const url = String(u); const body = o && o.body ? JSON.parse(o.body) : null;
++ if (url.endsWith('/internal/avatar-ingest')) {
++ ingest.push(body);
++ const id = body.url === `${M}/p/teal-holly-41/screenshot` ? B : body.url === `${M}/p/other/screenshot` ? C : A;
++ return { ok: true, json: async () => ({ ok: true, object_id: id, url: `${M}/o/${id}`, reused: body.url === `${M}/o/${id}` }) };
++ }
++ if (url.endsWith('/internal/avatar-release')) { release.push(body); return { ok: true, json: async () => ({ ok: true, released: 1 }) }; }
++ if (url.endsWith('/internal/user-avatar')) { pushes.push(body); pushHeaders.push(o.headers); return { ok: true }; }
++ return realFetch(u, o);
++ };
++
// Network's own service token for the push (plan T2): live.avatar.write, audience openvibe.live.
const { privateKey, publicKey } = require('crypto').generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
const selfToken = require('../server/identity/self-token').createSelfTokens({ privateKey, issuer: 'https://openvibe.network' });
-- const svc = createAvatarService({ db, config: { services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
++ const svc = createAvatarService({ db, config: { services: {} }, selfToken, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely', subject_id: 'usr_01KZK5GRQ088Y3CW1TMEF4Z4RM' }; next(); } });
++
++ // Live's picker sent a paste screenshot: it becomes the person's own avatar object, and Live is told the new
++ // address (otherwise it would keep pointing at the source).
assert.equal((await svc.fromSite({ user_id: 1, avatar_url: `${M}/f/a.png`, origin: 'live' })).changed, true);
-- assert.equal(pushes.length, 0, 'a change that came from Live is not echoed back to Live');
-- await svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network'); assert.equal(pushes.length, 1); assert.equal(pushes[0].avatar_url, `${M}/f/b.png`, 'a change made here is pushed to the sites');
++ assert.deepStrictEqual(ingest[0], { url: `${M}/f/a.png`, user_id: 1, username: 'Goosely', subject: 'usr_01KZK5GRQ088Y3CW1TMEF4Z4RM', product: 'network', ref: '1' },
++ 'the source is copied into the person\'s avatar object, and the profile reference recorded');
++ assert.deepStrictEqual([pushes.length, pushes[0].avatar_url], [1, `${M}/o/${A}`], 'the site learns the avatar object it now shows');
++ await svc.apply(1, 'Goosely', `${M}/f/b.png`, 'network');
++ assert.deepStrictEqual([pushes.length, pushes[1].avatar_url], [2, `${M}/f/b.png`], 'a change made here is pushed to the sites');
const bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');
const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
assert.ok(v.ok && v.claims.sub === 'svc:network' && v.claims.cap.includes('live.avatar.write'), `the push carries Network's token for live.avatar.write: ${v.reason || ''}`);
-- assert.equal((await svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' })).status, 422);
-- global.fetch = realFetch;
++ assert.equal((await svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' })).status, 422, 'a site reports our own media only');
++ assert.equal(ingest.length, 1, 'a refused source is never copied');
const app = express(); app.use('/avatar', svc.pub);
-- const srv = app.listen(0, async () => {
++ const apiApp = express(); apiApp.use(express.json()); apiApp.use('/api/profile/avatar', svc.api);
++ const srv = app.listen(0);
++ const apiSrv = apiApp.listen(0);
++ try {
const b = 'http://127.0.0.1:' + srv.address().port;
-- let r = await fetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'case-insensitive lookup redirects to the picture');
++ let r = await realFetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'case-insensitive lookup redirects to the picture');
assert.equal(r.headers.get('cross-origin-resource-policy'), 'cross-origin', 'usable in an <img> on any site');
-- r = await fetch(b + '/avatar/nobody'); assert.equal(r.status, 200); assert.ok(/svg/.test(r.headers.get('content-type')) && /<text/.test(await r.text()), 'unknown names get an initial');
-- r = await fetch(b + '/avatar/legacy', { redirect: 'manual' }); assert.equal(r.status, 200, 'a stored off-network picture is never redirected to');
++ r = await realFetch(b + '/avatar/nobody'); assert.equal(r.status, 200); assert.ok(/svg/.test(r.headers.get('content-type')) && /<text/.test(await r.text()), 'unknown names get an initial');
++ r = await realFetch(b + '/avatar/legacy', { redirect: 'manual' }); assert.equal(r.status, 200, 'a stored off-network picture is never redirected to');
// A size asks openvibe.media for a WebP of about that width, for a media object only.
-- r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'a file picture is not resized');
-- await svc.apply(1, 'Goosely', `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'network');
-- r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN?w=192&fmt=webp`, 'an object picture at twice the size it is shown');
-- r = await fetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'no size, the picture as stored');
-- r = await fetch(b + '/avatar/goosely?s=9000', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'a size out of range is ignored');
-- console.log('avatar: all checks passed'); srv.close();
-- });
++ r = await realFetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'a file picture is not resized');
++ await svc.apply(1, 'Goosely', `${M}/o/${A}`, 'network');
++ r = await realFetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/${A}?w=192&fmt=webp`, 'an object picture at twice the size it is shown');
++ r = await realFetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/${A}`, 'no size, the picture as stored');
++ r = await realFetch(b + '/avatar/goosely?s=9000', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/${A}`, 'a size out of range is ignored');
++
++ // The profile form: a paste link is copied, never stored as the paste's own object.
++ const apiBase = 'http://127.0.0.1:' + apiSrv.address().port + '/api/profile/avatar';
++ let res = await realFetch(apiBase, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ source: 'teal-holly-41' }) });
++ let out = await res.json();
++ assert.deepStrictEqual([res.status, out.avatar_url, out.paste], [200, `${M}/o/${B}`, 'teal-holly-41']);
++ assert.strictEqual(ingest.at(-1).url, `${M}/p/teal-holly-41/screenshot`, 'the paste screenshot is the source');
++ assert.strictEqual((await db.prepare('SELECT avatar_url FROM users WHERE id = 1').get()).avatar_url, `${M}/o/${B}`, 'the stored picture is the person\'s object');
++ assert.strictEqual(pushes.at(-1).avatar_url, `${M}/o/${B}`, 'and every site is told about it');
++
++ // Clearing the picture releases the profile reference, so Media can retire the object normally.
++ res = await realFetch(apiBase, { method: 'DELETE' });
++ assert.deepStrictEqual([res.status, (await res.json()).avatar_url], [200, null]);
++ assert.deepStrictEqual(release.at(-1), { product: 'network', ref: '1' });
++ assert.strictEqual((await db.prepare('SELECT avatar_url FROM users WHERE id = 1').get()).avatar_url, null);
++ console.log('avatar: all checks passed');
++ } finally {
++ global.fetch = realFetch;
++ srv.close(); apiSrv.close();
++ }
})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/migrate-screenshot-avatars.test.js b/test/migrate-screenshot-avatars.test.js
nnew file mode 100644
iindex 00000000..a4fbda14
---- /dev/null
++++ b/test/migrate-screenshot-avatars.test.js
@@@ -0,0 +1,114 @@
++'use strict';
++// The one-off move of every profile picture onto the person's own avatar object (incident 2026-11-06).
++// - the dry run lists exactly the rows it would change, and changes nothing (Network or Live);
++// - --apply copies each https picture through Media (product + slot recorded), clears a value that is not a
++// picture address, falls a legacy Live local path back to the linked Network picture or the initial, copies a
++// channel banner with the wide shape, and announces the Network profile updates.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const crypto = require('crypto');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-avatar-migration-'));
++ const key = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ });
++ const keyPath = path.join(tmp, 'private.pem');
++ fs.writeFileSync(keyPath, key.privateKey);
++ process.env.JWT_PRIVATE_KEY = keyPath;
++
++ const { getDb } = require('../server/db/database');
++ const script = require('../scripts/migrate-screenshot-avatars');
++
++ const OBJECT = 'https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN';
++ const PASTE = 'https://openvibe.media/p/fierce-clover-90/screenshot';
++ const LEGACY = '/data/avatars/avatar-13-1773211874260.png';
++
++ const db = getDb();
++ await db.prepare(`INSERT INTO users (id, username, password_hash, avatar_url, subject_id) VALUES
++ (501, 'Ana', 'x', ?, 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R1'),
++ (502, 'Ben', 'x', ?, 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R2'),
++ (503, 'Cal', 'x', ?, 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R3'),
++ (504, 'Dee', 'x', NULL, 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R4')`).run(OBJECT, PASTE, LEGACY);
++
++ const live = {
++ userRows: [
++ { id: 13, username: 'Cal', avatar_url: LEGACY, network_user_id: 503 },
++ { id: 461, username: 'Ben', avatar_url: PASTE, network_user_id: 502 },
++ ],
++ bannerRows: [{ id: 7, offline_banner_url: 'https://openvibe.media/p/banner-shot/screenshot', network_user_id: 502 }],
++ async users() { return this.userRows; },
++ async banners() { return this.bannerRows; },
++ async setUserAvatar(id, url) { this.userRows.find((u) => u.id === id).avatar_url = url; },
++ async setBanner(id, url) { this.bannerRows.find((c) => c.id === id).offline_banner_url = url; },
++ };
++ const take = () => ({ users: live.userRows.map((u) => ({ ...u })), banners: live.bannerRows.map((c) => ({ ...c })) });
++
++ // ── Dry run: the exact list, no write anywhere ──────────────
++ const before = take();
++ const lines = [];
++ const log = { log: (m) => lines.push(String(m)), error: (m) => lines.push(`ERROR ${m}`) };
++ const code = await script.main([], { db, live, log });
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(lines, [
++ `network users.avatar_url id=501 copy ${OBJECT}`,
++ `network users.avatar_url id=502 copy ${PASTE}`,
++ `network users.avatar_url id=503 not a picture address ${LEGACY} → the initial`,
++ 'live avatar:13 legacy local path /data/avatars/avatar-13-1773211874260.png → the initial',
++ `live avatar:461 copy ${PASTE}`,
++ 'live banner:7 copy https://openvibe.media/p/banner-shot/screenshot',
++ 'dry run: 3 Network avatar(s), 3 Live picture(s)/banner(s)',
++ 'Nothing changed. Re-run with --apply to copy them into avatar objects.',
++ ], 'the dry run lists exactly the affected rows');
++ assert.deepStrictEqual((await db.prepare('SELECT avatar_url FROM users WHERE id = 501').get()).avatar_url, OBJECT, 'the dry run writes nothing on Network');
++ assert.deepStrictEqual(take(), before, 'the dry run writes nothing on Live');
++
++ // ── Apply: copy through Media, fall back, announce ──────────
++ const realFetch = global.fetch;
++ const calls = [];
++ const NEW = 'med_01KZK5GRQ088Y3CW1TMEF4Z4RX';
++ global.fetch = async (u, o) => {
++ const url = String(u); const body = o && o.body ? JSON.parse(o.body) : null;
++ calls.push({ url, body });
++ if (url.endsWith('/internal/avatar-ingest')) {
++ const id = body.shape === 'banner' ? 'med_01KZK5GRQ088Y3CW1TMEF4Z4RY' : NEW;
++ return { ok: true, json: async () => ({ ok: true, object_id: id, url: `https://openvibe.media/o/${id}`, reused: false }) };
++ }
++ return { ok: true, json: async () => ({ ok: true, released: 1 }) };
++ };
++ try {
++ lines.length = 0;
++ const applied = await script.main(['--apply'], { db, live, log });
++ assert.strictEqual(applied, 0, lines.join('\n'));
++
++ const avatar = async (id) => (await db.prepare('SELECT avatar_url FROM users WHERE id = ?').get(id)).avatar_url;
++ assert.strictEqual(await avatar(501), `https://openvibe.media/o/${NEW}`, 'an avatar object is copied too (only the person\'s own is reused)');
++ assert.strictEqual(await avatar(502), `https://openvibe.media/o/${NEW}`, 'a paste screenshot becomes an avatar object');
++ assert.strictEqual(await avatar(503), null, 'a value that is not a picture address is cleared to the initial');
++ assert.strictEqual(await avatar(504), null, 'an account without a picture is left alone');
++
++ const ingest = calls.filter((c) => c.url.endsWith('/internal/avatar-ingest')).map((c) => c.body);
++ assert.ok(ingest.some((b) => b.url === PASTE && b.product === 'network' && b.ref === '502' && b.subject === 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R2'),
++ 'the Network copy records the profile reference and the owner subject');
++ assert.ok(calls.some((c) => c.url.endsWith('/internal/avatar-release') && c.body.ref === '503'), 'clearing releases the old reference');
++ assert.ok(lines.some((l) => /announced \d+ profile update/.test(l)), `the profile updates are announced: ${lines.join(' | ')}`);
++ const events = await db.prepare("SELECT envelope FROM network_event_outbox").all();
++ assert.ok(events.some((e) => JSON.stringify(e.envelope).includes('usr_01KZK5GRQ088Y3CW1TMEF4Z4R2') && JSON.stringify(e.envelope).includes('network.user.updated')),
++ 'network.user.updated is queued for the changed people');
++
++ assert.strictEqual(live.userRows.find((u) => u.id === 13).avatar_url, null, 'the legacy Live local path falls back to Network\'s (here the initial)');
++ assert.strictEqual(live.userRows.find((u) => u.id === 461).avatar_url, `https://openvibe.media/o/${NEW}`, 'Live\'s own copy points at an avatar object');
++ assert.strictEqual(live.bannerRows.find((c) => c.id === 7).offline_banner_url, 'https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RY', 'the banner is copied with the wide shape');
++ assert.ok(ingest.some((b) => b.shape === 'banner' && b.product === 'live' && b.ref === 'banner:7'), 'the banner slot is its own reference');
++ assert.ok(ingest.some((b) => b.product === 'live' && b.ref === 'avatar:461' && b.subject === 'usr_01KZK5GRQ088Y3CW1TMEF4Z4R2'), 'Live\'s avatar records the slot and the linked subject');
++
++ console.log('migrate screenshot avatars: all checks passed');
++ } finally {
++ global.fetch = realFetch;
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 3ff3afbd..3a136b10 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -132,7 +132,7 @@ const REVIEWED = {
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
-- 'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
++ 'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
@@@ -238,10 +238,13 @@ function inventory(root) {
assert.ok(!left.some((e) => /127\.0\.0\.1|\[::ffff|2130706433|localhost/.test(e)), 'rows with an internal address are dropped');
await listener.close();
-- // ── 2. Avatars: Network itself fetches openvibe.media only ─────────
++ // ── 2. Avatars: Network itself never fetches a picture (Media's ingest does) ─────────
++ // An accepted source is handed to Media; what must not happen is a look-alike or internal address being
++ // rewritten onto our own media host, which Network used to fetch. (Part 3 proves no connection is made.)
++ const hostOf = (x) => { try { return new URL(x).hostname.toLowerCase(); } catch { return null; } };
for (const u of [...internalUrls(8443, '/a.png'), 'https://[email protected]/a.png', 'https://openvibe.media.evil.test/a.png', 'https://127.0.0.1/p/abc/screenshot', 'https://[::1]/p/abc']) {
const n = normalizeAvatar(u);
-- assert.ok(!n.url || n.url.startsWith('https://openvibe.media/'), `${u}: Network fetches only openvibe.media (${JSON.stringify(n)})`);
++ assert.ok(!n.url || hostOf(u) === 'openvibe.media' || hostOf(n.url) !== 'openvibe.media', `${u}: not rewritten to openvibe.media (${JSON.stringify(n)})`);
}
// ── 3. The booted server ─────────────────────────────────────────