openvibers/openvibe.live Commits
The Rundown reads chat again (after_id), and the WHIP host answers WHIP only (#324)
Changes
@@@ -417,6 +417,8 @@ function isAllowedOrigin(origin) {} catch { return false; }}++// The WHIP host answers WHIP only; its other paths go to the site (server/web/whip-host.js).++app.use(require('./web/whip-host').createWhipHostRedirect({ whipUrl: () => (config.whip?.enabled ? config.whip.publicUrl : null), siteUrl: () => config.baseUrl }));// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every 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.@@@ -191,7 +191,8 @@ async function streamChat(stream) {try {const chat = require('../chat/chat-client');for (let page = 0; page < 6; page++) {-- const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId, limit: CHAT_PAGE });++ // The first page names no after_id: Chat refuses after_id=0 (an id is positive), which left every stream unfiltered.++ const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId || undefined, limit: CHAT_PAGE });const rows = (out && out.messages) || [];for (const m of rows) {hit.afterId = Math.max(hit.afterId, Number(m.id) || 0);nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++/**++ * The WHIP ingest host (config.whip.publicUrl, whip.openvibe.live) answers WHIP only: /whip and /whip/* (and++ * /api/health), and every other path moves to the site's own address with a 301. The same process serves both++ * names, so opened in a browser it showed the whole site on a second origin: a second copy of every page, and API++ * calls the credentialed CORS allowlist refused (logged once an hour, 2026-10-23).++ *++ * app.use(createWhipHostRedirect({ whipUrl: () => …, siteUrl: () => … }))++ *++ * Both are read per request: config.whip is filled by the URL-registry refresh after the middleware is built. With++ * no WHIP host, or one that is the site's own, it does nothing.++ */++function hostOf(url) {++ try { const u = new URL(String(url)); return /^https?:$/.test(u.protocol) ? u : null; } catch { return null; }++}++++function createWhipHostRedirect({ whipUrl, siteUrl }) {++ return function whipHostRedirect(req, res, next) {++ const whip = hostOf(whipUrl());++ const site = hostOf(siteUrl());++ if (!whip || !site || whip.hostname === site.hostname || req.hostname !== whip.hostname) return next();++ if (req.path === '/whip' || req.path.startsWith('/whip/') || req.path === '/api/health') return next();++ return res.redirect(301, `${site.origin}${req.originalUrl}`);++ };++}++++module.exports = { createWhipHostRedirect };@@@ -340,7 +340,8 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /const started = Date.parse((await db.get('SELECT started_at FROM streams WHERE id = ?', [st])).started_at.replace(' ', 'T') + 'Z');const chat = require('../server/chat/chat-client');const prevRead = chat.readMessages;-- chat.readMessages = async (q) => (Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });++ // Like Chat: an after_id that is not a positive id is a 400 (the first page names none).++ chat.readMessages = async (q) => (q.after_id !== undefined && !(Number(q.after_id) > 0) ? Promise.reject(new Error('Chat answered 400 (Invalid after_id)')) : Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });desk._test.reset();await desk.tick();const sent = calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'ttsstreamer');nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++// The WHIP host answers WHIP only (server/web/whip-host.js, 2026-10-23): on whip.openvibe.live, /whip, /whip/* and++// /api/health pass; every other path is a 301 to the same path on the site. The site's own host, a missing WHIP++// host or one equal to the site's pass everything.++const assert = require('assert');++const { createWhipHostRedirect } = require('../server/web/whip-host');++++const run = (mw, hostname, originalUrl) => {++ const path = originalUrl.split('?')[0];++ let out = 'next';++ mw({ hostname, path, originalUrl }, { redirect: (code, to) => { out = `${code} ${to}`; } }, () => {});++ return out;++};++++let whip = 'https://whip.openvibe.live';++const mw = createWhipHostRedirect({ whipUrl: () => whip, siteUrl: () => 'https://openvibe.live' });++++assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip/abc123'), 'next');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip/abc123/res_1'), 'next');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip'), 'next');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/api/health'), 'next');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), '301 https://openvibe.live/');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/@kim?tab=vods'), '301 https://openvibe.live/@kim?tab=vods');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/api/streams/live'), '301 https://openvibe.live/api/streams/live');++assert.strictEqual(run(mw, 'whip.openvibe.live', '/whipped'), '301 https://openvibe.live/whipped', 'only /whip itself and below');++assert.strictEqual(run(mw, 'openvibe.live', '/'), 'next', 'the site itself is untouched');++++// The WHIP host is read per request (the URL registry fills it after boot): none, or the site's own, does nothing.++whip = null;++assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), 'next');++whip = 'https://openvibe.live';++assert.strictEqual(run(mw, 'openvibe.live', '/'), 'next');++whip = 'not a url';++assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), 'next');++++console.log('whip host: WHIP only, everything else to the site');
@@ -417,6 +417,8 @@ function isAllowedOrigin(origin) {
@@ -417,6 +417,8 @@ function isAllowedOrigin(origin) {
} catch { return false; }
} catch { return false; }
}
}
+// The WHIP host answers WHIP only; its other paths go to the site (server/web/whip-host.js).
+app.use(require('./web/whip-host').createWhipHostRedirect({ whipUrl: () => (config.whip?.enabled ? config.whip.publicUrl : null), siteUrl: () => config.baseUrl }));
// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every origin —
// WHIP ingest is keyed by the stream key, not by a cookie, so it is open to every 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.
@@ -191,7 +191,8 @@ async function streamChat(stream) {
@@ -191,7 +191,8 @@ async function streamChat(stream) {
try {
try {
const chat = require('../chat/chat-client');
const chat = require('../chat/chat-client');
for (let page = 0; page < 6; page++) {
for (let page = 0; page < 6; page++) {
- const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId, limit: CHAT_PAGE });
+ // The first page names no after_id: Chat refuses after_id=0 (an id is positive), which left every stream unfiltered.
+ const out = await chat.readMessages({ stream_id: stream.id, after_id: hit.afterId || undefined, limit: CHAT_PAGE });
const rows = (out && out.messages) || [];
const rows = (out && out.messages) || [];
for (const m of rows) {
for (const m of rows) {
hit.afterId = Math.max(hit.afterId, Number(m.id) || 0);
hit.afterId = Math.max(hit.afterId, Number(m.id) || 0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+/**
+ * The WHIP ingest host (config.whip.publicUrl, whip.openvibe.live) answers WHIP only: /whip and /whip/* (and
+ * /api/health), and every other path moves to the site's own address with a 301. The same process serves both
+ * names, so opened in a browser it showed the whole site on a second origin: a second copy of every page, and API
+ * calls the credentialed CORS allowlist refused (logged once an hour, 2026-10-23).
+ *
+ * app.use(createWhipHostRedirect({ whipUrl: () => …, siteUrl: () => … }))
+ *
+ * Both are read per request: config.whip is filled by the URL-registry refresh after the middleware is built. With
+ * no WHIP host, or one that is the site's own, it does nothing.
+ */
+function hostOf(url) {
+ try { const u = new URL(String(url)); return /^https?:$/.test(u.protocol) ? u : null; } catch { return null; }
+}
+
+function createWhipHostRedirect({ whipUrl, siteUrl }) {
+ return function whipHostRedirect(req, res, next) {
+ const whip = hostOf(whipUrl());
+ const site = hostOf(siteUrl());
+ if (!whip || !site || whip.hostname === site.hostname || req.hostname !== whip.hostname) return next();
+ if (req.path === '/whip' || req.path.startsWith('/whip/') || req.path === '/api/health') return next();
+ return res.redirect(301, `${site.origin}${req.originalUrl}`);
+ };
+}
+
+module.exports = { createWhipHostRedirect };
@@ -340,7 +340,8 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
@@ -340,7 +340,8 @@ console.log('✅ API: front page, topic, reactions as a signed-out reader; old /
const started = Date.parse((await db.get('SELECT started_at FROM streams WHERE id = ?', [st])).started_at.replace(' ', 'T') + 'Z');
const started = Date.parse((await db.get('SELECT started_at FROM streams WHERE id = ?', [st])).started_at.replace(' ', 'T') + 'Z');
const chat = require('../server/chat/chat-client');
const chat = require('../server/chat/chat-client');
const prevRead = chat.readMessages;
const prevRead = chat.readMessages;
- chat.readMessages = async (q) => (Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });
+ // Like Chat: an after_id that is not a positive id is a 400 (the first page names none).
+ chat.readMessages = async (q) => (q.after_id !== undefined && !(Number(q.after_id) > 0) ? Promise.reject(new Error('Chat answered 400 (Invalid after_id)')) : Number(q.stream_id) === st && !q.after_id ? { messages: [{ id: 7, message: 'WTF this site is so cool', message_type: 'chat', timestamp: new Date(started + 5000).toISOString().replace('T', ' ').slice(0, 19) }] } : { messages: [] });
desk._test.reset();
desk._test.reset();
await desk.tick();
await desk.tick();
const sent = calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'ttsstreamer');
const sent = calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'ttsstreamer');
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+// The WHIP host answers WHIP only (server/web/whip-host.js, 2026-10-23): on whip.openvibe.live, /whip, /whip/* and
+// /api/health pass; every other path is a 301 to the same path on the site. The site's own host, a missing WHIP
+// host or one equal to the site's pass everything.
+const assert = require('assert');
+const { createWhipHostRedirect } = require('../server/web/whip-host');
+
+const run = (mw, hostname, originalUrl) => {
+ const path = originalUrl.split('?')[0];
+ let out = 'next';
+ mw({ hostname, path, originalUrl }, { redirect: (code, to) => { out = `${code} ${to}`; } }, () => {});
+ return out;
+};
+
+let whip = 'https://whip.openvibe.live';
+const mw = createWhipHostRedirect({ whipUrl: () => whip, siteUrl: () => 'https://openvibe.live' });
+
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip/abc123'), 'next');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip/abc123/res_1'), 'next');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/whip'), 'next');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/api/health'), 'next');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), '301 https://openvibe.live/');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/@kim?tab=vods'), '301 https://openvibe.live/@kim?tab=vods');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/api/streams/live'), '301 https://openvibe.live/api/streams/live');
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/whipped'), '301 https://openvibe.live/whipped', 'only /whip itself and below');
+assert.strictEqual(run(mw, 'openvibe.live', '/'), 'next', 'the site itself is untouched');
+
+// The WHIP host is read per request (the URL registry fills it after boot): none, or the site's own, does nothing.
+whip = null;
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), 'next');
+whip = 'https://openvibe.live';
+assert.strictEqual(run(mw, 'openvibe.live', '/'), 'next');
+whip = 'not a url';
+assert.strictEqual(run(mw, 'whip.openvibe.live', '/'), 'next');
+
+console.log('whip host: WHIP only, everything else to the site');