openvibers/openvibe.network Commits

Avatars: links elsewhere on the web are imported to openvibe.media, never stored; the account page explains it

OP OpenVibers committed 18 Sept 2026 · 0b3f78b3e8 · 4 files, +30 −8


Changes

ddiff --git a/public/my.html b/public/my.html
iindex f19a4d7a..702c9838 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -274,10 +274,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<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>
++ <p class="muted" style="margin-top:0">One picture for every OpenVibe site. It always lives on <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 any picture link: we fetch it once, clean it, and keep our own copy.</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">
++ <input type="url" id="avatar-source" placeholder="A paste link, or any https picture link" 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>
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex d8bf84a4..27b3dd92 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -388,7 +388,7 @@ router.put('/profile', requireAuth, (req, res) => {
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 });
++ if (n.error || n.ingest) return res.status(400).json({ error: n.error || 'Set your picture from the Avatar card (PUT /api/profile/avatar): links are imported to openvibe.media first' });
updates.push('avatar_url = ?'); params.push(n.url);
}
let emailChanged = false;
ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex a3b59c8c..21f84a19 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -31,7 +31,8 @@ function normalizeAvatar(input) {
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' };
++ // Anywhere else on the web: never stored, never served. openvibe.media fetches it once and keeps its own copy.
++ return { ingest: u.toString() };
}
/** The address must answer with an image (redirects to object storage are followed). */
@@@ -66,6 +67,21 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
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. */
++ async function ingest(url, user) {
++ const media = (config.services && config.services.media && config.services.media.internalUrl) || 'http://127.0.0.1:4100';
++ if (!config.internalKey || config.internalKey === 'change-me-in-production') 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', 'X-Internal-Key': config.internalKey },
++ body: JSON.stringify({ url, user_id: user.id, username: user.username }), 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 };
++ } 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. */
function apply(userId, username, url, origin) {
const before = (getUrl.get(userId) || {}).avatar_url || null;
@@@ -80,7 +96,11 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
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 }); }
++ if (n.ingest) {
++ const got = await ingest(n.ingest, 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 }); }
apply(req.user.id, req.user.username, n.url, 'network');
res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
});
@@@ -91,7 +111,7 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
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;
++ const ok = row && row.avatar_url && !!normalizeAvatar(row.avatar_url).url;
if (ok) return res.redirect(302, row.avatar_url);
res.type('image/svg+xml').send(initialSvg(row ? row.username : name, req.query.s));
});
@@@ -99,7 +119,7 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
/** 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 n = normalizeAvatar(body.avatar_url); if (n.error || n.ingest) return { status: 422, error: n.error || 'Sites report openvibe.media pictures only' };
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')) };
}
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
iindex 3a0641b9..8c300fb6 100644
---- a/test/avatar.test.js
++++ b/test/avatar.test.js
@@@ -10,7 +10,9 @@ assert.equal(normalizeAvatar('https://openvibe.community/p/teal-holly-41').url,
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.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');
++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}`);
(async () => {