openvibers/openvibe.play Commits

Expose public person achievement feed (#215)

OP OpenVibers committed 2 days ago · be7036c9e9 · 4 files, +56 −2

Changes

ddiff --git a/server/app.js b/server/app.js
iindex ae6290e..7cf70cc 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -162,7 +162,7 @@ async function createApp(opts = {}) {
// Public reads from any site: the titles, their leaderboards and a person's achievements answer every origin,
// without credentials (a game's own page draws its board with them; any site shows a profile's showcase). Everything else keeps the same-site resource policy and no CORS. A preflight
// is answered here.
-- const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements)$/;
++ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements|achievements\/feed)$/;
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 2613988..6866944 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -9,6 +9,7 @@
* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
* GET /people/:subject/achievements public a person's unlocks across every title (their profile:
* OpenVibe.Network's /@name and any site's showcase)
++ * GET /achievements/feed public recent person unlocks, cursor paginated
* GET /me/achievements a person their own unlocks across titles
* PUT /titles/:title play.title.manage register or update a title the caller manages
* DELETE /titles/:title play.title.manage delete a sandbox title the caller manages (204)
@@@ -65,6 +66,27 @@ function createApi(ctx) {
return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));
}));
++ r.get('/achievements/feed', reads, wrap(async (req, res) => {
++ const limit = req.query.limit === undefined ? 50 : Number(req.query.limit);
++ if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new PlayError(400, 'play.bad_limit', 'limit is 1 to 100');
++ let before = null;
++ if (req.query.cursor !== undefined) {
++ const encoded = String(req.query.cursor);
++ try {
++ if (!/^[A-Za-z0-9_-]{1,500}$/.test(encoded)) throw new Error('bad cursor');
++ const raw = Buffer.from(encoded, 'base64url');
++ if (raw.toString('base64url') !== encoded) throw new Error('bad cursor');
++ before = JSON.parse(raw.toString('utf8'));
++ if (!Array.isArray(before) || before.length !== 4 || !Number.isSafeInteger(before[0]) || before[0] < 0
++ || !/^[a-z0-9-]{2,40}$/.test(before[1]) || !/^[a-z0-9_-]{1,64}$/.test(before[2])
++ || !/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(before[3])) throw new Error('bad cursor');
++ } catch { throw new PlayError(400, 'play.bad_cursor', 'invalid feed cursor'); }
++ }
++ const out = await play.achievementFeed({ before, limit });
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json({ items: out.items, next: out.next ? Buffer.from(JSON.stringify(out.next)).toString('base64url') : null });
++ }));
++
// A person's unlocks, for their public profile (Steam-style: what you earned shows wherever you are). Only people
// (usr_…): a guest's rows are a game's own until it adopts them (404). Someone with no unlocks yet gets an empty list,
// the same answer as a subject Play has never seen, so the read never says whether an account exists.
ddiff --git a/server/play/store.js b/server/play/store.js
iindex d3bd2e3..ca2dfb8 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -300,6 +300,21 @@ function createPlay({ s }) {
return [...by.values()];
}
++ /** Public person unlocks across every production title, newest first. Guest and sandbox rows stay private. */
++ async function achievementFeed({ before = null, limit = 50 } = {}) {
++ const rows = await db.many(`SELECT u.player, u.title_id, t.name AS title_name, u.achievement_id, a.name, a.description, u.unlocked_at
++ FROM play_unlocks u JOIN play_titles t ON t.id = u.title_id
++ JOIN play_achievements a ON a.title_id = u.title_id AND a.id = u.achievement_id
++ WHERE u.player ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND t.id NOT LIKE 'sandbox-%'
++ AND ($1::bigint IS NULL OR (u.unlocked_at, u.title_id, u.achievement_id, u.player) < ($1::bigint, $2::text, $3::text, $4::text))
++ ORDER BY u.unlocked_at DESC, u.title_id DESC, u.achievement_id DESC, u.player DESC LIMIT $5`,
++ [before ? before[0] : null, before ? before[1] : null, before ? before[2] : null, before ? before[3] : null, limit + 1]);
++ const next = rows.length > limit ? rows[limit - 1] : null;
++ return { items: rows.slice(0, limit).map((r) => ({ player: r.player, title: r.title_id, title_name: r.title_name,
++ id: r.achievement_id, name: r.name, description: r.description, unlocked_at: new Date(Number(r.unlocked_at)).toISOString() })),
++ next: next ? [Number(next.unlocked_at), next.title_id, next.achievement_id, next.player] : null };
++ }
++
// ── cloud saves ──
const slotOf = (v) => { if (!SLOT_RE.test(String(v || ''))) fail(400, 'play.bad_slot', 'a slot is 1 to 32 of a-z 0-9 _ - (starting with a letter or digit)'); return String(v); };
const saveView = (r, withData) => ({ slot: r.slot, version: Number(r.version), size: Number(r.size), created_at: new Date(Number(r.created_at)).toISOString(), updated_at: new Date(Number(r.updated_at)).toISOString(), ...(withData ? { data: r.data } : {}) });
@@@ -353,7 +368,7 @@ function createPlay({ s }) {
return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;
}
-- return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle,
++ return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, achievementFeed, ownTitle,
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
ddiff --git a/test/play-api.test.js b/test/play-api.test.js
iindex d10bdb4..085d816 100644
---- a/test/play-api.test.js
++++ b/test/play-api.test.js
@@@ -142,6 +142,23 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
});
++ await check('the public achievement feed pages across people and excludes guests', async () => {
++ const guest = 'guest:feedguest123';
++ await t.get('/api/v1/titles/salvage-rush/unlocks', { ...games, json: { player: guest, achievements: ['first-haul'] } });
++ const first = await t.get('/api/v1/achievements/feed?limit=1', { headers: { Origin: 'https://openvibe.community' } });
++ assert.strictEqual(first.status, 200, first.text);
++ assert.strictEqual(first.headers.get('access-control-allow-origin'), '*');
++ assert.strictEqual(first.json().items.length, 1);
++ assert.ok(first.json().items[0].player.startsWith('usr_'));
++ const second = await t.get(`/api/v1/achievements/feed?limit=1&cursor=${encodeURIComponent(first.json().next)}`);
++ assert.strictEqual(second.status, 200, second.text);
++ assert.notDeepStrictEqual(second.json().items[0], first.json().items[0]);
++ const full = (await t.get('/api/v1/achievements/feed?limit=100')).json();
++ assert.ok(full.items.length >= 3);
++ assert.ok(full.items.every((i) => i.player.startsWith('usr_') && i.title === 'salvage-rush'));
++ assert.strictEqual((await t.get('/api/v1/achievements/feed?cursor=bad')).json().code, 'play.bad_cursor');
++ });
++
await check('the player pages moved to OpenVibe.Games; llms.txt says where and keeps the API links', async () => {
for (const [from, to] of [
['/', 'https://openvibe.games/achievements'],