openvibers/openvibe.community Commits

Feed: LIVE only while the channel is live now (#309)

OP OpenVibers committed 4 days ago · a6356e51f4 · 3 files, +38 −5

The feed's first day showed LIVE on streams from three days ago. Each live card now carries live_now, true only while its
channel is on Live's live list (GET /api/streams, cached a minute); the menu's Feed draws LIVE from it and folds past
go-lives into one card.

Changes

ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
iindex f4caaf2..b6f6b9b 100644
---- a/server/pulse/feed.js
++++ b/server/pulse/feed.js
@@@ -8,7 +8,7 @@
*
* GET /?kind=live|clip|video|pic|post|meme|game|update&cursor=<opaque>&limit= anyone (the signed-in person too)
* → { items: [{ id, kind, url, title, text, at, site, site_name, actor: { name, avatar } | null,
-- * media: { thumb, ratio, length } | null, counts: {} }], next_cursor }
++ * media: { thumb, ratio, length } | null, counts: {}, live_now? }], next_cursor } live_now: on Live's live list now
*
* Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
*/
@@@ -80,12 +80,31 @@ function createTrending({ networkUrl, fetchImpl }) {
};
}
--function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', fetchImpl = globalThis.fetch }) {
++/** Who is live on Live right now (GET /api/streams, cached a minute): a "went live" card says LIVE only for them. */
++function createLiveNow({ liveUrl, fetchImpl }) {
++ let cache = new Set(); let at = 0;
++ return async function liveNow() {
++ if (Date.now() - at < 60_000) return cache;
++ at = Date.now();
++ try {
++ const r = await fetchImpl(`${liveUrl}/api/streams`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(3000) });
++ const j = r.ok ? await r.json() : null;
++ cache = new Set(((j && j.streams) || []).map((x) => String((x && x.username) || '').toLowerCase()).filter(Boolean));
++ } catch { /* keep the last answer */ }
++ return cache;
++ };
++}
++const channelOf = (url) => { try { const u = new URL(url); return /(^|\.)openvibe\.live$/.test(u.hostname) ? (/^\/@?([A-Za-z0-9_.-]{1,40})\/?$/.exec(u.pathname) || [])[1] || '' : ''; } catch { return ''; } };
++
++function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', liveUrl = 'https://openvibe.live', fetchImpl = globalThis.fetch }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(limits.reads('community.feed.read'));
const trending = createTrending({ networkUrl, fetchImpl });
++ const liveNow = createLiveNow({ liveUrl, fetchImpl });
++ // A "went live" item is live now only while that channel is on Live's live list.
++ const mark = (cards, live) => cards.forEach((c) => { if (c.kind === 'live') c.live_now = live.has(channelOf(c.url).toLowerCase()); });
router.get('/', run(async (req) => {
const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
@@@ -109,13 +128,15 @@ function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.
let k = 0;
items.forEach((x, n) => { woven.push(x); if ((n + 1) % 3 === 0 && k < t.length) woven.push(t[k++]); });
while (woven.length < want && k < t.length) woven.push(t[k++]);
++ mark(woven, await liveNow());
return { items: woven, next_cursor: next };
}
}
++ mark(items, await liveNow());
return { items, next_cursor: next };
}));
router.use((req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov }));
return router;
}
--module.exports = { createFeedApi, kindOf, card };
++module.exports = { createFeedApi, kindOf, card, channelOf };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex d5dd328..277ece3 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -15,7 +15,8 @@ const FRAME = { cards: [
] },
{ site: 'news', title: 'Top stories', items: [{ title: 'Not a kind the feed shows', url: 'https://openvibe.news/a', image: { src: 'https://openvibe.media/t/n.webp' } }] },
] };
--const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json: async () => FRAME });
++const LIVE = { streams: [{ username: 'ada' }] };
++const feedFetch = async (url) => ({ ok: /\/api\/(frame|streams)/.test(String(url)), json: async () => (/\/api\/streams/.test(String(url)) ? LIVE : FRAME) });
(async () => {
const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });
@@@ -53,6 +54,17 @@ const feedFetch = async (url) => ({ ok: /\/api\/frame/.test(String(url)), json:
assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
});
++ await check('LIVE only for a channel that is live now', async () => {
++ const mk = (id, who) => t.get('/api/v1/pulse/items', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${svc}`, 'x-ov-subject': alex.subject_id },
++ body: JSON.stringify({ ref: { service: 'live', type: 'stream', id }, title: `${who} went live: a stream`, url: `https://openvibe.live/@${who}` }) });
++ assert.strictEqual((await mk('s1', 'ada')).status, 201);
++ assert.strictEqual((await mk('s2', 'bo')).status, 201);
++ const j = (await t.get('/api/v1/feed?kind=live')).json();
++ const by = Object.fromEntries(j.items.map((x) => [x.url, x.live_now]));
++ assert.strictEqual(by['https://openvibe.live/@ada'], true, 'ada is on the live list');
++ assert.strictEqual(by['https://openvibe.live/@bo'], false, 'bo went live earlier and is not live now');
++ });
++
await check('a kind filters the stream', async () => {
const j = (await t.get('/api/v1/feed?kind=clip')).json();
assert.ok(j.items.length >= 1 && j.items.every((x) => x.kind === 'clip'), JSON.stringify(j.items.map((x) => x.kind)));
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 73914db..6b73ae1 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -35,7 +35,7 @@ const INVENTORY = {
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],
'server/observability.js': [1, '/api/ready probes of the configured Media health URL'],
'server/pulse/consumer.js': [2, 'Events subscriptions API (EVENTS_URL) with a service token'],
-- 'server/pulse/feed.js': [1, 'Network /api/frame at the configured networkUrl (trending cards), no user input in the URL'],
++ 'server/pulse/feed.js': [2, 'Network /api/frame at networkUrl (trending cards) and Live /api/streams at liveUrl (who is live now); no user input in either URL'],
'server/pulse/service.js': [1, 'Network /api/v1/me/follows at the configured networkInternalUrl, with the viewer token and a bounded cursor'],
'server/relay/discord-gateway.js': [1, 'DISCORD_GATEWAY_URL (owner) or the resume_gateway_url Discord\'s READY names'],
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],