openvibers/openvibe.live Commits
Restream setup: sign in with the platform, or paste a key — and make the buttons tappable
Changes
@@@ -1737,6 +1737,12 @@border-radius: 0;border: none;overflow-y: auto;++ /* .broadcast-layout pins its bottom padding to 0, so this scroller ran to the very edge of++ the viewport and whatever sat last in it — "Add Destination", at the end of the settings++ panel — ended up inside the home-indicator strip, where the system takes the touch. The++ inset plus a little room means the end of the list can always be scrolled clear of it. */++ padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));++ scroll-padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));}.bc-video-container {@@@ -92,12 +92,21 @@ body.ovg-lock { overflow: hidden; }.ovg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: ovgFall linear forwards; }@keyframes ovgFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }@media (max-width: 640px) {++ /* As a bottom sheet the card's own bottom edge is the screen's bottom edge, so the footer++ buttons landed in the phone's home-indicator strip — the system swallows the first touch++ there, which is why Next could not be tapped. 94vh also measured the *large* viewport, so++ with the browser toolbar showing the sheet ran below the visible area entirely. dvh tracks++ the viewport that is actually on screen, and the footer reserves the safe-area inset. */.ovg-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: ovgSheet .35s cubic-bezier(.2,1,.3,1); }++ @supports (height: 100dvh) {++ .ovg-card { max-height: min(94dvh, calc(100dvh - 6px)); }++ }++ .ovg-foot { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }@keyframes ovgSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }.ovg-grid, .ovg-grid--3 { grid-template-columns: 1fr; }.ovg-steps { grid-template-columns: repeat(4, 1fr); } .ovg-step-label { font-size: 0.6rem; }.ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }-- .ovg-foot { padding: 10px 14px 14px; }++ .ovg-foot { padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px)); }}@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }@@@ -140,3 +149,27 @@ body.ovg-lock { overflow: hidden; }.ovg-spot-card h4 { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; } .ovg-spot-card p { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }.ovg-spot-actions { display: flex; flex-wrap: wrap; gap: 8px; }@media (max-width: 640px) { .ovg-hub-top { grid-template-columns: 76px 1fr; } .ovg-ring { width: 76px; height: 76px; } .ovg-ring span { font-size: 1.2rem; } .ovg-profile { grid-template-columns: 1fr; justify-items: center; } .ovg-tasks li { grid-template-columns: 30px 1fr; } .ovg-tasks li .btn { grid-column: 2; justify-self: start; } }++++/* ── Restream: the guided way in, and the manual one ──────────────────────────++ Adding a destination used to mean leaving the page, finding a stream key in someone else's++ dashboard, and pasting it back. Where we have an OAuth app for the platform, signing in does++ all of that in one tap. Both paths live in the same box, with the sign-in first because it is++ the one most people want — but the key field is right there under it, not hidden behind a++ disclosure, because plenty of people would rather not link an account at all. */++.ovg-connect { margin: 0 0 4px; }++.ovg-connect-go { width: 100%; justify-content: center; gap: 10px; }++.ovg-connect-go i { font-size: 1.05em; }++.ovg-connect-go[disabled] { opacity: 0.75; cursor: progress; }++.ovg-connected {++ display: flex; align-items: center; gap: 8px; margin: 0 0 8px;++ padding: 8px 10px; border-radius: 10px; font-size: 0.8rem; font-weight: 700;++ background: color-mix(in srgb, #06d6a0 12%, var(--bg-tertiary));++ border: 1px solid color-mix(in srgb, #06d6a0 40%, var(--border));++}++.ovg-connected i { color: #06d6a0; }++/* A labelled rule between the two methods — neither is a footnote to the other. */++.ovg-or { display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; }++.ovg-or::before, .ovg-or::after { content: ''; height: 1px; flex: 1; background: var(--border); }++.ovg-or span { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }++.ovg-nooauth { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px; }++.ovg-nooauth i { color: var(--accent); margin-top: 2px; }@@@ -94,18 +94,18 @@<link rel="stylesheet" href="/css/i18n-star.css?v=11"><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"><link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">-- <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/broadcast.css?v=12" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">-- <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">++ <link rel="stylesheet" href="/css/ov-guide.css?v=3" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null"><link rel="stylesheet" href="/css/context-cards.css?v=4" media="print" onload="this.media='all';this.onload=null"><noscript><link rel="stylesheet" href="/css/chat-shared.css?v=1">-- <link rel="stylesheet" href="/css/broadcast.css?v=11">++ <link rel="stylesheet" href="/css/broadcast.css?v=12"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1">-- <link rel="stylesheet" href="/css/ov-guide.css?v=1">++ <link rel="stylesheet" href="/css/ov-guide.css?v=3"><link rel="stylesheet" href="/css/recap.css?v=1"><link rel="stylesheet" href="/css/context-cards.css?v=4"></noscript>@@@ -5181,7 +5181,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/js/home-fx.js?v=9" defer></script><script src="/js/home-tour.js?v=9" defer></script><script src="/js/ov-guide.js?v=2" defer></script>--<script src="/js/guides-live.js?v=7" defer></script>++<script src="/js/guides-live.js?v=8" defer></script><!-- ── OpenVibe shared libs (notifications, account sync, themes) ── --><script src="/shared/theme-loader.js?v=1" defer></script>@@@ -24,10 +24,51 @@const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };// ── Shared stream state (slot / endpoint / restreams) ─────-- const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };++ const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, oauth: {}, method: null, mode: 'camera', poll: null };async function loadSlots() { try { const d = await api('/streams/managed'); D.slots = d.managed_streams || []; } catch { D.slots = []; } return D.slots; }async function loadEndpoint() { if (!D.slot) return; try { const d = await api(`/streams/managed/${D.slot.id}/profile`); D.key = d.stream_key || D.slot.stream_key || null; D.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; D.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); } catch { D.key = D.slot.stream_key || null; D.rtmpUrl = 'rtmp://openvibe.live/live'; D.whipBase = location.origin; } }-- async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } }++ async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } await loadOAuth(); }++ /** Which platforms this server can do OAuth for, and which the reader has already linked. */++ async function loadOAuth() { try { const d = await api('/restream/oauth/status'); D.oauth = {}; for (const p of (d.platforms || [])) D.oauth[p.platform] = p; } catch { D.oauth = {}; } return D.oauth; }++++ /**++ * Open a platform's OAuth popup and wait for it to come back.++ *++ * Three channels, because any one of them can be lost: postMessage dies when the browser++ * severs window.opener across a cross-origin navigation (COOP), BroadcastChannel is++ * same-origin but not everywhere, and polling the server is slow but always true. Whichever++ * arrives first wins; the rest are torn down. Same approach the broadcast desk uses — this++ * copy is free of that page's DOM so the guide can use it anywhere.++ */++ function connectPlatform(platform, onDone) {++ const url = `/api/restream/oauth/${platform}/start${D.slot ? `?managed_stream_id=${D.slot.id}` : ''}`;++ const popup = window.open(url, 'ov-oauth-' + platform, 'width=620,height=760');++ if (!popup) { say('Allow pop-ups for openvibe.live to connect this way, or paste a key instead.', 'error'); return; }++ const before = (D.oauth && D.oauth[platform]) || {};++ let done = false, polls = 0, timer = null, bc = null;++ const cleanup = () => { try { window.removeEventListener('message', onMsg); } catch { /* */ } try { if (bc) bc.close(); } catch { /* */ } if (timer) clearInterval(timer); };++ const finish = async (payload) => {++ if (done) return; done = true; cleanup();++ if (payload && payload.ok === false) { say(payload.error || 'Connection failed', 'error'); onDone(false); return; }++ await loadDests();++ const st = (D.oauth && D.oauth[platform]) || {};++ const dest = D.dests.find(x => x.platform === platform);++ const needsKey = payload ? payload.needsManualKey : !!(dest && !dest.stream_key);++ say(`Connected ${(PLAT[platform] || PLAT.custom)[0]}${st.username ? ' as ' + st.username : ''}${needsKey ? ' — one key still needed' : ''}`, needsKey ? 'info' : 'success');++ onDone(true, needsKey);++ };++ const onMsg = (e) => { if (e.origin !== location.origin) return; const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); };++ window.addEventListener('message', onMsg);++ try { bc = new BroadcastChannel('restream-oauth'); bc.onmessage = (e) => { const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); }; } catch { bc = null; }++ timer = setInterval(async () => {++ polls++;++ await loadOAuth();++ const now = (D.oauth && D.oauth[platform]) || {};++ if (now.connected && (!before.connected || now.username !== before.username)) return finish(null);++ if (popup.closed && polls >= 2 && !done) { cleanup(); done = true; onDone(false); return; }++ if (polls >= 120 && !done) { cleanup(); done = true; onDone(false); }++ }, 2000);++ }async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }const onBroadcast = () => location.pathname.startsWith('/broadcast');@@@ -144,12 +185,20 @@id: 'restream', title: 'Restream', icon: 'fa-satellite-dish', heading: 'Mirror it everywhere (optional)', optional: true, skipLabel: 'Skip for now',render: async () => {if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');++ if (!D.oauth || !Object.keys(D.oauth).length) await loadOAuth();const added = [...(D.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${esc(D.rs.stream_name || ('robot ' + D.rs.robot_id))}<i class="fa-solid fa-check ovg-ok"></i></li>`] : []), ...D.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${esc(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check ovg-ok"></i></li>`)];return `${ui.lead(`Go live here once and the same stream goes out to every platform you add — while your chat, emotes, sound commands and controls stay on OpenVibe.${added.length ? ` <b>${added.length} already set up for "${esc(D.slot.title)}"</b> — add more, or move on.` : ' Add what you want, or skip.'}`)}${added.length ? `<ul class="ovg-added">${added.join('')}</ul>` : ''}${ui.grid([ui.pick({ id: 'robotstreamer', icon: 'fa-solid fa-robot', title: 'RobotStreamer', sub: 'log in once, pick your robot', color: '#4a9eff' }),-- ...Object.entries(PLAT).map(([id, [name, icon, color]]) => ui.pick({ id, icon, title: name, sub: id === 'custom' ? 'server URL + key' : 'paste your stream key', color })),++ ...Object.entries(PLAT).map(([id, [name, icon, color]]) => {++ const o = (D.oauth && D.oauth[id]) || null;++ const sub = id === 'custom' ? 'server URL + key'++ : (o && o.connected) ? `signed in as ${esc(o.username || name)}`++ : (o && o.configured) ? 'sign in — or paste a key'++ : 'paste your stream key';++ return ui.pick({ id, icon, title: name, sub, color });++ }),], 3)}<div class="ovg-detail" id="ovg-plat-form"></div>`;},mount: (el) => {@@@ -176,8 +225,39 @@return;}const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';-- f.innerHTML = ui.box(name, `<p>${plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.' : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.' : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.' : 'Paste the RTMP server URL and stream key from the service you want to mirror to.'}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));++ const oauth = (D.oauth && D.oauth[plat]) || null;++ const canConnect = !!(oauth && oauth.configured);++ const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'++ : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'++ : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'++ : 'Paste the RTMP server URL and stream key from the service you want to mirror to.';++++ // Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;++ // pasting a key is still here for anyone who would rather not link an account, whose++ // platform we have no OAuth app for, or who is mirroring to some other RTMP service.++ const connectBlock = canConnect ? `++ <div class="ovg-connect">++ ${oauth.connected ? `<p class="ovg-connected"><i class="fa-solid fa-circle-check"></i> Already signed in as <b>${esc(oauth.username || name)}</b> — reconnect to refresh the key.</p>` : ''}++ <button type="button" class="btn btn-primary btn-lg ovg-connect-go" id="ovg-oauth-go">++ <i class="${icon}"></i> ${oauth.connected ? 'Reconnect' : 'Sign in with'} ${esc(name)}++ </button>++ <p class="muted ovg-fine">Opens ${esc(name)} in a window. We read your channel and ${oauth.providesKey ? 'stream key' : 'channel details'} and fill this in — nothing to copy across.</p>++ <div class="ovg-or"><span>or do it by hand</span></div>++ </div>` : `<p class="muted ovg-fine ovg-nooauth"><i class="fa-solid fa-circle-info"></i> One-tap sign-in isn't set up for ${esc(name)} on this server yet — paste a key instead.</p>`;++++ f.innerHTML = ui.box(name, `${connectBlock}<p>${manualHelp}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));f.querySelector('.ovg-box-head i').className = icon;++ const oauthBtn = f.querySelector('#ovg-oauth-go');++ if (oauthBtn) oauthBtn.onclick = () => {++ oauthBtn.disabled = true;++ oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';++ connectPlatform(plat, (ok, needsKey) => {++ if (ok && !needsKey) return rerender(); // done — back to the list++ if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting++ oauthBtn.disabled = false;++ oauthBtn.innerHTML = `<i class="${icon}"></i> Sign in with ${esc(name)}`;++ });++ };f.querySelector('#ovg-p-add').onclick = async () => {const key = f.querySelector('#ovg-p-key').value.trim(); const url = needsUrl ? f.querySelector('#ovg-p-url').value.trim() : '';if (!key) return say('Paste the stream key first', 'error'); if (needsUrl && !url) return say('The server URL is needed too', 'error');@@@ -570,8 +570,19 @@ router.get('/oauth/:platform/callback', async (req, res) => {scope: tokens.scope,});-- // Provision / update the restream destination for the target slot-- const managedStreamId = stateData.managedStreamId || null;++ // Provision / update the restream destination for the target slot.++ // managed_stream_id arrives from the query at /start and rides along in the signed state,++ // so it is authentic but not authorised — the signature proves we issued it, not that the++ // slot belongs to this account. Without the ownership check a user could start the flow++ // against someone else's slot id and have a destination provisioned onto their stream.++ let managedStreamId = stateData.managedStreamId || null;++ if (managedStreamId) {++ const slot = db.getManagedStreamById(managedStreamId);++ if (!slot || slot.user_id !== userId) {++ console.warn(`[Restream OAuth] ${platform}: user ${userId} tried to provision onto slot ${managedStreamId}`);++ managedStreamId = null; // link the account, but touch nobody else's slot++ }++ }let destProvisioned = false;if (managedStreamId) {const existing = db.getRestreamDestinationsByManagedStream(managedStreamId)@@@ -167,4 +167,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };}}++// ── A signed OAuth state proves we issued it, not that the slot is yours ─────++{++ const src = fs.readFileSync(path.join(ROOT, 'server/streaming/restream-routes.js'), 'utf8');++ const cb = src.match(/const userId = stateData\.userId;[\s\S]*?destProvisioned = true;/);++ assert(cb, 'the OAuth callback should still provision a destination');++ assert(/slot\.user_id !== userId/.test(cb[0]),++ 'the callback must confirm the state\'s managed_stream_id belongs to the authenticating user');++ assert(cb[0].indexOf('slot.user_id !== userId') < cb[0].indexOf('createRestreamDestination'),++ 'ownership must be checked before a destination is created');++ ok('restream OAuth callback will not provision onto another account\'s slot');++}++console.log(`\n${pass} checks passed`);
@@ -1737,6 +1737,12 @@
@@ -1737,6 +1737,12 @@
border-radius: 0;
border-radius: 0;
border: none;
border: none;
overflow-y: auto;
overflow-y: auto;
+ /* .broadcast-layout pins its bottom padding to 0, so this scroller ran to the very edge of
+ the viewport and whatever sat last in it — "Add Destination", at the end of the settings
+ panel — ended up inside the home-indicator strip, where the system takes the touch. The
+ inset plus a little room means the end of the list can always be scrolled clear of it. */
+ padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
+ scroll-padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
}
}
.bc-video-container {
.bc-video-container {
@@ -92,12 +92,21 @@ body.ovg-lock { overflow: hidden; }
@@ -92,12 +92,21 @@ body.ovg-lock { overflow: hidden; }
.ovg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: ovgFall linear forwards; }
.ovg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: ovgFall linear forwards; }
@keyframes ovgFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
@keyframes ovgFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
@media (max-width: 640px) {
@media (max-width: 640px) {
+ /* As a bottom sheet the card's own bottom edge is the screen's bottom edge, so the footer
+ buttons landed in the phone's home-indicator strip — the system swallows the first touch
+ there, which is why Next could not be tapped. 94vh also measured the *large* viewport, so
+ with the browser toolbar showing the sheet ran below the visible area entirely. dvh tracks
+ the viewport that is actually on screen, and the footer reserves the safe-area inset. */
.ovg-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: ovgSheet .35s cubic-bezier(.2,1,.3,1); }
.ovg-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: ovgSheet .35s cubic-bezier(.2,1,.3,1); }
+ @supports (height: 100dvh) {
+ .ovg-card { max-height: min(94dvh, calc(100dvh - 6px)); }
+ }
+ .ovg-foot { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
@keyframes ovgSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes ovgSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
.ovg-grid, .ovg-grid--3 { grid-template-columns: 1fr; }
.ovg-grid, .ovg-grid--3 { grid-template-columns: 1fr; }
.ovg-steps { grid-template-columns: repeat(4, 1fr); } .ovg-step-label { font-size: 0.6rem; }
.ovg-steps { grid-template-columns: repeat(4, 1fr); } .ovg-step-label { font-size: 0.6rem; }
.ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }
.ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }
- .ovg-foot { padding: 10px 14px 14px; }
+ .ovg-foot { padding: 10px 14px calc(14px + env(safe-area-inset-bottom, 0px)); }
}
}
@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }
@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }
@@ -140,3 +149,27 @@ body.ovg-lock { overflow: hidden; }
@@ -140,3 +149,27 @@ body.ovg-lock { overflow: hidden; }
.ovg-spot-card h4 { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; } .ovg-spot-card p { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }
.ovg-spot-card h4 { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; } .ovg-spot-card p { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }
.ovg-spot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.ovg-spot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
@media (max-width: 640px) { .ovg-hub-top { grid-template-columns: 76px 1fr; } .ovg-ring { width: 76px; height: 76px; } .ovg-ring span { font-size: 1.2rem; } .ovg-profile { grid-template-columns: 1fr; justify-items: center; } .ovg-tasks li { grid-template-columns: 30px 1fr; } .ovg-tasks li .btn { grid-column: 2; justify-self: start; } }
@media (max-width: 640px) { .ovg-hub-top { grid-template-columns: 76px 1fr; } .ovg-ring { width: 76px; height: 76px; } .ovg-ring span { font-size: 1.2rem; } .ovg-profile { grid-template-columns: 1fr; justify-items: center; } .ovg-tasks li { grid-template-columns: 30px 1fr; } .ovg-tasks li .btn { grid-column: 2; justify-self: start; } }
+
+/* ── Restream: the guided way in, and the manual one ──────────────────────────
+ Adding a destination used to mean leaving the page, finding a stream key in someone else's
+ dashboard, and pasting it back. Where we have an OAuth app for the platform, signing in does
+ all of that in one tap. Both paths live in the same box, with the sign-in first because it is
+ the one most people want — but the key field is right there under it, not hidden behind a
+ disclosure, because plenty of people would rather not link an account at all. */
+.ovg-connect { margin: 0 0 4px; }
+.ovg-connect-go { width: 100%; justify-content: center; gap: 10px; }
+.ovg-connect-go i { font-size: 1.05em; }
+.ovg-connect-go[disabled] { opacity: 0.75; cursor: progress; }
+.ovg-connected {
+ display: flex; align-items: center; gap: 8px; margin: 0 0 8px;
+ padding: 8px 10px; border-radius: 10px; font-size: 0.8rem; font-weight: 700;
+ background: color-mix(in srgb, #06d6a0 12%, var(--bg-tertiary));
+ border: 1px solid color-mix(in srgb, #06d6a0 40%, var(--border));
+}
+.ovg-connected i { color: #06d6a0; }
+/* A labelled rule between the two methods — neither is a footnote to the other. */
+.ovg-or { display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; }
+.ovg-or::before, .ovg-or::after { content: ''; height: 1px; flex: 1; background: var(--border); }
+.ovg-or span { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
+.ovg-nooauth { display: flex; align-items: flex-start; gap: 7px; margin: 0 0 10px; }
+.ovg-nooauth i { color: var(--accent); margin-top: 2px; }
@@ -94,18 +94,18 @@
@@ -94,18 +94,18 @@
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="/css/i18n-star.css?v=11">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/chat-shared.css?v=1" media="print" onload="this.media='all';this.onload=null">
- <link rel="stylesheet" href="/css/broadcast.css?v=11" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/broadcast.css?v=12" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/cosmetics.css?v=2" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/messenger.css?v=1" media="print" onload="this.media='all';this.onload=null">
- <link rel="stylesheet" href="/css/ov-guide.css?v=1" media="print" onload="this.media='all';this.onload=null">
+ <link rel="stylesheet" href="/css/ov-guide.css?v=3" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/recap.css?v=1" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/context-cards.css?v=4" media="print" onload="this.media='all';this.onload=null">
<link rel="stylesheet" href="/css/context-cards.css?v=4" media="print" onload="this.media='all';this.onload=null">
<noscript>
<noscript>
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
- <link rel="stylesheet" href="/css/broadcast.css?v=11">
+ <link rel="stylesheet" href="/css/broadcast.css?v=12">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/ov-guide.css?v=1">
+ <link rel="stylesheet" href="/css/ov-guide.css?v=3">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/context-cards.css?v=4">
<link rel="stylesheet" href="/css/context-cards.css?v=4">
</noscript>
</noscript>
@@ -5181,7 +5181,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5181,7 +5181,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-fx.js?v=9" defer></script>
<script src="/js/home-tour.js?v=9" defer></script>
<script src="/js/home-tour.js?v=9" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
<script src="/js/ov-guide.js?v=2" defer></script>
-<script src="/js/guides-live.js?v=7" defer></script>
+<script src="/js/guides-live.js?v=8" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js?v=1" defer></script>
<script src="/shared/theme-loader.js?v=1" defer></script>
@@ -24,10 +24,51 @@
@@ -24,10 +24,51 @@
const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };
const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes', mods: 'fa-user-shield', requests: 'fa-list-ol', moderation: 'fa-gavel', controls: 'fa-gamepad', aibot: 'fa-robot', paste: 'fa-code' };
// ── Shared stream state (slot / endpoint / restreams) ─────
// ── Shared stream state (slot / endpoint / restreams) ─────
- const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };
+ const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, oauth: {}, method: null, mode: 'camera', poll: null };
async function loadSlots() { try { const d = await api('/streams/managed'); D.slots = d.managed_streams || []; } catch { D.slots = []; } return D.slots; }
async function loadSlots() { try { const d = await api('/streams/managed'); D.slots = d.managed_streams || []; } catch { D.slots = []; } return D.slots; }
async function loadEndpoint() { if (!D.slot) return; try { const d = await api(`/streams/managed/${D.slot.id}/profile`); D.key = d.stream_key || D.slot.stream_key || null; D.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; D.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); } catch { D.key = D.slot.stream_key || null; D.rtmpUrl = 'rtmp://openvibe.live/live'; D.whipBase = location.origin; } }
async function loadEndpoint() { if (!D.slot) return; try { const d = await api(`/streams/managed/${D.slot.id}/profile`); D.key = d.stream_key || D.slot.stream_key || null; D.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; D.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); } catch { D.key = D.slot.stream_key || null; D.rtmpUrl = 'rtmp://openvibe.live/live'; D.whipBase = location.origin; } }
- async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } }
+ async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } await loadOAuth(); }
+ /** Which platforms this server can do OAuth for, and which the reader has already linked. */
+ async function loadOAuth() { try { const d = await api('/restream/oauth/status'); D.oauth = {}; for (const p of (d.platforms || [])) D.oauth[p.platform] = p; } catch { D.oauth = {}; } return D.oauth; }
+
+ /**
+ * Open a platform's OAuth popup and wait for it to come back.
+ *
+ * Three channels, because any one of them can be lost: postMessage dies when the browser
+ * severs window.opener across a cross-origin navigation (COOP), BroadcastChannel is
+ * same-origin but not everywhere, and polling the server is slow but always true. Whichever
+ * arrives first wins; the rest are torn down. Same approach the broadcast desk uses — this
+ * copy is free of that page's DOM so the guide can use it anywhere.
+ */
+ function connectPlatform(platform, onDone) {
+ const url = `/api/restream/oauth/${platform}/start${D.slot ? `?managed_stream_id=${D.slot.id}` : ''}`;
+ const popup = window.open(url, 'ov-oauth-' + platform, 'width=620,height=760');
+ if (!popup) { say('Allow pop-ups for openvibe.live to connect this way, or paste a key instead.', 'error'); return; }
+ const before = (D.oauth && D.oauth[platform]) || {};
+ let done = false, polls = 0, timer = null, bc = null;
+ const cleanup = () => { try { window.removeEventListener('message', onMsg); } catch { /* */ } try { if (bc) bc.close(); } catch { /* */ } if (timer) clearInterval(timer); };
+ const finish = async (payload) => {
+ if (done) return; done = true; cleanup();
+ if (payload && payload.ok === false) { say(payload.error || 'Connection failed', 'error'); onDone(false); return; }
+ await loadDests();
+ const st = (D.oauth && D.oauth[platform]) || {};
+ const dest = D.dests.find(x => x.platform === platform);
+ const needsKey = payload ? payload.needsManualKey : !!(dest && !dest.stream_key);
+ say(`Connected ${(PLAT[platform] || PLAT.custom)[0]}${st.username ? ' as ' + st.username : ''}${needsKey ? ' — one key still needed' : ''}`, needsKey ? 'info' : 'success');
+ onDone(true, needsKey);
+ };
+ const onMsg = (e) => { if (e.origin !== location.origin) return; const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); };
+ window.addEventListener('message', onMsg);
+ try { bc = new BroadcastChannel('restream-oauth'); bc.onmessage = (e) => { const d = e.data; if (d && d.type === 'restream-oauth' && d.platform === platform) finish(d); }; } catch { bc = null; }
+ timer = setInterval(async () => {
+ polls++;
+ await loadOAuth();
+ const now = (D.oauth && D.oauth[platform]) || {};
+ if (now.connected && (!before.connected || now.username !== before.username)) return finish(null);
+ if (popup.closed && polls >= 2 && !done) { cleanup(); done = true; onDone(false); return; }
+ if (polls >= 120 && !done) { cleanup(); done = true; onDone(false); }
+ }, 2000);
+ }
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
const onBroadcast = () => location.pathname.startsWith('/broadcast');
const onBroadcast = () => location.pathname.startsWith('/broadcast');
@@ -144,12 +185,20 @@
@@ -144,12 +185,20 @@
id: 'restream', title: 'Restream', icon: 'fa-satellite-dish', heading: 'Mirror it everywhere (optional)', optional: true, skipLabel: 'Skip for now',
id: 'restream', title: 'Restream', icon: 'fa-satellite-dish', heading: 'Mirror it everywhere (optional)', optional: true, skipLabel: 'Skip for now',
render: async () => {
render: async () => {
if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
+ if (!D.oauth || !Object.keys(D.oauth).length) await loadOAuth();
const added = [...(D.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${esc(D.rs.stream_name || ('robot ' + D.rs.robot_id))}<i class="fa-solid fa-check ovg-ok"></i></li>`] : []), ...D.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${esc(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check ovg-ok"></i></li>`)];
const added = [...(D.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${esc(D.rs.stream_name || ('robot ' + D.rs.robot_id))}<i class="fa-solid fa-check ovg-ok"></i></li>`] : []), ...D.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${esc(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check ovg-ok"></i></li>`)];
return `${ui.lead(`Go live here once and the same stream goes out to every platform you add — while your chat, emotes, sound commands and controls stay on OpenVibe.${added.length ? ` <b>${added.length} already set up for "${esc(D.slot.title)}"</b> — add more, or move on.` : ' Add what you want, or skip.'}`)}
return `${ui.lead(`Go live here once and the same stream goes out to every platform you add — while your chat, emotes, sound commands and controls stay on OpenVibe.${added.length ? ` <b>${added.length} already set up for "${esc(D.slot.title)}"</b> — add more, or move on.` : ' Add what you want, or skip.'}`)}
${added.length ? `<ul class="ovg-added">${added.join('')}</ul>` : ''}
${added.length ? `<ul class="ovg-added">${added.join('')}</ul>` : ''}
${ui.grid([
${ui.grid([
ui.pick({ id: 'robotstreamer', icon: 'fa-solid fa-robot', title: 'RobotStreamer', sub: 'log in once, pick your robot', color: '#4a9eff' }),
ui.pick({ id: 'robotstreamer', icon: 'fa-solid fa-robot', title: 'RobotStreamer', sub: 'log in once, pick your robot', color: '#4a9eff' }),
- ...Object.entries(PLAT).map(([id, [name, icon, color]]) => ui.pick({ id, icon, title: name, sub: id === 'custom' ? 'server URL + key' : 'paste your stream key', color })),
+ ...Object.entries(PLAT).map(([id, [name, icon, color]]) => {
+ const o = (D.oauth && D.oauth[id]) || null;
+ const sub = id === 'custom' ? 'server URL + key'
+ : (o && o.connected) ? `signed in as ${esc(o.username || name)}`
+ : (o && o.configured) ? 'sign in — or paste a key'
+ : 'paste your stream key';
+ return ui.pick({ id, icon, title: name, sub, color });
+ }),
], 3)}<div class="ovg-detail" id="ovg-plat-form"></div>`;
], 3)}<div class="ovg-detail" id="ovg-plat-form"></div>`;
},
},
mount: (el) => {
mount: (el) => {
@@ -176,8 +225,39 @@
@@ -176,8 +225,39 @@
return;
return;
}
}
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
- f.innerHTML = ui.box(name, `<p>${plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.' : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.' : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.' : 'Paste the RTMP server URL and stream key from the service you want to mirror to.'}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));
+ const oauth = (D.oauth && D.oauth[plat]) || null;
+ const canConnect = !!(oauth && oauth.configured);
+ const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'
+ : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'
+ : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'
+ : 'Paste the RTMP server URL and stream key from the service you want to mirror to.';
+
+ // Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;
+ // pasting a key is still here for anyone who would rather not link an account, whose
+ // platform we have no OAuth app for, or who is mirroring to some other RTMP service.
+ const connectBlock = canConnect ? `
+ <div class="ovg-connect">
+ ${oauth.connected ? `<p class="ovg-connected"><i class="fa-solid fa-circle-check"></i> Already signed in as <b>${esc(oauth.username || name)}</b> — reconnect to refresh the key.</p>` : ''}
+ <button type="button" class="btn btn-primary btn-lg ovg-connect-go" id="ovg-oauth-go">
+ <i class="${icon}"></i> ${oauth.connected ? 'Reconnect' : 'Sign in with'} ${esc(name)}
+ </button>
+ <p class="muted ovg-fine">Opens ${esc(name)} in a window. We read your channel and ${oauth.providesKey ? 'stream key' : 'channel details'} and fill this in — nothing to copy across.</p>
+ <div class="ovg-or"><span>or do it by hand</span></div>
+ </div>` : `<p class="muted ovg-fine ovg-nooauth"><i class="fa-solid fa-circle-info"></i> One-tap sign-in isn't set up for ${esc(name)} on this server yet — paste a key instead.</p>`;
+
+ f.innerHTML = ui.box(name, `${connectBlock}<p>${manualHelp}</p>${needsUrl ? '<input class="form-input" id="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));
f.querySelector('.ovg-box-head i').className = icon;
f.querySelector('.ovg-box-head i').className = icon;
+ const oauthBtn = f.querySelector('#ovg-oauth-go');
+ if (oauthBtn) oauthBtn.onclick = () => {
+ oauthBtn.disabled = true;
+ oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';
+ connectPlatform(plat, (ok, needsKey) => {
+ if (ok && !needsKey) return rerender(); // done — back to the list
+ if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting
+ oauthBtn.disabled = false;
+ oauthBtn.innerHTML = `<i class="${icon}"></i> Sign in with ${esc(name)}`;
+ });
+ };
f.querySelector('#ovg-p-add').onclick = async () => {
f.querySelector('#ovg-p-add').onclick = async () => {
const key = f.querySelector('#ovg-p-key').value.trim(); const url = needsUrl ? f.querySelector('#ovg-p-url').value.trim() : '';
const key = f.querySelector('#ovg-p-key').value.trim(); const url = needsUrl ? f.querySelector('#ovg-p-url').value.trim() : '';
if (!key) return say('Paste the stream key first', 'error'); if (needsUrl && !url) return say('The server URL is needed too', 'error');
if (!key) return say('Paste the stream key first', 'error'); if (needsUrl && !url) return say('The server URL is needed too', 'error');
@@ -570,8 +570,19 @@ router.get('/oauth/:platform/callback', async (req, res) => {
@@ -570,8 +570,19 @@ router.get('/oauth/:platform/callback', async (req, res) => {
scope: tokens.scope,
scope: tokens.scope,
});
});
- // Provision / update the restream destination for the target slot
- const managedStreamId = stateData.managedStreamId || null;
+ // Provision / update the restream destination for the target slot.
+ // managed_stream_id arrives from the query at /start and rides along in the signed state,
+ // so it is authentic but not authorised — the signature proves we issued it, not that the
+ // slot belongs to this account. Without the ownership check a user could start the flow
+ // against someone else's slot id and have a destination provisioned onto their stream.
+ let managedStreamId = stateData.managedStreamId || null;
+ if (managedStreamId) {
+ const slot = db.getManagedStreamById(managedStreamId);
+ if (!slot || slot.user_id !== userId) {
+ console.warn(`[Restream OAuth] ${platform}: user ${userId} tried to provision onto slot ${managedStreamId}`);
+ managedStreamId = null; // link the account, but touch nobody else's slot
+ }
+ }
let destProvisioned = false;
let destProvisioned = false;
if (managedStreamId) {
if (managedStreamId) {
const existing = db.getRestreamDestinationsByManagedStream(managedStreamId)
const existing = db.getRestreamDestinationsByManagedStream(managedStreamId)
@@ -167,4 +167,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -167,4 +167,16 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
}
}
}
}
+// ── A signed OAuth state proves we issued it, not that the slot is yours ─────
+{
+ const src = fs.readFileSync(path.join(ROOT, 'server/streaming/restream-routes.js'), 'utf8');
+ const cb = src.match(/const userId = stateData\.userId;[\s\S]*?destProvisioned = true;/);
+ assert(cb, 'the OAuth callback should still provision a destination');
+ assert(/slot\.user_id !== userId/.test(cb[0]),
+ 'the callback must confirm the state\'s managed_stream_id belongs to the authenticating user');
+ assert(cb[0].indexOf('slot.user_id !== userId') < cb[0].indexOf('createRestreamDestination'),
+ 'ownership must be checked before a destination is created');
+ ok('restream OAuth callback will not provision onto another account\'s slot');
+}
+
console.log(`\n${pass} checks passed`);
console.log(`\n${pass} checks passed`);