openvibers/openvibe.network Commits
Network: /avatar/:username?s= asks openvibe.media for a sized WebP of an object picture (#202)
Changes
@@@ -9,7 +9,9 @@// 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.++// Works in any <img> on any site with no API call. With ?s, a media++// object picture is asked of openvibe.media at about that width as WebP++// (Media's /o/:id?w=&fmt=, Fabric F4): a 32 px avatar is under 1 KB.// 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.// ═══════════════════════════════════════════════════════════════@@@ -35,6 +37,21 @@ function normalizeAvatar(input) {return { ingest: u.toString() };}++/**++ * The picture's address for a requested size: an openvibe.media object (/o/med_…, no query of its own) gets++ * ?w=<2 × s>&fmt=webp (s is the size it is shown at; twice that stays sharp on a 2x screen), and Media answers a WebP++ * of about that width (it snaps to its ladder and never enlarges). Any other address, or a size outside 16–1024, is++ * the picture as stored.++ */++function sizedAvatar(url, s) {++ const n = Number(s);++ if (!Number.isInteger(n) || n < 16 || n > 1024) return url;++ let u; try { u = new URL(url); } catch { return url; }++ if (u.hostname !== MEDIA_HOST || !/^\/o\/med_[0-9A-Za-z]{10,40}$/.test(u.pathname) || u.search) return url;++ u.search = `?w=${n * 2}&fmt=webp`;++ return u.toString();++}++/** The address must answer with an image (redirects to object storage are followed). */async function verifyImage(url, fetchImpl = fetch) {try {@@@ -120,7 +137,7 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,const row = await 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).url;-- if (ok) return res.redirect(302, row.avatar_url);++ if (ok) return res.redirect(302, sizedAvatar(row.avatar_url, req.query.s));res.type('image/svg+xml').send(initialSvg(row ? row.username : name, req.query.s));});@@@ -45,6 +45,12 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https: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');++ // A size asks openvibe.media for a WebP of about that width, for a media object only.++ r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'a file picture is not resized');++ await svc.apply(1, 'Goosely', `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'network');++ r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN?w=192&fmt=webp`, 'an object picture at twice the size it is shown');++ r = await fetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'no size, the picture as stored');++ r = await fetch(b + '/avatar/goosely?s=9000', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'a size out of range is ignored');console.log('avatar: all checks passed'); srv.close();});})().catch(e => { console.error(e); process.exit(1); });
@@ -9,7 +9,9 @@
@@ -9,7 +9,9 @@
// PUT /api/profile/avatar { source } a paste link, a paste slug, or an https://openvibe.media/… image
// 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
// DELETE /api/profile/avatar back to the generated initial
// GET /avatar/:username[?s=96] public, cacheable: 302 to the picture, or an SVG 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.
+// Works in any <img> on any site with no API call. With ?s, a media
+// object picture is asked of openvibe.media at about that width as WebP
+// (Media's /o/:id?w=&fmt=, Fabric F4): a 32 px avatar is under 1 KB.
// Sync: every change is pushed to the sites that keep their own user row (Live), and Live pushes changes
// 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.
// made there to POST /internal/user-avatar — so the last change wins everywhere.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -35,6 +37,21 @@ function normalizeAvatar(input) {
@@ -35,6 +37,21 @@ function normalizeAvatar(input) {
return { ingest: u.toString() };
return { ingest: u.toString() };
}
}
+/**
+ * The picture's address for a requested size: an openvibe.media object (/o/med_…, no query of its own) gets
+ * ?w=<2 × s>&fmt=webp (s is the size it is shown at; twice that stays sharp on a 2x screen), and Media answers a WebP
+ * of about that width (it snaps to its ladder and never enlarges). Any other address, or a size outside 16–1024, is
+ * the picture as stored.
+ */
+function sizedAvatar(url, s) {
+ const n = Number(s);
+ if (!Number.isInteger(n) || n < 16 || n > 1024) return url;
+ let u; try { u = new URL(url); } catch { return url; }
+ if (u.hostname !== MEDIA_HOST || !/^\/o\/med_[0-9A-Za-z]{10,40}$/.test(u.pathname) || u.search) return url;
+ u.search = `?w=${n * 2}&fmt=webp`;
+ return 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). */
async function verifyImage(url, fetchImpl = fetch) {
async function verifyImage(url, fetchImpl = fetch) {
try {
try {
@@ -120,7 +137,7 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
@@ -120,7 +137,7 @@ function createAvatarService({ db, config, requireAuth, selfToken = () => null,
const row = await byName.get(name);
const row = await 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).url;
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, sizedAvatar(row.avatar_url, req.query.s));
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));
});
});
@@ -45,6 +45,12 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
@@ -45,6 +45,12 @@ for (const bad of ['http://openvibe.media/x.png', 'javascript:alert(1)', 'https:
assert.equal(r.headers.get('cross-origin-resource-policy'), 'cross-origin', 'usable in an <img> on any site');
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/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');
r = await fetch(b + '/avatar/legacy', { redirect: 'manual' }); assert.equal(r.status, 200, 'a stored off-network picture is never redirected to');
+ // A size asks openvibe.media for a WebP of about that width, for a media object only.
+ r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/f/b.png`, 'a file picture is not resized');
+ await svc.apply(1, 'Goosely', `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'network');
+ r = await fetch(b + '/avatar/goosely?s=96', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN?w=192&fmt=webp`, 'an object picture at twice the size it is shown');
+ r = await fetch(b + '/avatar/goosely', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'no size, the picture as stored');
+ r = await fetch(b + '/avatar/goosely?s=9000', { redirect: 'manual' }); assert.equal(r.headers.get('location'), `${M}/o/med_01KZK5GRQ088Y3CW1TMEF4Z4RN`, 'a size out of range is ignored');
console.log('avatar: all checks passed'); srv.close();
console.log('avatar: all checks passed'); srv.close();
});
});
})().catch(e => { console.error(e); process.exit(1); });
})().catch(e => { console.error(e); process.exit(1); });