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
Changes
@@@ -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"},@@@ -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 {@@@ -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;@@@ -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);@@@ -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);nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -272,6 +272,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -272,6 +272,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<!-- Profile Section -->
<!-- Profile Section -->
<div class="section active" id="sec-profile">
<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">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-user"></i></span> Edit Profile</h3>
<h3><span class="icon"><i class="fa-solid fa-user"></i></span> Edit Profile</h3>
<form id="profile-form">
<form id="profile-form">
@@ -1001,6 +1012,28 @@ function renderProfile(user) {
@@ -1001,6 +1012,28 @@ function renderProfile(user) {
}
}
// Profile form
// 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) => {
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault();
e.preventDefault();
try {
try {
@@ -385,7 +385,12 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -385,7 +385,12 @@ router.put('/profile', requireAuth, (req, res) => {
updates.push('display_name = ?'); params.push(dn || uname);
updates.push('display_name = ?'); params.push(dn || uname);
}
}
if (bio !== undefined) { updates.push('bio = ?'); params.push(bio.slice(0, 500)); }
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;
let emailChanged = false;
if (email !== undefined) {
if (email !== undefined) {
const next = (email || '').trim() || null;
const next = (email || '').trim() || null;
@@ -426,6 +426,12 @@ app.get('/api/catalog.json', async (req, res) => {
@@ -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);
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).
// Shared chrome: analytics-ranked navigation + footer copy for every site (server/chrome).
const chromeService = require('./chrome/service').createChromeService(db, config, analytics);
const chromeService = require('./chrome/service').createChromeService(db, config, analytics);
app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
app.use('/api/chrome', rateLimit({ windowMs: 60_000, max: 240 }), chromeService.router);
@@ -126,17 +126,25 @@ router.post('/link-account', (req, res) => {
@@ -126,17 +126,25 @@ router.post('/link-account', (req, res) => {
try {
try {
const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
if (me) {
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); }
} catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }
res.json({ success: true });
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 ──────────────────────────────────────
// ── Get Linked Accounts ──────────────────────────────────────
router.get('/users/:id/linked-accounts', (req, res) => {
router.get('/users/:id/linked-accounts', (req, res) => {
const db = getDb(req);
const db = getDb(req);
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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); });