openvibers/openvibe.network Commits
Avatars: links elsewhere on the web are imported to openvibe.media, never stored; the account page explains it
Changes
@@@ -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>@@@ -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;@@@ -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')) };}@@@ -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 () => {
@@ -274,10 +274,10 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -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="section active" id="sec-profile">
<div class="card" id="avatar-card">
<div class="card" id="avatar-card">
<h3><span class="icon"><i class="fa-solid fa-image-portrait"></i></span> Avatar</h3>
<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">
<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)">
<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 btn-primary" id="avatar-save">Use this picture</button>
<button type="button" class="btn" id="avatar-clear">Remove</button>
<button type="button" class="btn" id="avatar-clear">Remove</button>
</div>
</div>
@@ -388,7 +388,7 @@ router.put('/profile', requireAuth, (req, res) => {
@@ -388,7 +388,7 @@ router.put('/profile', requireAuth, (req, res) => {
if (avatar_url !== undefined) {
if (avatar_url !== undefined) {
// Same rule as PUT /api/profile/avatar: only pictures on openvibe.media (or a paste, resolved to its screenshot).
// 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);
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);
updates.push('avatar_url = ?'); params.push(n.url);
}
}
let emailChanged = false;
let emailChanged = false;
@@ -31,7 +31,8 @@ function normalizeAvatar(input) {
@@ -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);
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 (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() }; }
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). */
/** The address must answer with an image (redirects to object storage are followed). */
@@ -66,6 +67,21 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
@@ -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(() => {});
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. */
/** 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) {
function apply(userId, username, url, origin) {
const before = (getUrl.get(userId) || {}).avatar_url || null;
const before = (getUrl.get(userId) || {}).avatar_url || null;
@@ -80,7 +96,11 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
@@ -80,7 +96,11 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
api.put('/', requireAuth, async (req, res) => {
api.put('/', requireAuth, async (req, res) => {
const n = normalizeAvatar(req.body && (req.body.source ?? req.body.avatar_url ?? req.body.url));
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.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');
apply(req.user.id, req.user.username, n.url, 'network');
res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
res.json({ ok: true, avatar_url: n.url, paste: n.paste || null });
});
});
@@ -91,7 +111,7 @@ function createAvatarService({ db, config, requireAuth, log = console }) {
@@ -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 name = String(req.params.username || '').replace(/\.(png|jpg|svg)$/i, '').slice(0, 64);
const row = byName.get(name);
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' });
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);
if (ok) return res.redirect(302, row.avatar_url);
res.type('image/svg+xml').send(initialSvg(row ? row.username : name, req.query.s));
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 }) {
@@ -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). */
/** Live changed someone's picture: adopt it (same rules, no verification round trip for our own media host). */
function fromSite(body) {
function fromSite(body) {
const id = parseInt(body && body.user_id, 10); if (!id) return { status: 400, error: 'user_id required' };
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' };
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 { status: 200, changed: apply(id, u.username, n.url, String(body.origin || 'live')) };
}
}
@@ -10,7 +10,9 @@ assert.equal(normalizeAvatar('https://openvibe.community/p/teal-holly-41').url,
@@ -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('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(`${M}/f/screenshots/avatar-1.png`).url, `${M}/f/screenshots/avatar-1.png`, 'a media file');
assert.equal(normalizeAvatar('').url, null, 'empty clears it');
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}`);
assert.ok(normalizeAvatar(bad).error, `rejected: ${bad}`);
(async () => {
(async () => {