openvibers/openvibe.network Commits

Avatar is a network-wide property: openvibe.media pictures or paste screenshots only, verified as images, public /avatar/:username, pushed to sites and accepted from them; profile route validates avatar_url; account page avatar card

OP OpenVibers committed 18 Sept 2026 · c83969ceeb · 7 files, +210 −7


Changes

ddiff --git a/package.json b/package.json
iindex 22f25cde..c0d13df8 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js",
++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js && node test/avatar.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/my.html b/public/my.html
iindex 52ed68a2..f19a4d7a 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -272,6 +272,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<!-- Profile Section -->
<div class="section active" id="sec-profile">
++ <div class="card" id="avatar-card">
++ <h3><span class="icon"><i class="fa-solid fa-image-portrait"></i></span> Avatar</h3>
++ <p class="muted" style="margin-top:0">One picture for every OpenVibe site. It comes from <b>openvibe.media</b>: paste the link to one of your image pastes (from <a href="https://openvibe.community/my" target="_blank" rel="noopener">your pastes</a>), or the address of an image hosted there.</p>
++ <div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
++ <img id="avatar-preview" alt="" width="64" height="64" style="width:64px;height:64px;border-radius:50%;object-fit:cover;border:2px solid var(--accent)">
++ <input type="url" id="avatar-source" placeholder="https://openvibe.community/p/your-paste" style="flex:1;min-width:220px" autocomplete="off" spellcheck="false">
++ <button type="button" class="btn btn-primary" id="avatar-save">Use this picture</button>
++ <button type="button" class="btn" id="avatar-clear">Remove</button>
++ </div>
++ <div id="avatar-msg" class="muted" role="status" style="margin-top:8px;min-height:1.2em"></div>
++ </div>
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-user"></i></span> Edit Profile</h3>
<form id="profile-form">
@@@ -1001,6 +1012,28 @@ function renderProfile(user) {
}
// Profile form
++
++// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
++(function () {
++ const save = document.getElementById('avatar-save'), clear = document.getElementById('avatar-clear'), src = document.getElementById('avatar-source'), msg = document.getElementById('avatar-msg'), prev = document.getElementById('avatar-preview');
++ if (!save) return;
++ const paint = () => { if (typeof currentUser !== 'undefined' && currentUser) { prev.src = avatarSrc(currentUser, 96); prev.onerror = () => { prev.src = avatarPlaceholder(currentUser, 96); }; } };
++ const apply = async (method, body) => {
++ save.disabled = clear.disabled = true; msg.textContent = method === 'PUT' ? 'Checking the picture…' : '';
++ try {
++ const data = await apiFetch('/api/profile/avatar', { method, body: body ? JSON.stringify(body) : undefined });
++ currentUser = Object.assign({}, currentUser, { avatar_url: data.avatar_url });
++ renderHeader(currentUser); paint(); src.value = '';
++ msg.textContent = data.avatar_url ? 'Saved. Every OpenVibe site will show it within a few minutes.' : 'Removed. Sites will show your initial.';
++ } catch (err) { msg.textContent = err.message || 'That did not work'; }
++ save.disabled = clear.disabled = false;
++ };
++ save.addEventListener('click', () => { if (src.value.trim()) apply('PUT', { source: src.value.trim() }); else msg.textContent = 'Paste a link first.'; });
++ clear.addEventListener('click', () => apply('DELETE'));
++ src.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); save.click(); } });
++ document.addEventListener('ov:account-ready', paint); setTimeout(paint, 1500);
++})();
++
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex e4a497fb..d8bf84a4 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -385,7 +385,12 @@ router.put('/profile', requireAuth, (req, res) => {
updates.push('display_name = ?'); params.push(dn || uname);
}
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }
-- if (avatar_url !== undefined) { updates.push('avatar_url = ?'); params.push(avatar_url); }
++ if (avatar_url !== undefined) {
++ // Same rule as PUT /api/profile/avatar: only pictures on openvibe.media (or a paste, resolved to its screenshot).
++ const n = require('../profile/avatar').normalizeAvatar(avatar_url);
++ if (n.error) return res.status(400).json({ error: n.error });
++ updates.push('avatar_url = ?'); params.push(n.url);
++ }
let emailChanged = false;
if (email !== undefined) {
const next = (email || '').trim() || null;
ddiff --git a/server/index.js b/server/index.js
iindex abfbd645..ec7c4427 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -426,6 +426,12 @@ app.get('/api/catalog.json', async (req, res) => {
res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=300, stale-while-revalidate=86400' }).json(catalog);
});
++// The avatar: one picture per account, hosted on openvibe.media, used by every site (server/profile/avatar.js).
++const avatarService = require('./profile/avatar').createAvatarService({ db, config, requireAuth });
++app.use('/api/profile/avatar', rateLimit({ windowMs: 60_000, max: 20 }), avatarService.api);
++app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub);
++app.locals.avatarService = avatarService;
++
// Shared chrome: analytics-ranked navigation + footer copy for every site (server/chrome).
const chromeService = require('./chrome/service').createChromeService(db, config, analytics);
app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 6743431e..895933bf 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -126,17 +126,25 @@ router.post('/link-account', (req, res) => {
try {
const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
if (me) {
-- let okAvatar = null;
-- try { const u = new URL(String(avatar_url || '')); if (u.protocol === 'https:' && /(^|\.)openvibe\.(live|media|network|games|community|tools)$/.test(u.hostname) && String(avatar_url).length < 500) okAvatar = u.toString(); } catch { /* not a URL */ }
-- if (okAvatar && !me.avatar_url) db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').run(okAvatar, user_id);
-- const name = String(display_name || '').replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 60);
-- if (name && (!me.display_name || me.display_name === me.username)) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);
++ const svc = req.app.locals.avatarService;
++ if (svc && avatar_url && !me.avatar_url) svc.fromSite({ user_id, avatar_url, origin: service });
++ const name = String(display_name || '').trim().slice(0, 60);
++ // Same rule the profile form enforces: a display name only re-cases the username.
++ if (name && name !== me.display_name && name.toLowerCase() === String(me.username || '').toLowerCase()) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);
}
} catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }
res.json({ success: true });
});
++// ── A site changed someone's avatar (Live's avatar picker) ───
++router.post('/user-avatar', (req, res) => {
++ const svc = req.app.locals.avatarService;
++ if (!svc) return res.status(503).json({ error: 'avatar service unavailable' });
++ const r = svc.fromSite(req.body || {});
++ res.status(r.status).json(r.error ? { error: r.error } : { ok: true, changed: r.changed });
++});
++
// ── Get Linked Accounts ──────────────────────────────────────
router.get('/users/:id/linked-accounts', (req, res) => {
const db = getDb(req);
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
nnew file mode 100644
iindex 00000000..a3b59c8c
---- /dev/null
++++ b/server/profile/avatar.js
@@@ -0,0 +1,110 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// 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.
++//
++// PUT /api/profile/avatar { source } a paste link, a paste slug, or an https://openvibe.media/… image
++// 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.
++// Sync: every change is pushed to the sites that keep their own user row (Live), and Live pushes changes
++// made there to POST /internal/user-avatar — so the last change wins everywhere.
++// ═══════════════════════════════════════════════════════════════
++const express = require('express');
++
++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 */
++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' }; }
++ 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() }; }
++ return { error: 'Avatars live on openvibe.media: use one of your pastes, or an image hosted there' };
++}
++
++/** The address must answer with an image (redirects to object storage are followed). */
++async function verifyImage(url, fetchImpl = fetch) {
++ try {
++ const r = await fetchImpl(url, { method: 'GET', redirect: 'follow', headers: { Range: 'bytes=0-0', Accept: 'image/*' }, signal: AbortSignal.timeout(6000) });
++ const type = String(r.headers.get('content-type') || '');
++ try { if (r.body && r.body.cancel) await r.body.cancel(); } catch { /* */ }
++ if (!(r.ok || r.status === 206)) return { ok: false, error: 'That picture could not be loaded' };
++ if (!/^image\/(png|jpe?g|webp|gif|avif)/i.test(type)) return { ok: false, error: 'That paste has no picture to use (it needs an image or a screenshot)' };
++ return { ok: true };
++ } catch { return { ok: false, error: 'openvibe.media did not answer in time; try again' }; }
++}
++
++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();
++ let h = 0; for (const c of String(name || '')) h = (h * 31 + c.charCodeAt(0)) % 360;
++ return `<svg xmlns="http://www.w3.org/2000/svg" width="${s}" height="${s}" viewBox="0 0 96 96"><rect width="96" height="96" fill="hsl(${h} 45% 42%)"/><text x="48" y="50" font-family="system-ui,sans-serif" font-size="46" font-weight="700" fill="#fff" text-anchor="middle" dominant-baseline="middle">${ch}</text></svg>`;
++};
++
++function createAvatarService({ db, config, requireAuth, log = console }) {
++ const getUrl = db.prepare('SELECT avatar_url FROM users WHERE id = ?');
++ const byName = db.prepare('SELECT username, avatar_url FROM users WHERE lower(username) = lower(?)');
++ const setUrl = db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?');
++
++ /** Tell the sites that keep their own copy. Fire and forget: they also pick it up at the next sign-in. */
++ function pushToSites(userId, username, url) {
++ const live = config.services && config.services.live && config.services.live.internalUrl || 'http://127.0.0.1:3000';
++ if (!config.internalKey || config.internalKey === 'change-me-in-production') return;
++ fetch(`${live.replace(/\/$/, '')}/internal/user-avatar`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Internal-Key': config.internalKey },
++ body: JSON.stringify({ openvibenetwork_id: userId, username, avatar_url: url }), signal: AbortSignal.timeout(5000) }).catch(() => {});
++ }
++
++ /** The one place an avatar changes. `origin` names who asked ('network', 'live'), so we never echo a push back. */
++ function apply(userId, username, url, origin) {
++ const before = (getUrl.get(userId) || {}).avatar_url || null;
++ if (before === url) return false;
++ setUrl.run(url, userId);
++ try { 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);
++ return true;
++ }
++
++ const api = express.Router();
++ 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.url) { const v = await verifyImage(n.url); if (!v.ok) return res.status(422).json({ error: v.error }); }
++ apply(req.user.id, req.user.username, n.url, 'network');
++ res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
++ });
++ api.delete('/', requireAuth, (req, res) => { apply(req.user.id, req.user.username, null, 'network'); res.json({ ok: true, avatar_url: null }); });
++
++ const pub = express.Router();
++ pub.get('/:username', (req, res) => {
++ const name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
++ const row = 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).error;
++ if (ok) return res.redirect(302, row.avatar_url);
++ 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). */
++ 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) return { status: 422, error: n.error };
++ const u = db.prepare('SELECT username FROM users WHERE id = ?').get(id); if (!u) return { status: 404, error: 'user not found' };
++ return { status: 200, changed: apply(id, u.username, n.url, String(body.origin || 'live')) };
++ }
++
++ return { api, pub, fromSite, apply, log };
++}
++
++module.exports = { createAvatarService, normalizeAvatar, verifyImage, initialSvg };
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
nnew file mode 100644
iindex 00000000..3a0641b9
---- /dev/null
++++ b/test/avatar.test.js
@@@ -0,0 +1,41 @@
++'use strict';
++const assert = require('assert');
++const express = require('express'); const Database = require('better-sqlite3');
++const { createAvatarService, normalizeAvatar, verifyImage } = require('../server/profile/avatar');
++
++// What counts as an avatar
++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');
++for (const bad of ['https://evil.example/p.png', 'http://openvibe.media/x.png', 'javascript:alert(1)', 'https://openvibe.media.evil.example/x.png', 'https://user:[email protected]/x.png', 'data:image/png;base64,AAAA', '../../etc/passwd'])
++ assert.ok(normalizeAvatar(bad).error, `rejected: ${bad}`);
++
++(async () => {
++ const img = async () => ({ ok: true, status: 200, headers: { get: () => 'image/png' }, body: { cancel: async () => {} } });
++ const html = async () => ({ ok: true, status: 200, headers: { get: () => 'text/html' }, body: null });
++ 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 db = new Database(':memory:');
++ db.exec("CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT, avatar_url TEXT); CREATE TABLE audit_log(id INTEGER PRIMARY KEY, user_id INT, action TEXT, details TEXT, ip TEXT); INSERT INTO users VALUES (1,'Goosely',NULL),(2,'legacy','https://evil.example/old.png')");
++ const pushes = []; const realFetch = global.fetch; global.fetch = async (u, o) => { pushes.push(JSON.parse(o.body)); return { ok: true }; };
++ const svc = createAvatarService({ db, config: { internalKey: 'k'.repeat(24), services: {} }, requireAuth: (req, _res, next) => { req.user = { id: 1, username: 'Goosely' }; next(); } });
++ assert.equal(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');
++ 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.equal(svc.fromSite({ user_id: 1, avatar_url: 'https://evil.example/x.png' }).status, 422);
++ global.fetch = realFetch;
++
++ const app = express(); app.use('/avatar', svc.pub);
++ const srv = app.listen(0, async () => {
++ 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');
++ 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');
++ console.log('avatar: all checks passed'); srv.close();
++ });
++})().catch(e => { console.error(e); process.exit(1); });