openvibers/openvibe.live Commits
Avatars: keep the copied avatar object, not the paste screenshot (#637)
Changes
@@@ -227,9 +227,17 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, rconst 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);@@@ -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' });@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -227,9 +227,17 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -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);
const screenshotUrl = media.publicUrl(paste.screenshot_url) || media.pasteScreenshotUrl(paste.slug);
await db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
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) {
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
console.error('[Auth] Avatar upload error:', err.message);
console.error('[Auth] Avatar upload error:', err.message);
@@ -84,8 +84,13 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
@@ -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' });
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;
const avatarUrl = media.publicUrl(paste.screenshot_url) || paste.screenshot_url;
await db.updateUserAvatar(req.user.id, avatarUrl, null);
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) {
} catch (err) {
console.warn('[Pastes → Community] set-avatar:', err.message);
console.warn('[Pastes → Community] set-avatar:', err.message);
res.status(500).json({ error: 'Failed to set avatar' });
res.status(500).json({ error: 'Failed to set avatar' });
@@ -99,13 +99,19 @@ function reportLinkedAccount(user) { _reportLinkedAccount(user).catch(err => con
@@ -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.
* sign-in report above (which only fills an empty picture), this one replaces whatever the Network had.
*/
*/
async function _reportAvatarChange(user) {
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);
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
* Push the same notification to many LIVE users (translated, deduped, chunked to the
@@ -150,4 +156,4 @@ async function _markNotificationsRead(userId, type, urlPattern) {
@@ -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)); }
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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); });