openvibers/openvibe.network Commits
Bridge Live player frames through Network across sites (#835)
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++(() => {++ const from = new URLSearchParams(location.search).get('from');++ const frame = document.getElementById('player');++ const liveOrigin = 'https://openvibe.live';++ if (!from || !frame || parent === window) return;++ let loaded = false;++ const pending = [];++ frame.addEventListener('load', () => {++ loaded = true;++ for (const cmd of pending.splice(0)) frame.contentWindow.postMessage({ type: 'ov-embed', cmd }, liveOrigin);++ });++ addEventListener('message', (event) => {++ const data = event.data;++ if (!data || data.type !== 'ov-embed') return;++ if (event.source === parent && event.origin === from) {++ if (!['mute', 'unmute', 'play', 'pause'].includes(data.cmd)) return;++ if (loaded) frame.contentWindow.postMessage({ type: 'ov-embed', cmd: data.cmd }, liveOrigin);++ else pending.push(data.cmd);++ } else if (event.source === frame.contentWindow && event.origin === liveOrigin && data.event === 'state') {++ parent.postMessage(data, from);++ }++ });++})();nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++const express = require('express');++const { SITES, PRODUCTS } = require('./sites');++++const allowedHosts = new Set([...SITES, ...PRODUCTS].map((site) => site.host));++for (const host of ['openvibe.link', 'openvibe.my', 'openvibe.mail', 'openvibe.search',++ 'openvibe.shoes', 'openvibe.connect', 'openvibe.party', 'openvibe.inventory']) allowedHosts.add(host);++const router = express.Router();++++function parentOrigin(value) {++ try {++ const url = new URL(String(value || ''));++ if (url.protocol !== 'https:' || url.origin !== value || !allowedHosts.has(url.hostname)) return null;++ return url.origin;++ } catch { return null; }++}++++// Every destination already permits Network frames. This narrow bridge lets one of++// those frames host Live's existing /embed player without widening every site's CSP.++router.get('/:username', (req, res) => {++ const from = parentOrigin(req.query.from);++ const username = String(req.params.username || '');++ if (!from || !/^[A-Za-z0-9_-]{1,40}$/.test(username)) return res.sendStatus(404);++ res.removeHeader('X-Frame-Options');++ res.set({++ 'Content-Security-Policy': `default-src 'none'; frame-src https://openvibe.live; frame-ancestors ${from}; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; object-src 'none'`,++ 'Cache-Control': 'public, max-age=300',++ });++ res.type('html').send(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live stream</title><style>html,body{margin:0;width:100%;height:100%;background:#000}iframe{display:block;width:100%;height:100%;border:0}</style></head><body><iframe id="player" src="https://openvibe.live/embed/${encodeURIComponent(username)}" title="Live stream" allow="autoplay; fullscreen; picture-in-picture" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" referrerpolicy="strict-origin-when-cross-origin"></iframe><script src="/js/live-player-bridge.js" defer></script></body></html>`);++});++++module.exports = { router, parentOrigin };@@@ -159,6 +159,7 @@ app.use(helmet({crossOriginEmbedderPolicy: false,}));app.use(cookieParser());++app.use('/frame/live', require('./frame/live-player').router);// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so// the route sees the raw bytes it must verify the Svix signature over.app.use('/api/webhooks', require('./notifications/resend-webhook')());nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++const assert = require('node:assert/strict');++const express = require('express');++const helmet = require('helmet');++const path = require('node:path');++const { router, parentOrigin } = require('../server/frame/live-player');++++assert.equal(parentOrigin('https://openvibe.video'), 'https://openvibe.video');++assert.equal(parentOrigin('https://openvibe.link'), 'https://openvibe.link');++assert.equal(parentOrigin('https://openvibe.my'), 'https://openvibe.my');++assert.equal(parentOrigin('https://evil.example'), null);++assert.equal(parentOrigin('http://openvibe.video'), null);++assert.equal(parentOrigin('https://openvibe.video/path'), null);++++(async () => {++ const app = express();++ app.use(helmet());++ app.use('/frame/live', router);++ app.use(express.static(path.join(__dirname, '..', 'public')));++ const server = app.listen(0, '127.0.0.1');++ try {++ await new Promise((resolve) => server.once('listening', resolve));++ const base = `http://127.0.0.1:${server.address().port}`;++ const res = await fetch(`${base}/frame/live/maticus?from=https%3A%2F%2Fopenvibe.video`);++ assert.equal(res.status, 200);++ const csp = res.headers.get('content-security-policy');++ assert.match(csp, /frame-src https:\/\/openvibe\.live/);++ assert.match(csp, /frame-ancestors https:\/\/openvibe\.video/);++ assert.equal(res.headers.get('x-frame-options'), null);++ const html = await res.text();++ assert.match(html, /https:\/\/openvibe\.live\/embed\/maticus/);++ assert.match(html, /\/js\/live-player-bridge\.js/);++ const script = await fetch(`${base}/js/live-player-bridge.js`);++ assert.equal(script.status, 200);++ for (const from of ['https://evil.example', 'http://openvibe.video', 'https://openvibe.video/path']) {++ const blocked = await fetch(`${base}/frame/live/maticus?from=${encodeURIComponent(from)}`);++ assert.equal(blocked.status, 404);++ }++ console.log('live-player-bridge: exact first-party frame policy and relay asset');++ } finally { server.close(); }++})().catch((error) => { console.error(error); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+(() => {
+ const from = new URLSearchParams(location.search).get('from');
+ const frame = document.getElementById('player');
+ const liveOrigin = 'https://openvibe.live';
+ if (!from || !frame || parent === window) return;
+ let loaded = false;
+ const pending = [];
+ frame.addEventListener('load', () => {
+ loaded = true;
+ for (const cmd of pending.splice(0)) frame.contentWindow.postMessage({ type: 'ov-embed', cmd }, liveOrigin);
+ });
+ addEventListener('message', (event) => {
+ const data = event.data;
+ if (!data || data.type !== 'ov-embed') return;
+ if (event.source === parent && event.origin === from) {
+ if (!['mute', 'unmute', 'play', 'pause'].includes(data.cmd)) return;
+ if (loaded) frame.contentWindow.postMessage({ type: 'ov-embed', cmd: data.cmd }, liveOrigin);
+ else pending.push(data.cmd);
+ } else if (event.source === frame.contentWindow && event.origin === liveOrigin && data.event === 'state') {
+ parent.postMessage(data, from);
+ }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+const express = require('express');
+const { SITES, PRODUCTS } = require('./sites');
+
+const allowedHosts = new Set([...SITES, ...PRODUCTS].map((site) => site.host));
+for (const host of ['openvibe.link', 'openvibe.my', 'openvibe.mail', 'openvibe.search',
+ 'openvibe.shoes', 'openvibe.connect', 'openvibe.party', 'openvibe.inventory']) allowedHosts.add(host);
+const router = express.Router();
+
+function parentOrigin(value) {
+ try {
+ const url = new URL(String(value || ''));
+ if (url.protocol !== 'https:' || url.origin !== value || !allowedHosts.has(url.hostname)) return null;
+ return url.origin;
+ } catch { return null; }
+}
+
+// Every destination already permits Network frames. This narrow bridge lets one of
+// those frames host Live's existing /embed player without widening every site's CSP.
+router.get('/:username', (req, res) => {
+ const from = parentOrigin(req.query.from);
+ const username = String(req.params.username || '');
+ if (!from || !/^[A-Za-z0-9_-]{1,40}$/.test(username)) return res.sendStatus(404);
+ res.removeHeader('X-Frame-Options');
+ res.set({
+ 'Content-Security-Policy': `default-src 'none'; frame-src https://openvibe.live; frame-ancestors ${from}; script-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; object-src 'none'`,
+ 'Cache-Control': 'public, max-age=300',
+ });
+ res.type('html').send(`<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live stream</title><style>html,body{margin:0;width:100%;height:100%;background:#000}iframe{display:block;width:100%;height:100%;border:0}</style></head><body><iframe id="player" src="https://openvibe.live/embed/${encodeURIComponent(username)}" title="Live stream" allow="autoplay; fullscreen; picture-in-picture" sandbox="allow-scripts allow-same-origin allow-popups allow-forms" referrerpolicy="strict-origin-when-cross-origin"></iframe><script src="/js/live-player-bridge.js" defer></script></body></html>`);
+});
+
+module.exports = { router, parentOrigin };
@@ -159,6 +159,7 @@ app.use(helmet({
@@ -159,6 +159,7 @@ app.use(helmet({
crossOriginEmbedderPolicy: false,
crossOriginEmbedderPolicy: false,
}));
}));
app.use(cookieParser());
app.use(cookieParser());
+app.use('/frame/live', require('./frame/live-player').router);
// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so
// Provider webhooks (Resend bounces/complaints) — mounted BEFORE the JSON body parser so
// the route sees the raw bytes it must verify the Svix signature over.
// the route sees the raw bytes it must verify the Svix signature over.
app.use('/api/webhooks', require('./notifications/resend-webhook')());
app.use('/api/webhooks', require('./notifications/resend-webhook')());
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+const assert = require('node:assert/strict');
+const express = require('express');
+const helmet = require('helmet');
+const path = require('node:path');
+const { router, parentOrigin } = require('../server/frame/live-player');
+
+assert.equal(parentOrigin('https://openvibe.video'), 'https://openvibe.video');
+assert.equal(parentOrigin('https://openvibe.link'), 'https://openvibe.link');
+assert.equal(parentOrigin('https://openvibe.my'), 'https://openvibe.my');
+assert.equal(parentOrigin('https://evil.example'), null);
+assert.equal(parentOrigin('http://openvibe.video'), null);
+assert.equal(parentOrigin('https://openvibe.video/path'), null);
+
+(async () => {
+ const app = express();
+ app.use(helmet());
+ app.use('/frame/live', router);
+ app.use(express.static(path.join(__dirname, '..', 'public')));
+ const server = app.listen(0, '127.0.0.1');
+ try {
+ await new Promise((resolve) => server.once('listening', resolve));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const res = await fetch(`${base}/frame/live/maticus?from=https%3A%2F%2Fopenvibe.video`);
+ assert.equal(res.status, 200);
+ const csp = res.headers.get('content-security-policy');
+ assert.match(csp, /frame-src https:\/\/openvibe\.live/);
+ assert.match(csp, /frame-ancestors https:\/\/openvibe\.video/);
+ assert.equal(res.headers.get('x-frame-options'), null);
+ const html = await res.text();
+ assert.match(html, /https:\/\/openvibe\.live\/embed\/maticus/);
+ assert.match(html, /\/js\/live-player-bridge\.js/);
+ const script = await fetch(`${base}/js/live-player-bridge.js`);
+ assert.equal(script.status, 200);
+ for (const from of ['https://evil.example', 'http://openvibe.video', 'https://openvibe.video/path']) {
+ const blocked = await fetch(`${base}/frame/live/maticus?from=${encodeURIComponent(from)}`);
+ assert.equal(blocked.status, 404);
+ }
+ console.log('live-player-bridge: exact first-party frame policy and relay asset');
+ } finally { server.close(); }
+})().catch((error) => { console.error(error); process.exitCode = 1; });