openvibers/openvibe.network Commits

Profiles show your games and achievements (OpenVibe.Play) beside your items and badges, on the page and in the JSON every site reads: one Steam-style profile wherever you play (#333)

OP OpenVibers committed 19 days ago · d8e216bb58 · 4 files, +70 −11

Changes

ddiff --git a/server/index.js b/server/index.js
iindex ed128c4a..330c6a8f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -657,7 +657,7 @@ app.use('/avatar', rateLimit({ windowMs: 60_000, max: 600 }), avatarService.pub)
app.locals.avatarService = avatarService;
// Public profiles (plan T21 step 3): openvibe.network/@<username> and GET /api/v1/profiles/:username (CORS-open,
// server/public-cors.js), with the items from OpenVibe.Inventory (server/profile/public.js).
--const publicProfiles = require('./profile/public').createPublicProfiles({ db, selfToken, inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980' });
++const publicProfiles = require('./profile/public').createPublicProfiles({ db, selfToken, inventoryUrl: process.env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030', questUrl: process.env.OV_QUEST_INTERNAL_URL || 'http://127.0.0.1:4980', playUrl: process.env.OV_PLAY_INTERNAL_URL || 'http://127.0.0.1:5040' });
app.use('/api/v1/profiles', rateLimit({ windowMs: 60_000, max: 240 }), publicProfiles.api);
app.get('/@:username', rateLimit({ windowMs: 60_000, max: 120 }), publicProfiles.pageHandler);
ddiff --git a/server/profile/public.js b/server/profile/public.js
iindex 96a9ae45..05aabb84 100644
---- a/server/profile/public.js
++++ b/server/profile/public.js
@@@ -8,7 +8,9 @@
// Items come from OpenVibe.Inventory's public reads (GET /api/v1/kinds, /people/:subject/items and /equipped) with the
// Network's own service token (svc:network, audience openvibe.inventory), kept 60 s per person, so a busy profile costs
// Inventory one read a minute. Inventory down: the profile still answers, with its items marked unavailable.
--// Badges come the same way from OpenVibe.Quest (GET /api/v1/profiles/:subject/badges, audience openvibe.quest).
++// Badges come the same way from OpenVibe.Quest (GET /api/v1/profiles/:subject/badges, audience openvibe.quest), and
++// achievements from OpenVibe.Play's public read (GET /api/v1/people/:subject/achievements): every game's unlocks, so a
++// profile is the person's Steam-style page wherever they play.
//
// Who has one: a real account (not anonymous, banned, merged into another or deleted); anyone else is a 404. An old
// username answers 301 to the current one. A person can hide theirs (users.profile_public = 0, the account hub's Edit
@@@ -23,6 +25,8 @@ const esc = seo.esc;
const SITE = 'https://openvibe.network';
const INVENTORY_SITE = 'https://inventory.openvibe.network';
const QUEST_SITE = 'https://openvibe.quest';
++const PLAY_SITE = 'https://play.openvibe.network';
++const RECENT_ACHIEVEMENTS = 6; // per game on the page; the rest is on the game's page on OpenVibe.Play
const AUDIENCE = 'openvibe.inventory';
const ITEMS_TTL_MS = 60_000;
const KINDS_TTL_MS = 10 * 60_000;
@@@ -47,10 +51,11 @@ function monthOf(ts) {
return m && MONTHS[Number(m[2]) - 1] ? `${MONTHS[Number(m[2]) - 1]} ${m[1]}` : null;
}
--function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', questUrl = 'http://127.0.0.1:4980', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
++function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http://127.0.0.1:5030', questUrl = 'http://127.0.0.1:4980', playUrl = 'http://127.0.0.1:5040', fetchImpl = (...a) => fetch(...a), now = () => Date.now(), log = console } = {}) {
const base = String(inventoryUrl).replace(/\/+$/, '');
const itemsCache = new Map(); // subject → { at, value }
const badgesCache = new Map(); // subject → { at, value }
++ const achievementsCache = new Map(); // subject → { at, value }
let kindsHit = null;
let lastWarn = '';
@@@ -101,10 +106,39 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
return value;
}
-- /** What a public profile shows from other services: its items (Inventory) and its badges (Quest), read together. */
++ /** A person's achievements in every game (Play), newest game first: { total, games: [{ title, name, count, recent }] }. */
++ async function achievementsOf(subject) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return { total: 0, games: [] };
++ const hit = achievementsCache.get(subject);
++ if (hit && now() - hit.at < ITEMS_TTL_MS) return hit.value;
++ let value;
++ try {
++ const r = await fetchImpl(`${String(playUrl).replace(/\/+$/, '')}/api/v1/people/${subject}/achievements`, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ if (r.status === 404) value = { total: 0, games: [] };
++ else {
++ if (!r.ok) throw new Error(`answered ${r.status}`);
++ const body = await r.json();
++ const games = (Array.isArray(body.titles) ? body.titles : []).filter((t) => t && typeof t.title === 'string' && Array.isArray(t.achievements)).map((t) => ({
++ title: t.title.slice(0, 64), name: String(t.name || t.title).slice(0, 80), count: t.achievements.length,
++ url: `${PLAY_SITE}/t/${encodeURIComponent(t.title.slice(0, 64))}`,
++ recent: t.achievements.slice(0, RECENT_ACHIEVEMENTS).filter((a) => a && typeof a.name === 'string').map((a) => ({ name: a.name.slice(0, 80), description: String(a.description || '').slice(0, 200), unlocked_at: typeof a.unlocked_at === 'string' ? a.unlocked_at : null })),
++ }));
++ value = { total: games.reduce((n, g) => n + g.count, 0), games };
++ }
++ } catch (err) {
++ const m = `OpenVibe.Play: ${err.message}`;
++ if (m !== lastWarn) { lastWarn = m; log.warn(`[Profiles] ${m}`); }
++ return { total: 0, games: [], unavailable: true };
++ }
++ achievementsCache.set(subject, { at: now(), value });
++ if (achievementsCache.size > 2000) achievementsCache.delete(achievementsCache.keys().next().value);
++ return value;
++ }
++
++ /** What a public profile shows from other services: items (Inventory), badges (Quest), achievements (Play), together. */
async function wearsOf(subject) {
-- const [inv, quest] = await Promise.all([itemsOf(subject), badgesOf(subject)]);
-- return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable };
++ const [inv, quest, play] = await Promise.all([itemsOf(subject), badgesOf(subject), achievementsOf(subject)]);
++ return { ...inv, badges: quest.badges, badgesUnavailable: !!quest.unavailable, achievements: play };
}
/** A person's items and what they wear, or { unavailable: true } while Inventory cannot answer. */
@@@ -184,6 +218,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
inventory_url: SUBJECT_RE.test(String(u.subject_id || '')) ? `${INVENTORY_SITE}/u/${u.subject_id}` : null,
badges: inv.badges,
...(inv.badgesUnavailable ? { badges_unavailable: true } : {}),
++ achievements: { total: inv.achievements.total, games: inv.achievements.games, ...(inv.achievements.unavailable ? { unavailable: true } : {}) },
});
}
@@@ -212,7 +247,7 @@ function createPublicProfiles({ db, selfToken = () => null, inventoryUrl = 'http
} catch (err) { next(err); }
});
-- return { pageHandler, api, find, profileOf, itemsOf, badgesOf };
++ return { pageHandler, api, find, profileOf, itemsOf, badgesOf, achievementsOf };
}
// ── The page ───────────────────────────────────────────────────
@@@ -241,6 +276,15 @@ function badgesHtml(inv) {
<ul class="pf-badges">${inv.badges.map((b) => `<li><a class="pf-badge" href="${QUEST_SITE}/" title="${esc(b.awarded_at ? `Earned ${monthOf(b.awarded_at) || ''} on OpenVibe.Quest` : 'Earned on OpenVibe.Quest')}"><span class="pf-badge-ic" aria-hidden="true">🏅</span><span>${esc(b.name)}</span>${b.awarded_at && monthOf(b.awarded_at) ? `<small>${esc(monthOf(b.awarded_at))}</small>` : ''}</a></li>`).join('')}</ul></section>`;
}
++/** Games: each game the person played with its achievements (newest first), Steam-style; links to the game on OpenVibe.Play. */
++function gamesHtml(inv) {
++ const a = inv.achievements;
++ if (!a || !a.games.length) return '';
++ return `<section class="pf-section" aria-labelledby="pf-games"><h2 id="pf-games">Games <span class="pf-count">${a.games.length}</span></h2>
++<ul class="pf-games">${a.games.map((g) => `<li class="pf-game"><a class="pf-game-head" href="${esc(g.url)}"><span class="pf-game-name">${esc(g.name)}</span><span class="pf-game-n">${esc(plural(g.count, 'achievement', 'achievements'))}</span></a>
++<ul class="pf-ach">${g.recent.map((x) => `<li title="${esc(x.description || x.name)}"><span aria-hidden="true">🏆</span> ${esc(x.name)}${x.unlocked_at && monthOf(x.unlocked_at) ? ` <small>${esc(monthOf(x.unlocked_at))}</small>` : ''}</li>`).join('')}</ul></li>`).join('')}</ul></section>`;
++}
++
function itemsHtml(inv, u) {
const name = esc(u.display_name || u.username);
if (inv.unavailable) return `<section class="pf-section" aria-labelledby="pf-items"><h2 id="pf-items">Items</h2><p class="pf-empty">The items could not be loaded just now. They are safe on <a href="${INVENTORY_SITE}/">OpenVibe.Inventory</a>; try again in a minute.</p></section>`;
@@@ -284,6 +328,7 @@ function renderPage(u, inv) {
const stats = isPublic ? [
since ? `<li><span class="pf-stat-k">Member since</span><span class="pf-stat-v">${esc(since)}</span></li>` : '',
inv.badges && inv.badges.length ? `<li><span class="pf-stat-k">Badges</span><span class="pf-stat-v">${inv.badges.length}</span></li>` : '',
++ inv.achievements && inv.achievements.total ? `<li><span class="pf-stat-k">Achievements</span><span class="pf-stat-v">${inv.achievements.total}</span></li>` : '',
inv.unavailable ? '' : `<li><span class="pf-stat-k">Items</span><span class="pf-stat-v">${inv.more ? `${inv.total}+` : inv.total}</span></li>`,
inv.items.length ? `<li><span class="pf-stat-k">Rarest</span><span class="pf-stat-v">${esc(RARITY_LABEL[inv.items.reduce((best, i) => (rarityRank(i.rarity) > rarityRank(best) ? i.rarity : best), 'common')])}</span></li>` : '',
].join('') : '';
@@@ -360,6 +405,12 @@ a{color:var(--accent)}
.pf-badge:hover,.pf-badge:focus-visible{border-color:var(--r-legendary)}
.pf-badge small{font-weight:500;color:var(--text-muted);font-size:.75rem}
.pf-badge-ic{font-size:1.05rem}
++.pf-games{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(min(300px,100%),1fr));gap:12px}
++.pf-game{border:1px solid var(--border);border-radius:14px;background:var(--bg-secondary);padding:12px 14px;min-width:0}
++.pf-game-head{display:flex;justify-content:space-between;gap:10px;align-items:baseline;text-decoration:none;color:inherit;margin-bottom:8px}
++.pf-game-name{font-weight:700;overflow-wrap:anywhere}.pf-game-n{font-size:.8rem;color:var(--text-muted);flex:none}
++.pf-ach{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:minmax(0,1fr);gap:4px;font-size:.88rem}
++.pf-ach li{overflow-wrap:anywhere}.pf-ach small{color:var(--text-muted);font-size:.75rem}
.pf-empty{color:var(--text-secondary);padding:18px;border:1px dashed var(--border);border-radius:12px;margin:0}
.pf-note{color:var(--text-secondary);font-size:.85rem;margin:12px 0 0}
@media (max-width:560px){.pf-head{padding:0 16px 16px;gap:14px}.pf-avatar{width:92px;height:92px}.pf-hat{font-size:32px;top:-18px}.pf-who{padding-top:0;min-width:100%}.pf-bio,.pf-stats,.pf-private{padding-left:16px;padding-right:16px}.pf-grid{grid-template-columns:repeat(auto-fill,minmax(112px,1fr))}.pf-badge small{display:none}.pf-badge{padding:6px 12px 6px 9px}}
@@@ -378,7 +429,7 @@ ${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Network', links
${bio ? `<p class="pf-bio">${esc(bio)}</p>` : ''}
${isPublic ? `<ul class="pf-stats">${stats}</ul>` : '<p class="pf-private">This profile is private.</p>'}
</section>
--${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + itemsHtml(inv, u) : ''}
++${isPublic ? showcaseHtml(inv, name) + badgesHtml(inv) + gamesHtml(inv) + itemsHtml(inv, u) : ''}
</main>
${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
<script src="/shared/navbar.js" defer></script>
ddiff --git a/test/public-profile.test.js b/test/public-profile.test.js
iindex 56556720..8199df70 100644
---- a/test/public-profile.test.js
++++ b/test/public-profile.test.js
@@@ -63,13 +63,14 @@ const INSTANCES = [
if (u.pathname === '/api/v1/kinds') return json(200, { kinds: KINDS });
if (u.pathname === `/api/v1/people/${A}/equipped`) return json(200, { subject: A, slots: { 'live.hat:hat': { instance_id: INSTANCES[0].id, definition_id: INSTANCES[0].definition_id }, 'live.name_effect:name_effect': { instance_id: INSTANCES[1].id, definition_id: INSTANCES[1].definition_id, token: 'name-fx-rainbow' }, 'network.badge:badge': { instance_id: INSTANCES[3].id, definition_id: INSTANCES[3].definition_id, media_id: 'med_01JZ00000000000000000000B1' } } });
if (u.pathname === `/api/v1/people/${A}/items`) return json(200, { subject: A, instances: INSTANCES, definitions: DEFS, next_cursor: null });
++ if (u.pathname === `/api/v1/people/${A}/achievements`) return json(200, { subject: A, total: 3, titles: [{ title: 'salvage-rush', name: 'Salvage <Rush>', achievements: [{ id: 'big-haul', name: 'Big Haul', description: 'Bring back 50 parts', unlocked_at: '2026-10-20T10:00:00Z' }, { id: 'first-haul', name: 'First Haul', description: '', unlocked_at: '2026-10-19T10:00:00Z' }] }, { title: 'vibe-heroes', name: 'Vibe Heroes', achievements: [{ id: 'hello', name: 'Hello', unlocked_at: '2026-10-01T10:00:00Z' }] }] });
if (u.pathname === `/api/v1/profiles/${A}/badges`) return json(200, { subject: A, badges: [{ id: 'first-follow', name: 'First Follow', awarded_at: '2026-10-02T10:00:00Z' }, { id: 'thread-starter', name: 'Thread <Starter>', awarded_at: '2026-10-05T10:00:00Z' }] });
return json(404, { code: 'inventory.unknown_subject' });
});
await new Promise((r) => inv.listen(0, '127.0.0.1', r));
let clock = Date.now();
const selfToken = (aud, caps) => `tok:${aud}:${caps.join(',')}`;
-- const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: `http://127.0.0.1:${inv.address().port}`, questUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });
++ const profiles = createPublicProfiles({ db, selfToken, inventoryUrl: `http://127.0.0.1:${inv.address().port}`, questUrl: `http://127.0.0.1:${inv.address().port}`, playUrl: `http://127.0.0.1:${inv.address().port}`, now: () => clock, log: { warn() {} } });
const app = express();
app.get('/@:username', profiles.pageHandler);
@@@ -96,7 +97,12 @@ const INSTANCES = [
assert.ok(r.text.includes('--pf-color:#ff0066'));
assert.ok(r.text.includes('https://inventory.openvibe.network/items/itd_01JZ00000000000000000001R1'), 'items link to their Inventory page');
assert.ok(!/name="robots" content="noindex"/.test(r.text));
-- assert.ok(calls.filter((c) => !c.path.includes('/badges')).every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
++ assert.ok(calls.filter((c) => !c.path.includes('/badges') && !c.path.endsWith('/achievements')).every((c) => c.auth === 'Bearer tok:openvibe.inventory:inventory.item.read'), 'Network\'s own token for Inventory');
++ assert.strictEqual(calls.find((c) => c.path.endsWith('/achievements')).auth, '', 'Play\'s read is public: no token');
++ assert.ok(r.text.includes('id="pf-games"') && r.text.includes('Salvage &lt;Rush&gt;') && !r.text.includes('Salvage <Rush>'), 'games, escaped');
++ assert.ok(/pf-game-n">2 achievements<[\s\S]*Big Haul[\s\S]*First Haul[\s\S]*Vibe Heroes[\s\S]*1 achievement</.test(r.text), 'each game with its achievements, newest first');
++ assert.ok(r.text.includes('href="https://play.openvibe.network/t/salvage-rush"'), 'a game links to its page on Play');
++ assert.ok(/<span class="pf-stat-k">Achievements<\/span><span class="pf-stat-v">3<\/span>/.test(r.text));
assert.strictEqual(calls.find((c) => c.path.endsWith('/badges')).auth, 'Bearer tok:openvibe.quest:quest.badge.read', 'and for Quest');
assert.ok(r.text.includes('id="pf-badges"') && /Thread &lt;Starter&gt;[\s\S]*First Follow/.test(r.text), 'badges, newest first, escaped');
// The worn badge's image rule (.85em square) once shared the chips' class and squeezed every chip into it.
@@@ -120,6 +126,7 @@ const INSTANCES = [
assert.strictEqual(p.inventory_url, `https://inventory.openvibe.network/u/${A}`);
assert.deepStrictEqual(p.items.by_kind.map((k) => k.kind).sort(), ['live.hat', 'live.name_effect', 'live.particle', 'network.badge']);
assert.deepStrictEqual(p.badges.map((b) => b.id), ['thread-starter', 'first-follow']);
++ assert.deepStrictEqual([p.achievements.total, p.achievements.games.map((g) => [g.title, g.count])], [3, [['salvage-rush', 2], ['vibe-heroes', 1]]], 'achievements in the JSON every site reads');
// A hidden profile: name and picture, nothing else, not indexed.
r = await get('/@hidden');
@@@ -150,6 +157,7 @@ const INSTANCES = [
r = await get('/api/v1/profiles/Ana');
assert.strictEqual(JSON.parse(r.text).profile.items.unavailable, true);
assert.strictEqual(JSON.parse(r.text).profile.badges_unavailable, true, 'Quest down too: said, not hidden');
++ assert.strictEqual(JSON.parse(r.text).profile.achievements.unavailable, true, 'and Play');
console.log('public profiles: all checks passed');
} finally {
srv.close();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex e3795cad..de630f54 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -123,7 +123,7 @@ const REVIEWED = {
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
-- 'server/profile/public.js fetch': [3, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030) and OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) only; the paths are fixed, the subject is a checked usr_ id'],
++ 'server/profile/public.js fetch': [4, 'OpenVibe.Inventory (OV_INVENTORY_INTERNAL_URL, loopback 5030), OpenVibe.Quest (OV_QUEST_INTERNAL_URL, loopback 4980) and OpenVibe.Play (OV_PLAY_INTERNAL_URL, loopback 5040) only; the paths are fixed, the subject is a checked usr_ id'],
'server/profile/avatar.js fetch': [3, 'Live and Media internal URLs from config; verifyImage only for https://openvibe.media/ addresses (normalizeAvatar, asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],