openvibers/openvibe.network Commits

Network: /avatar/:username?s= asks openvibe.media for a sized WebP of an object picture (#202)

OP OpenVibers committed 29 days ago · f6846deaf8 · 2 files, +25 −2

**`/avatar/:username?s=` asks openvibe.media for a sized WebP of an object picture (Media's `/o/:id?w=&fmt=`, Fabric F4).**

**Why:** Community's posts and comments load `/avatar/<name>?s=44` and Network's profiles `?s=160`, but the size was used only for the generated initial. A stored picture was sent at full size. One sampled avatar is a 162 × 152 PNG of 18.6 KB, and as a 96 px WebP it is 606 bytes. Media's variants (Media#118, with the object route following) resize public images on request and cache them.

**Change (`server/profile/avatar.js`):** `sizedAvatar(url, s)`.
- **Object pictures:** an `https://openvibe.media/o/med_…` picture with no query of its own redirects to `?w=<2 × s>&fmt=webp`. `s` is the size the picture is shown at, and twice that stays sharp on a 2x screen. Media snaps to its ladder and never enlarges.
- **Unchanged:** any other picture address (a `/f/` file, a paste screenshot), no `s`, or an `s` outside 16–1024 redirects to the picture as stored.

Until Media serves object variants it ignores the query and answers the original, so the deploy order does not matter.

**Test (`avatar.test.js`):** a file picture is not resized; an object picture with `s=96` redirects to `?w=192&fmt=webp`; with no size, or a size out of range, the address is unchanged.

Changes

ddiff --git a/server/profile/avatar.js b/server/profile/avatar.js
iindex f641422f..bfec4498 100644
---- a/server/profile/avatar.js
++++ b/server/profile/avatar.js
@@@ -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));
});
ddiff --git a/test/avatar.test.js b/test/avatar.test.js
iindex ccc75d90..7965ea6e 100644
---- a/test/avatar.test.js
++++ b/test/avatar.test.js
@@@ -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); });