openvibers/openvibe.live Commits

Live: count openvibe.video's views with a beacon; allow Media's shield host for VOD files (#106)

OP OpenVibers committed 12 Oct 2026 · dc706b2fa9 · 4 files, +209 −3

This PR makes two changes for recordings watched on openvibe.video and on Live.

**Views from openvibe.video**
- Live counts a view as one `content_views` row per item and address, written by the detail read.
- openvibe.video reads that API over loopback, so every viewer there looked like the same address. Its watch page now sends one beacon from the viewer's browser when playback starts (MediaHub#32), to the new `POST /api/views/vod/:id` or `/api/views/clip/:id` (`server/media-proxy/view-beacon.js`).
- What the endpoint does:
  - answers only `https://openvibe.video`'s origin; other origins and no origin get 403;
  - reads no cookie and is never credentialed. It is mounted before the credentialed CORS allowlist, as WHIP is;
  - counts only a public or unlisted, finished item, using Live's own visibility rule. That rule is remembered for a minute, and nothing is remembered while Media is unreachable;
  - is limited to 60 a minute per address;
  - answers 204 whether or not it counted, so it reveals nothing about the item.
- Documented in `docs/vods-and-clips.md` ("Views").

**CSP: Media's shield host**
- Media answers a file on B2 with a 302 to its origin shield, `edge.openvibe.media`. CSP checks every redirect hop, and Live's `media-src` did not list that host.
- So the file path (`/api/vods/:id/file` → openvibe.media → edge) was refused for B2 recordings. That covers every recording without an HLS timeline, and the fallback when a playlist fails.
- `media-src` now lists it. Headless Chrome reported exactly this violation on openvibe.video, which had the same gap (fixed in MediaHub#32).

**Tests:** `test/view-beacon.test.js`. It covers:
- public, unlisted and clip views counted once per address;
- private, recording, missing and malformed ids not counted;
- Media down: nothing counted and nothing remembered;
- origin refusals, and the preflight without credentials.

The full suite passes: 164 of 167 files, with 2 skipped as usual. `deploy-wrapper.test.js` failed once under the suite's load and passes on its own; it does not touch anything changed here.

Changes

ddiff --git a/docs/vods-and-clips.md b/docs/vods-and-clips.md
iindex 460faff9..571d84d6 100644
---- a/docs/vods-and-clips.md
++++ b/docs/vods-and-clips.md
@@@ -58,6 +58,15 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
- **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load
++## Views
++
++A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`)
++adds it. openvibe.video reads those over loopback, so its watch page counts its viewers with a beacon instead:
++`POST /api/views/vod/:id` or `/api/views/clip/:id` (`server/media-proxy/view-beacon.js`), sent from the viewer's browser
++when playback starts. It answers only `https://openvibe.video`'s origin, reads no cookie, counts only a public or
++unlisted finished item (Live's visibility rule, remembered for a minute), is limited to 60 a minute per address, and
++answers 204 whether or not it counted.
++
## Who sees a private VOD or clip
A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments (including a comment's replies, edit and delete), thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link.
ddiff --git a/server/index.js b/server/index.js
iindex b141d959..469229c3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -373,11 +373,12 @@ app.use(helmet({
// https://cloudflareinsights.com is where Cloudflare Web Analytics' beacon (script-src static.cloudflareinsights.com) reports.
connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.events", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", "https://cloudflareinsights.com", whipConnectSrc],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
-- // itself redirect to presigned B2/R2 object-store URLs — all must be allowed
-- // or the browser blocks the media element.
++ // itself redirect to presigned B2/R2 object-store URLs or, for a B2 copy, to its
++ // origin shield edge.openvibe.media — all must be allowed or the browser blocks
++ // the media element (CSP checks every redirect hop).
// data: — the mod TTS voice preview plays a data:audio/… URL; without it the browser
// rejects the element ("no supported source") even though the backend returned audio.
-- mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
++ mediaSrc: ["'self'", "blob:", "data:", "https://openvibe.media", "https://edge.openvibe.media", "https://s3.us-west-004.backblazeb2.com", "https://*.backblazeb2.com", "https://*.r2.cloudflarestorage.com"],
frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],
workerSrc: ["'self'", "blob:"],
scriptSrcAttr: ["'unsafe-inline'"],
@@@ -413,6 +414,9 @@ function isAllowedOrigin(origin) {
// that is what lets a static, backend-less site publish from the browser (docs/whip.md).
// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
app.use('/whip', whipHandler.whipCors);
++// Views counted from openvibe.video's watch pages (a beacon from the viewer's browser): its own narrow
++// origin rule and no cookie, so it sits before the credentialed allowlist too.
++app.use('/api/views', require('./media-proxy/view-beacon').createViewBeacon({ rateLimit }));
const allowlistedCors = cors({
origin(origin, callback) {
ddiff --git a/server/media-proxy/view-beacon.js b/server/media-proxy/view-beacon.js
nnew file mode 100644
iindex 00000000..8eaaca84
---- /dev/null
++++ b/server/media-proxy/view-beacon.js
@@@ -0,0 +1,86 @@
++'use strict';
++
++/**
++ * POST /api/views/:kind/:id — a view of a recording or clip played on another OpenVibe site.
++ *
++ * openvibe.video plays Live's recordings and clips but reads Live's API over loopback, so Live's own
++ * view count (one content_views row per address, written by the detail read) saw one address for all
++ * of its viewers. Its watch page now calls this with navigator.sendBeacon when playback starts, from
++ * the viewer's own browser, so the address counted is the viewer's.
++ *
++ * It is mounted before the credentialed CORS allowlist and is not part of it: it reads no cookie,
++ * answers only the origins in VIEW_ORIGINS, changes nothing but content_views, and answers 204
++ * whether or not a row was added, so it tells a caller nothing about the item. A missing, private or
++ * unfinished item is not counted.
++ */
++const express = require('express');
++const db = require('../db/database');
++const media = require('../media-client');
++const access = require('./access');
++
++const VIEW_ORIGINS = new Set(['https://openvibe.video']);
++const VISIBLE_TTL_MS = 60_000;
++const VISIBLE_MAX = 2000;
++const visible = new Map(); // 'vod:12' → { at, ok }
++
++function requesterIp(req) {
++ return req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip || 'unknown';
++}
++
++/** Whether a signed-out viewer may see it: Live's own rule (./access), remembered for a minute. */
++async function isVisible(kind, id) {
++ const key = `${kind}:${id}`;
++ const hit = visible.get(key);
++ if (hit && Date.now() - hit.at < VISIBLE_TTL_MS) return hit.ok;
++ let ok = false;
++ try {
++ const item = kind === 'vod' ? await media.getVod(id) : await media.getClip(id);
++ ok = !!item && !item.is_recording && await access.canView(null, item);
++ } catch (err) {
++ if (!(err && err.name === 'MediaApiError' && err.status === 404)) return false; // Media down: count nothing, remember nothing
++ }
++ visible.delete(key);
++ visible.set(key, { at: Date.now(), ok });
++ if (visible.size > VISIBLE_MAX) visible.delete(visible.keys().next().value);
++ return ok;
++}
++
++function createViewBeacon({ rateLimit }) {
++ const router = express.Router();
++ const limiter = rateLimit({
++ windowMs: 60 * 1000,
++ max: 60,
++ standardHeaders: false,
++ legacyHeaders: false,
++ handler: (req, res) => res.status(204).end(),
++ });
++
++ router.use((req, res, next) => {
++ const origin = req.get('origin');
++ if (!origin || !VIEW_ORIGINS.has(origin)) return res.status(403).json({ error: 'Origin not allowed' });
++ res.set('Access-Control-Allow-Origin', origin);
++ res.set('Vary', 'Origin');
++ res.set('Cache-Control', 'no-store');
++ if (req.method === 'OPTIONS') {
++ res.set('Access-Control-Allow-Methods', 'POST');
++ res.set('Access-Control-Max-Age', '600');
++ return res.status(204).end();
++ }
++ return next();
++ });
++
++ router.post('/:kind/:id', limiter, async (req, res) => {
++ const { kind, id } = req.params;
++ if ((kind !== 'vod' && kind !== 'clip') || !/^[1-9]\d{0,15}$/.test(id)) return res.status(204).end();
++ try {
++ if (await isVisible(kind, id)) {
++ await db.run('INSERT INTO content_views (content_type, content_id, ip) VALUES (?, ?, ?) ON CONFLICT DO NOTHING', [kind, Number(id), requesterIp(req)]);
++ }
++ } catch { /* a view that is not counted is not an error */ }
++ res.status(204).end();
++ });
++
++ return router;
++}
++
++module.exports = { createViewBeacon, VIEW_ORIGINS, _internals: { visible } };
ddiff --git a/test/view-beacon.test.js b/test/view-beacon.test.js
nnew file mode 100644
iindex 00000000..688535c6
---- /dev/null
++++ b/test/view-beacon.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++// POST /api/views/:kind/:id (server/media-proxy/view-beacon.js): openvibe.video's watch page counts a view from the
++// viewer's browser. Only openvibe.video's origin is answered; a public or unlisted finished item gets one
++// content_views row per address; a private, missing or still-recording item, a bad id and Media being down count
++// nothing; every counted or uncounted beacon answers 204 the same way.
++const assert = require('assert');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++
++const rows = [];
++const items = {
++ 'vod:1': { id: 1, visibility: 'public' },
++ 'vod:2': { id: 2, visibility: 'private', user_id: 9 },
++ 'vod:3': { id: 3, visibility: 'unlisted' },
++ 'vod:4': { id: 4, visibility: 'public', is_recording: true },
++ 'clip:7': { id: 7, visibility: 'public' },
++};
++let mediaDown = false;
++let mediaCalls = 0;
++class MediaApiError extends Error { constructor(status) { super('media'); this.name = 'MediaApiError'; this.status = status; } }
++const lookup = (kind) => async (id) => {
++ mediaCalls++;
++ if (mediaDown) throw new Error('ECONNREFUSED');
++ const it = items[`${kind}:${id}`];
++ if (!it) throw new MediaApiError(404);
++ return it;
++};
++const stub = (rel, exports) => { const f = require.resolve(path.join(__dirname, '..', 'server', rel)); require.cache[f] = { id: f, filename: f, loaded: true, exports }; };
++stub('db/database', {
++ run: async (sql, params) => {
++ assert.match(sql, /INSERT INTO content_views .* ON CONFLICT DO NOTHING/);
++ const key = params.join('|');
++ if (rows.includes(key)) return { changes: 0 };
++ rows.push(key);
++ return { changes: 1 };
++ },
++ getStreamById: async () => null,
++});
++stub('media-client', { getVod: lookup('vod'), getClip: lookup('clip') });
++
++const { createViewBeacon, _internals } = require('../server/media-proxy/view-beacon');
++const rateLimit = require('express-rate-limit');
++
++(async () => {
++ const app = express();
++ app.set('trust proxy', 'loopback');
++ app.use('/api/views', createViewBeacon({ rateLimit }));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, { origin = 'https://openvibe.video', ip = '203.0.113.5', method = 'POST' } = {}) => fetch(base + p, {
++ method, headers: { ...(origin ? { Origin: origin } : {}), 'X-Forwarded-For': ip },
++ });
++
++ try {
++ let r = await post('/api/views/vod/1');
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.video');
++ assert.deepStrictEqual(rows, ['vod|1|203.0.113.5'], 'a public VOD is counted for the viewer\'s address');
++
++ r = await post('/api/views/vod/1');
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(rows.length, 1, 'the same address counts once');
++ assert.strictEqual(mediaCalls, 1, 'visibility is remembered for a minute');
++
++ await post('/api/views/vod/1', { ip: '198.51.100.7' });
++ assert.strictEqual(rows.length, 2, 'another address counts');
++
++ assert.strictEqual((await post('/api/views/vod/3')).status, 204);
++ assert.ok(rows.includes('vod|3|203.0.113.5'), 'unlisted counts');
++ assert.strictEqual((await post('/api/views/clip/7')).status, 204);
++ assert.ok(rows.includes('clip|7|203.0.113.5'), 'a clip counts');
++
++ const before = rows.length;
++ for (const p of ['/api/views/vod/2', '/api/views/vod/4', '/api/views/vod/99', '/api/views/vod/0', '/api/views/vod/abc', '/api/views/live/1']) {
++ r = await post(p);
++ assert.strictEqual(r.status, 204, `${p} answers 204`);
++ }
++ assert.strictEqual(rows.length, before, 'private, recording, missing and malformed items are not counted');
++
++ mediaDown = true;
++ _internals.visible.clear();
++ r = await post('/api/views/vod/1', { ip: '192.0.2.50' });
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(rows.length, before, 'Media down counts nothing');
++ mediaDown = false;
++ await post('/api/views/vod/1', { ip: '192.0.2.50' });
++ assert.strictEqual(rows.length, before + 1, 'and nothing was remembered while it was down');
++
++ r = await post('/api/views/vod/1', { origin: 'https://evil.example' });
++ assert.strictEqual(r.status, 403, 'another origin is refused');
++ r = await post('/api/views/vod/1', { origin: null });
++ assert.strictEqual(r.status, 403, 'no origin is refused');
++ r = await post('/api/views/vod/1', { origin: 'https://openvibe.live' });
++ assert.strictEqual(r.status, 403, 'Live itself counts on its detail read, not here');
++
++ r = await post('/api/views/vod/1', { method: 'OPTIONS' });
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(r.headers.get('access-control-allow-methods'), 'POST');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), null, 'never credentialed');
++
++ console.log('view-beacon: ok');
++ } finally {
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });