openvibers/openvibe.network Commits
Avatars: every picture is copied into the person's own avatar object (#603)
Changes
@@@ -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.mediaSource 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)nnew file mode 100644@@@ -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); });++}@@@ -134,7 +134,8 @@ router.post('/user-avatar', principals.guard('network.avatar.write'), async (reqconst 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@@@ -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 };@@@ -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); });nnew file mode 100644@@@ -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); });@@@ -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 ─────────────────────────────────────────
@@ -180,23 +180,32 @@ shows their navbar page-view count, labelled as such.
@@ -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
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.
`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
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 |
| 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 |
| 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
`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
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)
## 13. Navbar for app-style sites (what Live uses)
new file mode 100644
new file mode 100644
@@ -0,0 +1,245 @@
@@ -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); });
+}
@@ -134,7 +134,8 @@ router.post('/user-avatar', principals.guard('network.avatar.write'), async (req
@@ -134,7 +134,8 @@ router.post('/user-avatar', principals.guard('network.avatar.write'), async (req
const svc = req.app.locals.avatarService;
const svc = req.app.locals.avatarService;
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
const r = await svc.fromSite(req.body || {});
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
// Service URLs only (no secrets). Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the
@@ -2,11 +2,14 @@
@@ -2,11 +2,14 @@
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// The avatar — one picture per account, for every OpenVibe site.
// 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
// DELETE /api/profile/avatar back to the generated initial
// GET /avatar/:username[?s=96] public, cacheable: 302 to the picture, or an SVG 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
// Works in any <img> on any site with no API call. With ?s, a media
@@ -21,20 +24,23 @@ const MEDIA_HOST = 'openvibe.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 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}$/;
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) {
function normalizeAvatar(input) {
const raw = String(input == null ? '' : input).trim();
const raw = String(input == null ? '' : input).trim();
if (!raw) return { url: null };
if (!raw) return { url: null };
if (raw.length > 500) return { error: 'That link is too long' };
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 };
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' };
if (u.protocol !== 'https:' || u.username || u.password) return { error: 'Avatars must be https addresses' };
const host = u.hostname.toLowerCase();
const host = u.hostname.toLowerCase();
const m = /^\/p\/([A-Za-z0-9][A-Za-z0-9_-]{1,80})(?:\/(?:screenshot|raw)?)?\/?$/.exec(u.pathname);
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 (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) {
@@ -64,6 +70,11 @@ async function verifyImage(url, fetchImpl = fetch) {
} catch { return { ok: false, error: 'openvibe.media did not answer in time; try again' }; }
} 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 initialSvg = (name, size) => {
const s = Math.max(16, Math.min(512, parseInt(size, 10) || 96));
const s = Math.max(16, Math.min(512, parseInt(size, 10) || 96));
const ch = (String(name || '?').match(/[A-Za-z0-9]/) || ['?'])[0].toUpperCase();
const ch = (String(name || '?').match(/[A-Za-z0-9]/) || ['?'])[0].toUpperCase();
@@ -90,30 +101,49 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -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(() => {});
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) {
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');
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' };
if (!Object.keys(auth).length) return { ok: false, status: 503, error: 'Picture import is not configured on this server' };
try {
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).
// 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(() => ({}));
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' };
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
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' }; }
} 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;
const before = (await getUrl.get(userId) || {}).avatar_url || null;
if (before === url) return false;
if (before === url) return false;
await setUrl.run(url, userId);
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 */ }
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;
return true;
}
}
@@ -121,35 +151,50 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -121,35 +151,50 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
api.put('/', requireAuth, async (req, res) => {
api.put('/', requireAuth, async (req, res) => {
const n = normalizeAvatar(req.body && (req.body.source ?? req.body.avatar_url ?? req.body.url));
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.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 });
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();
const pub = express.Router();
pub.get('/:username', async (req, res) => {
pub.get('/:username', async (req, res) => {
const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
const row = await byName.get(name);
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' });
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));
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));
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) {
async function fromSite(body) {
const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };
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 };
module.exports = { createAvatarService, normalizeAvatar, verifyImage, initialSvg };
@@ -4,15 +4,15 @@ const express = require('express');
@@ -4,15 +4,15 @@ const express = require('express');
const { getDb } = require('../server/db/database');
const { getDb } = require('../server/db/database');
const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');
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';
const M = 'https://openvibe.media';
assert.equal(normalizeAvatar('teal-holly-41').url, `${M}/p/teal-holly-41/screenshot`, 'a paste slug');
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.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('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(`${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.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'])
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}`);
assert.ok(normalizeAvatar(bad).error, `rejected: ${bad}`);
@@ -22,35 +22,80 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
@@ -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`, img)).ok, true);
assert.equal((await verifyImage(`${M}/x.png`, html)).ok, false, 'a page is not a picture');
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();
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.
// 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 { 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 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((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 bearer = String(pushHeaders[0].Authorization || '').replace(/^Bearer /, '');
const v = require('openvibe-contracts').serviceAuth.verifyServiceToken(bearer, { publicKey, issuer: 'https://openvibe.network', audience: 'openvibe.live' });
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.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 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;
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');
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.
// 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); });
})().catch(e => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -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); });
@@ -132,7 +132,7 @@ const REVIEWED = {
@@ -132,7 +132,7 @@ const REVIEWED = {
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'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/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/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/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/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'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
@@ -238,10 +238,13 @@ function inventory(root) {
@@ -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');
assert.ok(!left.some((e) => /127\.0\.0\.1|\[::ffff|2130706433|localhost/.test(e)), 'rows with an internal address are dropped');
await listener.close();
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']) {
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);
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 ─────────────────────────────────────────
// ── 3. The booted server ─────────────────────────────────────────