openvibers/openvibe.live Commits

Avatars: keep the copied avatar object, not the paste screenshot (#637)

OP OpenVibers committed 6 days ago · 9c37fcbf6c · 4 files, +83 −11

An avatar upload or a paste set as the picture used to leave Live's own users
row pointing at another product's screenshot, which a cleanup could delete.
The Network now copies the picture into the person's own avatar object and
answers its address; the two avatar routes await that answer and store it.
When the Network is unreachable or refuses, the source stays in place and the
route still succeeds, so nothing breaks until the next report.

Test: test/avatar-object-sync.test.js (the copied address comes back and is
what the site stores, an unlinked account makes no call, a refusal or an
unreachable Network leaves the source and never throws).

Changes

ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex 22b14fa6..d981b8f3 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -227,9 +227,17 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
const screenshotUrl = media.publicUrl(paste.screenshot_url) || media.pasteScreenshotUrl(paste.slug);
await db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
-- const updated = await db.getUserById(req.user.id);
-- try { require('../utils/notify').reportAvatarChange(updated); } catch { /* the Network learns the new picture on the next sign-in */ }
-- res.json({ user: await sanitizeUser(updated), avatar_url: screenshotUrl });
++ // The Network copies the picture into the person's own avatar object (the paste stays as the source, and
++ // can be cleaned up without breaking the avatar); Live then keeps that object's address.
++ let view = await db.getUserById(req.user.id);
++ try {
++ const synced = await require('../utils/notify').syncAvatarChange(view);
++ if (synced && synced.avatar_url && synced.avatar_url !== view.avatar_url) {
++ await db.updateUserAvatar(req.user.id, synced.avatar_url, paste.id || null);
++ view = await db.getUserById(req.user.id);
++ }
++ } catch { /* the Network copies it on the next report; the paste still works until then */ }
++ res.json({ user: await sanitizeUser(view), avatar_url: view.avatar_url || screenshotUrl });
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
console.error('[Auth] Avatar upload error:', err.message);
ddiff --git a/server/media-proxy/pastes.js b/server/media-proxy/pastes.js
iindex 249e4044..c3751704 100644
---- a/server/media-proxy/pastes.js
++++ b/server/media-proxy/pastes.js
@@@ -84,8 +84,13 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
if (paste.type !== 'screenshot' || !paste.screenshot_url) return res.status(400).json({ error: 'That paste is not an image' });
const avatarUrl = media.publicUrl(paste.screenshot_url) || paste.screenshot_url;
await db.updateUserAvatar(req.user.id, avatarUrl, null);
-- try { require('../utils/notify').reportAvatarChange({ id: req.user.id, avatar_url: avatarUrl }); } catch { /* next sign-in */ }
-- res.json({ success: true, avatar_url: avatarUrl });
++ // The Network copies the screenshot into the person's own avatar object; the paste stays untouched.
++ let stored = avatarUrl;
++ try {
++ const synced = await require('../utils/notify').syncAvatarChange({ id: req.user.id, avatar_url: avatarUrl });
++ if (synced && synced.avatar_url) { stored = synced.avatar_url; await db.updateUserAvatar(req.user.id, stored, null); }
++ } catch { /* next report */ }
++ res.json({ success: true, avatar_url: stored });
} catch (err) {
console.warn('[Pastes → Community] set-avatar:', err.message);
res.status(500).json({ error: 'Failed to set avatar' });
ddiff --git a/server/utils/notify.js b/server/utils/notify.js
iindex 03af6486..b03ef51a 100644
---- a/server/utils/notify.js
++++ b/server/utils/notify.js
@@@ -99,13 +99,19 @@ function reportLinkedAccount(user) { _reportLinkedAccount(user).catch(err => con
* sign-in report above (which only fills an empty picture), this one replaces whatever the Network had.
*/
async function _reportAvatarChange(user) {
-- if (!user?.id || !require('../net/network-principal').configured()) return;
++ if (!user?.id || !require('../net/network-principal').configured()) return null;
const networkId = await toNetworkId(user.id);
-- if (!networkId) return;
-- _post('/internal/user-avatar', { user_id: networkId, avatar_url: user.avatar_url || null, origin: 'live' })
-- .then(r => { if (r && !r.ok) console.warn(`[Notify] avatar sync refused (${r.status})`); }).catch(() => {});
++ if (!networkId) return null;
++ const r = await _post('/internal/user-avatar', { user_id: networkId, avatar_url: user.avatar_url || null, origin: 'live' });
++ if (!r) return null;
++ if (!r.ok) { console.warn(`[Notify] avatar sync refused (${r.status})`); return null; }
++ // Network copies the picture into the person's own avatar object and answers the address to store: Live keeps
++ // that object, never the source (a paste screenshot another product may clean up).
++ try { return await r.json(); } catch { return null; }
}
--function reportAvatarChange(user) { _reportAvatarChange(user).catch(err => console.warn('[Notify] Avatar error:', err.message)); }
++/** Awaited form: how the avatar routes learn the copied address. → { avatar_url } or null. */
++function syncAvatarChange(user) { return _reportAvatarChange(user).catch(err => { console.warn('[Notify] Avatar error:', err.message); return null; }); }
++function reportAvatarChange(user) { syncAvatarChange(user).catch(() => {}); }
/**
* Push the same notification to many LIVE users (translated, deduped, chunked to the
@@@ -150,4 +156,4 @@ async function _markNotificationsRead(userId, type, urlPattern) {
}
function markNotificationsRead(userId, type, urlPattern) { _markNotificationsRead(userId, type, urlPattern).catch(err => console.warn('[Notify] Mark-read error:', err.message)); }
--module.exports = { reportAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL };
++module.exports = { reportAvatarChange, syncAvatarChange, pushNotification, pushBulkNotification, actorInfo, markNotificationsRead, reportLinkedAccount, toNetworkId, toNetworkIds, OV_NETWORK_INTERNAL_URL };
ddiff --git a/test/avatar-object-sync.test.js b/test/avatar-object-sync.test.js
nnew file mode 100644
iindex 00000000..0c7e0122
---- /dev/null
++++ b/test/avatar-object-sync.test.js
@@@ -0,0 +1,53 @@
++'use strict';
++// A picture set on Live becomes the person's own avatar object on openvibe.media (incident 2026-11-06): the
++// Network copies it and answers the address to store, and Live keeps that object rather than the paste screenshot
++// it was made from. An unlinked account makes no call; a refused or unreachable Network leaves the source in place
++// and never throws through the fire-and-forget form.
++const assert = require('assert');
++
++process.env.NODE_ENV = 'test';
++
++const db = require('../server/db/database');
++const principal = require('../server/net/network-principal');
++principal.configured = () => true;
++principal.headersFor = async () => ({ Authorization: 'Bearer test' });
++principal.tokenRejected = () => {};
++
++const notify = require('../server/utils/notify');
++
++(async () => {
++ const COPY = 'https://openvibe.media/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN';
++ const SOURCE = 'https://openvibe.media/p/paste-1/screenshot';
++ await db.run("INSERT INTO users (id, username, password_hash) OVERRIDING SYSTEM VALUE VALUES (7, 'Ada', 'x'), (8, 'Unlinked', 'x')");
++ await db.run("INSERT INTO linked_accounts (user_id, service, service_user_id, linked_at) VALUES (7, 'network', '513', ov_now())");
++
++ const realFetch = global.fetch;
++ try {
++ const seen = [];
++ global.fetch = async (u, o) => {
++ seen.push({ url: String(u), body: JSON.parse(o.body) });
++ return { ok: true, status: 200, json: async () => ({ ok: true, changed: true, avatar_url: COPY }) };
++ };
++
++ const out = await notify.syncAvatarChange({ id: 7, avatar_url: SOURCE });
++ assert.deepStrictEqual(out, { ok: true, changed: true, avatar_url: COPY }, 'the copied address comes back to the caller');
++ assert.strictEqual(seen.length, 1);
++ assert.ok(seen[0].url.endsWith('/internal/user-avatar'), 'the avatar is reported to the Network');
++ assert.deepStrictEqual(seen[0].body, { user_id: 513, avatar_url: SOURCE, origin: 'live' }, 'Live sends its own row\'s picture, naming the origin');
++
++ assert.strictEqual(await notify.syncAvatarChange({ id: 8, avatar_url: SOURCE }), null, 'an account with no Network link makes no call');
++ assert.strictEqual(seen.length, 1);
++
++ global.fetch = async () => ({ ok: false, status: 422, json: async () => ({ error: 'refused' }) });
++ assert.strictEqual(await notify.syncAvatarChange({ id: 7, avatar_url: SOURCE }), null, 'a refused report leaves the source in place');
++
++ global.fetch = async () => { throw new Error('network down'); };
++ assert.strictEqual(await notify.syncAvatarChange({ id: 7, avatar_url: SOURCE }), null, 'an unreachable Network is not an error for the route');
++ notify.reportAvatarChange({ id: 7, avatar_url: SOURCE });
++ await new Promise((r) => setTimeout(r, 20));
++
++ console.log('avatar object sync: all checks passed');
++ } finally {
++ global.fetch = realFetch;
++ }
++})().catch((err) => { console.error(err); process.exit(1); });