openvibers/openvibe.community Commits

The Feed on every site: one stream from Pulse, with trending pictures (#307)

OP OpenVibers committed 4 days ago · 705731c7fd · 5 files, +205 −0

* The Feed on every site: GET /api/v1/feed, one stream from Pulse with trending pictures woven in

The menu's Feed (openvibe-shared ov-home.js feed()) asks Community for one stream of everything public on the network:
Pulse items as cards by kind (live, clip, video, pic, post, meme, game, update), a kind filter, cursor paging, and the
network's trending pictures and videos from Network's Frame woven into the first page (https and pictures only).
Cross-site CORS, read limits (community.feed.read).

* Feed: documented in /openapi.json; its Frame call is in the outbound inventory

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 9d86dc5..1339806 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -309,6 +309,8 @@ async function createApp(opts = {}) {
const cors = v1.cors(config.apiCorsOrigins);
app.use('/api/v1/comments', cors, createCommentsApi({ service: comments, viewers, anonWriteLimiter, resolveLimiter, limits }));
app.use('/api/v1/pulse', cors, createPulseApi({ pulse, viewers, limits }));
++ // The menu's Feed on every site: one stream from Pulse with the network's trending pictures and videos (pulse/feed.js).
++ app.use('/api/v1/feed', cors, require('./pulse/feed').createFeedApi({ pulse, viewers, limits, networkUrl: config.networkUrl, fetchImpl: opts.feedFetch || opts.fetchImpl || globalThis.fetch }));
app.use('/api/v1/me/status', cors, require('./status/api').createStatusApi({ forum, pulse, viewers, limits, db, baseUrl: config.baseUrl }));
app.use('/api/v1/profiles', cors, require('./status/api').createProfileStatusApi({ viewers, limits, db, baseUrl: config.baseUrl }));
// OpenVibe.Events → Pulse (server/pulse/consumer.js): public activity from Live, Blog, Wiki and News.
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 9f231f4..b0def82 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -316,6 +316,18 @@ route('post', '/api/v1/comments/{commentId}/votes', {
responses: { 200: json('The comment and its score'), ...errors() },
});
++// ── /api/v1/feed — the menu's Feed on every site (server/pulse/feed.js) ──────────────────────
++route('get', '/api/v1/feed', {
++ operationId: 'listFeed', summary: 'One stream of everything public on the network, as cards by kind', tags: ['pulse'], security: ANONYMOUS,
++ description: 'Pulse items as cards (live, clip, video, pic, post, meme, game, update), newest first, with the network\'s trending pictures and videos woven into the first page.',
++ parameters: [
++ query('kind', { type: 'string', enum: ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'] }),
++ query('cursor', str(), 'The next_cursor from the previous page'), query('limit', { type: 'integer', minimum: 1, maximum: 40 })],
++ responses: { 200: json('Cards and the cursor for the next page', { type: 'object', properties: { items: arrayOf({ type: 'object', properties: {
++ id: str(), kind: str(), url: str(), title: str(), text: str(), at: nullable('string'), site: str(), site_name: str(), label: str(),
++ actor: { type: ['object', 'null'] }, media: { type: ['object', 'null'] }, counts: { type: 'object' } } }), next_cursor: nullable('string') } }), ...errors([429]) },
++});
++
// ── /api/v1/pulse — the network's public activity (server/pulse/api.js) ──────────────────────
route('get', '/api/v1/pulse', {
operationId: 'listPulse', summary: 'What the network is doing; followed people first in the last hour for me', tags: ['pulse'], security: ANONYMOUS, parameters: [
ddiff --git a/server/pulse/feed.js b/server/pulse/feed.js
nnew file mode 100644
iindex 0000000..f4caaf2
---- /dev/null
++++ b/server/pulse/feed.js
@@@ -0,0 +1,121 @@
++'use strict';
++
++/**
++ * GET /api/v1/feed — the menu's Feed on every OpenVibe site (openvibe-shared ov-home.js feed(): "a real feed… you scroll
++ * through easy content… a filter system", the owner, 11-09). One stream, newest first, built from Pulse (every public
++ * thing that happens on the network: posts, pastes, statuses, threads, a stream going live, a clip, a video, a picture,
++ * a meme, an update…) with the network's trending pictures and videos (Network's Frame cards) woven into the first page.
++ *
++ * 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 }
++ *
++ * Text only (the client draws every field as text); https links only; nothing from Frame without a picture and a title.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { run } = require('../http/v1');
++
++const KINDS = ['live', 'clip', 'video', 'pic', 'post', 'meme', 'game', 'update'];
++const SITE_NAMES = { live: 'Live', 'media-hub': 'Video', video: 'Video', pics: 'Pics', meme: 'Meme', games: 'Games', community: 'Community', blog: 'Blog', wiki: 'Wiki', quest: 'Quest', chat: 'Chat', tools: 'Tools', news: 'News', report: 'Report' };
++const https = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
++const clean = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
++
++/** What a Pulse item is, as a card. */
++function kindOf(i) {
++ const s = String((i.source && i.source.service) || '');
++ const t = String((i.source && i.source.type) || '');
++ if (/^(live|stream|went_live|broadcast)$/.test(t) || (s === 'live' && /\b(went live|is live|live now)\b/i.test(i.title || ''))) return 'live';
++ if (t === 'clip' || t === 'moment') return 'clip';
++ if (/^(vod|video|recording|upload)$/.test(t) || s === 'media-hub' || s === 'video') return 'video';
++ if (/^(pic|picture|image|photo)$/.test(t) || s === 'pics') return 'pic';
++ if (s === 'meme' || t === 'meme') return 'meme';
++ if (s === 'games' || /^(game|world|level)$/.test(t)) return 'game';
++ if (s === 'blog' || /^(devblog|update|release|changelog)$/.test(t)) return 'update';
++ return 'post';
++}
++
++function card(i) {
++ const site = String((i.source && i.source.service) || 'community');
++ const a = i.actor;
++ return {
++ id: `p:${i.id}`,
++ kind: kindOf(i),
++ url: https(i.url) || i.url,
++ title: clean(i.title, 300),
++ text: '',
++ at: i.occurred_at || null,
++ site,
++ site_name: SITE_NAMES[site] || site.replace(/^app:/, ''),
++ actor: a ? { name: clean(a.display_name || a.username || '', 80) || null, avatar: https(a.avatar_url) } : null,
++ media: i.picture_url && https(i.picture_url) ? { thumb: https(i.picture_url), ratio: 16 / 9 } : null,
++ counts: {},
++ ...(i.label ? { label: i.label } : {}),
++ };
++}
++
++/** The network's trending cards with pictures (Frame), as feed items: woven into the first page, cached five minutes. */
++function createTrending({ networkUrl, fetchImpl }) {
++ let cache = null; let at = 0;
++ const SITE_KIND = { 'media-hub': 'video', video: 'video', live: 'live', pics: 'pic', meme: 'meme', games: 'game' };
++ return async function trending() {
++ if (cache && Date.now() - at < 5 * 60_000) return cache;
++ try {
++ const r = await fetchImpl(`${networkUrl}/api/frame?v=2`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(4000) });
++ const f = r.ok ? await r.json() : null;
++ const out = [];
++ for (const c of (f && Array.isArray(f.cards) ? f.cards : [])) {
++ const kind = SITE_KIND[c && c.site];
++ if (!kind || !Array.isArray(c.items)) continue;
++ for (const x of c.items.slice(0, 4)) {
++ const url = https(x && x.url); const thumb = https(x && x.image && x.image.src);
++ if (!url || !thumb || !x.title) continue;
++ out.push({ id: `t:${c.site}:${url}`, kind, url, title: clean(x.title, 200), text: clean(x.meta, 160), at: null, site: c.site,
++ site_name: SITE_NAMES[c.site] || c.site, actor: null, media: { thumb, ratio: 16 / 9 }, counts: {}, label: 'Trending' });
++ }
++ }
++ cache = out; at = Date.now();
++ } catch { cache = cache || []; }
++ return cache;
++ };
++}
++
++function createFeedApi({ pulse, viewers, limits, networkUrl = 'https://openvibe.network', 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 });
++
++ router.get('/', run(async (req) => {
++ const kind = KINDS.includes(String(req.query.kind || '')) ? String(req.query.kind) : '';
++ const want = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 40);
++ const items = [];
++ let cursor = req.query.cursor ? String(req.query.cursor) : '';
++ let next = null;
++ // A kind filters Pulse's pages until the page is full (at most four pages read per call).
++ for (let pages = 0; pages < 4 && items.length < want; pages++) {
++ const page = await pulse.list({ after: cursor || undefined, limit: 40 }, req.viewer);
++ for (const i of page.items || []) { const c = card(i); if (!kind || c.kind === kind) items.push(c); if (items.length >= want) break; }
++ next = page.next_cursor || null;
++ if (!next) break;
++ cursor = next;
++ }
++ // The first page carries the network's trending pictures and videos, one after every three items.
++ if (!req.query.cursor) {
++ const t = (await trending()).filter((x) => !kind || x.kind === kind);
++ if (t.length) {
++ const woven = [];
++ 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++]);
++ return { items: woven, next_cursor: next };
++ }
++ }
++ 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 };
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
nnew file mode 100644
iindex 0000000..d5dd328
---- /dev/null
++++ b/test/feed-api.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * GET /api/v1/feed (the menu's Feed on every site): one stream from Pulse, newest first, as cards by kind (a paste or a
++ * thread is a post, a Live clip a clip), a kind filter, the network's trending pictures woven into the first page (from
++ * Network's Frame, https and pictures only), and the shape openvibe-shared's ov-home.js feed() draws.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++const FRAME = { cards: [
++ { site: 'media-hub', title: 'Trending', items: [
++ { title: 'A trending video', url: 'https://openvibe.video/v/1', meta: '1.2k views', image: { src: 'https://openvibe.media/t/1.webp' } },
++ { title: 'No picture', url: 'https://openvibe.video/v/2' },
++ { title: 'Not https', url: 'http://openvibe.video/v/3', image: { src: 'https://openvibe.media/t/3.webp' } },
++ ] },
++ { 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 });
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: { feedFetch } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
++ const pastes = t.app.locals.pastes;
++ const svc = net.signService({ cap: ['community.pulse.write'] });
++
++ await pastes.createText(asAlex, { title: 'Public one', content: 'x' });
++ const clip = await 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: 'clip', id: 'c1' }, title: 'Great clip', url: 'https://openvibe.live/clip/c1' }) });
++ assert.strictEqual(clip.status, 201, clip.text);
++
++ await check('the feed: newest first, cards by kind, the shape the menu draws', async () => {
++ const r = await t.get('/api/v1/feed');
++ assert.strictEqual(r.status, 200, r.text);
++ const j = r.json();
++ const kinds = j.items.map((x) => x.kind);
++ assert.ok(kinds.includes('clip') && kinds.includes('post'), `a clip and a post (${kinds})`);
++ const c = j.items.find((x) => x.kind === 'clip');
++ assert.strictEqual(c.title, 'Great clip');
++ assert.strictEqual(c.site, 'live');
++ assert.strictEqual(c.site_name, 'Live');
++ assert.strictEqual(c.actor.name, 'Alex');
++ assert.ok('next_cursor' in j);
++ for (const x of j.items) for (const k of ['id', 'kind', 'url', 'title', 'site', 'site_name', 'counts']) assert.ok(k in x, `${k} on every card`);
++ });
++
++ await check('trending pictures are woven in: https, with a picture, kinds the feed shows', async () => {
++ const j = (await t.get('/api/v1/feed')).json();
++ const tr = j.items.filter((x) => x.label === 'Trending');
++ assert.deepStrictEqual(tr.map((x) => x.title), ['A trending video'], 'only the https one with a picture');
++ assert.strictEqual(tr[0].kind, 'video');
++ assert.strictEqual(tr[0].media.thumb, 'https://openvibe.media/t/1.webp');
++ });
++
++ 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)));
++ const none = (await t.get('/api/v1/feed?kind=meme')).json();
++ assert.deepStrictEqual(none.items, []);
++ });
++
++ await check('cross-site: answers any OpenVibe site (CORS)', async () => {
++ const r = await t.get('/api/v1/feed', { headers: { origin: 'https://openvibe.video' } });
++ assert.strictEqual(r.status, 200);
++ });
++
++ await done(t);
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 8a220a8..73914db 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -35,6 +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/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'],