openvibers/openvibe.live Commits
Live: count openvibe.video's views with a beacon; allow Media's shield host for VOD files (#106)
Changes
@@@ -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 clipA 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.@@@ -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) {nnew file mode 100644@@@ -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 } };nnew file mode 100644@@@ -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); });
@@ -58,6 +58,15 @@ From the dashboard:
@@ -58,6 +58,15 @@ From the dashboard:
(`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once
(`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
- **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
## 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.
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.
@@ -373,11 +373,12 @@ app.use(helmet({
@@ -373,11 +373,12 @@ app.use(helmet({
// https://cloudflareinsights.com is where Cloudflare Web Analytics' beacon (script-src static.cloudflareinsights.com) reports.
// 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],
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
// 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
// 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.
// 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],
frameSrc: ["'self'", "https://openvibe.network", "https://www.youtube.com", "https://www.youtube-nocookie.com", "https://player.vimeo.com", ...botFrameSrc],
workerSrc: ["'self'", "blob:"],
workerSrc: ["'self'", "blob:"],
scriptSrcAttr: ["'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
@@ -413,6 +414,9 @@ function isAllowedOrigin(origin) {
@@ -413,6 +414,9 @@ function isAllowedOrigin(origin) {
// that is what lets a static, backend-less site publish from the browser (docs/whip.md).
// 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.
// It must therefore bypass the credentialed allowlist below, which would 403 the preflight.
app.use('/whip', whipHandler.whipCors);
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({
const allowlistedCors = cors({
origin(origin, callback) {
origin(origin, callback) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -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 } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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); });