openvibers/openvibe.chat Commits

openvibe.party's home: pick something to watch, paste a watch page, how it works (#394)

OP OpenVibers committed 6 days ago · 17381fa818 · 5 files, +225 −21

* openvibe.party's home: pick something to watch, paste a watch page, and how it works (Opus's design)

- The home shows OpenVibe.Video's latest (MediaHub /api/v1/video/watchable/latest, cached a minute; Video down leaves
  the grid out). A card starts a party on its page; signed out, /new asks for sign-in first.
- /new and its POST make a pasted watch page playable: Video names the file, its title and picture
  (/api/v1/video/watchable). A private, processing or foreign page is refused with a plain reason.
- The confirmation shows the video's picture. party-home.css: phone first, Chat's tokens.
- Tests never reach the real Video (NODE_ENV=test without CHAT_PARTY_VIDEO_URL fetches nothing).

* Merge origin/main into party/home

* security-ssrf: party-pages calls OpenVibe.Video at a fixed host and paths; a pasted link is only a query parameter

Changes

ddiff --git a/public/web/party-home.css b/public/web/party-home.css
nnew file mode 100644
iindex 0000000..26a557e
---- /dev/null
++++ b/public/web/party-home.css
@@@ -0,0 +1,48 @@
++/* openvibe.party's home and its start confirmation (Opus's design): a hero that starts a party in one move, a grid of
++ OpenVibe.Video's latest to pick from, and how it works in three steps. Chat's tokens (chat.css), phone first. */
++.pty-hero { padding-block: 28px 8px; display: grid; gap: 12px; max-width: 760px; }
++.pty-eyebrow { margin: 0; font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; color: var(--oc-accent); }
++.pty-hero h1 { margin: 0; font-size: clamp(2.1rem, 7vw, 3.4rem); line-height: 1.05; letter-spacing: -.025em; font-weight: 850; text-wrap: balance; }
++.pty-hero .oc-lede { font-size: clamp(1.02rem, 2.6vw, 1.2rem); }
++
++.pty-paste { display: grid; gap: 8px; margin-top: 8px; }
++.pty-paste label { font-weight: 700; font-size: 14px; }
++.pty-paste-row { display: flex; flex-wrap: wrap; gap: 8px; }
++.pty-paste input { flex: 1 1 240px; min-width: 0; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; }
++.pty-paste input::placeholder { color: var(--oc-muted); opacity: .8; }
++.pty-paste .oc-button, .pty-cta .oc-button { min-height: 48px; padding: 0 20px; display: inline-flex; align-items: center; justify-content: center; border-radius: 12px; }
++.pty-paste .oc-button { flex: 0 0 auto; }
++.pty-hint { margin: 0; font-size: 13px; color: var(--oc-muted); }
++.pty-cta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 8px 0 0; }
++
++.pty-pick, .pty-how { margin-top: 40px; }
++.pty-pick h2, .pty-how h2 { margin: 0 0 14px; font-size: 1.15rem; letter-spacing: -.01em; }
++.pty-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 18px 14px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
++@media (min-width: 720px) { .pty-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 22px 18px; } }
++.pty-card a { display: grid; gap: 6px; color: var(--oc-text); text-decoration: none; min-width: 0; }
++.pty-thumb { position: relative; display: block; aspect-ratio: 16 / 9; max-width: 100%; border-radius: 12px; overflow: hidden; background: var(--oc-panel-2); border: 1px solid var(--oc-line); }
++.pty-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }
++.pty-thumb-none { position: absolute; inset: 0; background: linear-gradient(135deg, color-mix(in srgb, var(--oc-accent) 22%, transparent), transparent 70%); }
++.pty-dur { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 6px; background: rgba(0, 0, 0, .78); color: #fff; font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
++/* The move a card makes, said on the picture on hover and keyboard focus. A touch screen has no hover: the whole card
++ is the link and the heading says what it does, so eight pills over eight pictures would only be noise. */
++.pty-go { position: absolute; left: 8px; top: 8px; padding: 4px 10px; border-radius: 999px; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); font-size: 12px; font-weight: 800; opacity: 0; transform: translateY(-4px); transition: opacity .15s ease, transform .15s ease; }
++.pty-card a:hover .pty-go, .pty-card a:focus-visible .pty-go { opacity: 1; transform: none; }
++.pty-card a:hover .pty-thumb img { transform: scale(1.03); }
++.pty-title { font-weight: 700; font-size: 14.5px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
++.pty-by { font-size: 13px; color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.pty-more { margin: 16px 0 0; font-size: 14px; color: var(--oc-muted); }
++
++.pty-how ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; counter-reset: pty; }
++@media (min-width: 720px) { .pty-how ol { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } }
++.pty-how li { counter-increment: pty; display: grid; grid-template-columns: auto 1fr; column-gap: 12px; row-gap: 2px; align-items: start; padding: 14px 16px; border-radius: 14px; background: var(--oc-panel); border: 1px solid var(--oc-line); }
++.pty-how li::before { content: counter(pty); grid-row: span 2; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-weight: 850; color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }
++.pty-how li strong { font-size: 15px; }
++.pty-how li span { color: var(--oc-muted); font-size: 14px; }
++
++.pty-confirm { max-width: 640px; display: grid; gap: 12px; padding-block: 24px; }
++.pty-confirm p { margin: 0; }
++.pty-confirm h1 { margin: 0; font-size: clamp(1.6rem, 5vw, 2.2rem); letter-spacing: -.02em; }
++.pty-confirm-thumb { width: 100%; max-width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 14px; border: 1px solid var(--oc-line); background: var(--oc-panel-2); }
++.pty-confirm form { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 4px; }
++.pty-confirm form .oc-button { min-height: 48px; padding: 0 22px; border-radius: 12px; }
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex e25aeae..738fe9c 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -158,6 +158,7 @@ ${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
++${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex 990ee6c..7c0867d 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -74,26 +74,107 @@ function createPartyRoutes({ config }) {
const notice = (kind, text) => `<p class="oc-notice oc-${kind}" role="${kind === 'error' ? 'alert' : 'status'}">${esc(text)}</p>`;
++ // ── OpenVibe.Video (MediaHub /api/v1/video/watchable): what a party plays for a watch page, and what to pick ──
++ // A person pastes a watch page (openvibe.video/watch?v=…), not a file: Video says which file plays, its title and its
++ // picture, for anything anyone with the link may watch (public or unlisted, ready, no content label). The home's
++ // grid is Video's latest. Both are read from the server with a short timeout and cached a minute; Video slow or down
++ // leaves the grid out and a pasted page unresolved (the person is told), never a broken party.
++ // A test names its own stand-in; a test that does not never reaches the real site.
++ const videoUrl = () => String(process.env.CHAT_PARTY_VIDEO_URL || (process.env.NODE_ENV === 'test' ? '' : 'https://openvibe.video')).replace(/\/+$/, '');
++ const PAGE_HOSTS = new Set(['openvibe.video', 'www.openvibe.video', 'openvibe.live', 'www.openvibe.live']);
++ const watchCache = new Map(); // key → { at, value }
++ async function fromVideo(what, path) {
++ const at = videoUrl();
++ if (!at) return null;
++ const key = `${at} ${what}`;
++ const hit = watchCache.get(key);
++ if (hit && Date.now() - hit.at < 60_000) return hit.value;
++ let value = null;
++ try {
++ const r = await fetch(`${at}${path}`, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(2500) });
++ value = r.ok ? await r.json() : null;
++ } catch { value = hit ? hit.value : null; }
++ if (watchCache.size > 500) watchCache.clear();
++ watchCache.set(key, { at: Date.now(), value });
++ return value;
++ }
++ const httpsOrNull = (v) => (typeof v === 'string' && /^https:\/\/[^\s"'<>]{1,2048}$/.test(v) ? v : null);
++ /** A watch page's party media: { media, title, source_url, thumbnail_url } — or null when it is not one Video vouches for. */
++ async function watchablePage(url) {
++ let u;
++ try { u = new URL(url); } catch { return null; }
++ if (u.protocol !== 'https:' || !PAGE_HOSTS.has(u.hostname.toLowerCase())) return null;
++ const item = await fromVideo(`one:${url}`, `/api/v1/video/watchable?url=${encodeURIComponent(url)}`);
++ const media = item && httpsOrNull(item.file_url);
++ if (!media) return null;
++ return { media, title: String(item.title || '').slice(0, 200), source_url: httpsOrNull(item.page_url) || url, thumbnail_url: httpsOrNull(item.thumbnail_url) };
++ }
++ const isWatchPage = (url) => { try { const u = new URL(url); return PAGE_HOSTS.has(u.hostname.toLowerCase()); } catch { return false; } };
++ async function latestWatchable() {
++ const doc = await fromVideo('latest', '/api/v1/video/watchable/latest?limit=8');
++ return (doc && Array.isArray(doc.items) ? doc.items : [])
++ .filter((x) => x && httpsOrNull(x.page_url) && isWatchPage(x.page_url) && typeof x.title === 'string')
++ .slice(0, 8);
++ }
++ /** What a person asked to watch, made playable: a watch page becomes its file; a file stays as it is. */
++ async function playable({ media, title, source }) {
++ if (!isWatchPage(media)) return { media, title, source, thumb: null };
++ const page = await watchablePage(media);
++ if (!page) return null;
++ return { media: page.media, title: title || page.title, source: page.source_url, thumb: page.thumbnail_url };
++ }
++ const sized = (thumb, w) => {
++ if (!thumb) return null;
++ try { return new URL(thumb).hostname === 'openvibe.media' ? `${thumb}${thumb.includes('?') ? '&' : '?'}w=${w}&fmt=webp` : thumb; } catch { return null; }
++ };
++ const duration = (sec) => {
++ const n = Number(sec);
++ if (!(n > 0)) return '';
++ const h = Math.floor(n / 3600), m = Math.floor((n % 3600) / 60), x = Math.floor(n % 60);
++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(x).padStart(2, '0')}` : `${m}:${String(x).padStart(2, '0')}`;
++ };
++
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
-- const form = signedIn
-- ? `<form class="oc-party-start" method="get" action="/new">
--<label for="party-media">Paste an OpenVibe link</label>
--<input id="party-media" name="media" type="url" inputmode="url" autocomplete="off" required placeholder="https://openvibe.live/v/…">
--<button type="submit" class="oc-button">Start a party</button>
++ const picks = await latestWatchable();
++ const startHref = (x) => `/new?media=${encodeURIComponent(x.page_url)}&title=${encodeURIComponent(x.title)}`;
++ const start = signedIn
++ ? `<form class="pty-paste" method="get" action="/new">
++<label for="party-media">Paste a video link</label>
++<div class="pty-paste-row"><input id="party-media" name="media" type="url" inputmode="url" autocomplete="off" required placeholder="https://openvibe.video/watch?v=…"><button type="submit" class="oc-button">Start a party</button></div>
++<p class="pty-hint">A recording, a clip or an upload on OpenVibe.Video, or a video file on OpenVibe.</p>
</form>`
-- : `<p class="oc-party-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to start a party.</p>`;
++ : `<p class="pty-cta"><a class="oc-button" href="/auth/login?next=%2F">Sign in to start a party</a><span class="pty-hint">Free with your OpenVibe account.</span></p>`;
++ const grid = picks.length ? `<section class="pty-pick" aria-labelledby="pty-pick-h">
++<h2 id="pty-pick-h">Pick something to watch</h2>
++<ul class="pty-grid">${picks.map((x) => `<li class="pty-card"><a href="${esc(startHref(x))}">
++<span class="pty-thumb">${sized(x.thumbnail_url, 480) ? `<img src="${esc(sized(x.thumbnail_url, 480))}" alt="" loading="lazy" decoding="async">` : '<span class="pty-thumb-none" aria-hidden="true"></span>'}${duration(x.duration_s) ? `<span class="pty-dur">${esc(duration(x.duration_s))}</span>` : ''}<span class="pty-go" aria-hidden="true">Watch together</span></span>
++<span class="pty-title">${esc(x.title)}</span>
++${x.channel ? `<span class="pty-by">@${esc(String(x.channel).slice(0, 40))}</span>` : ''}
++</a></li>`).join('')}</ul>
++<p class="pty-more"><a href="https://openvibe.video/">More on OpenVibe.Video</a>: every watch page has a Watch together button.</p>
++</section>` : '';
await page(req, res, 200, {
-- actor, title: 'Watch together', path: '/', robots: 'index, follow', footer: 'full',
++ actor, title: 'Watch together', path: '/', robots: 'index, follow', footer: 'full', partyHome: true,
description: DESCRIPTION,
-- body: `<header class="oc-party-hero">
++ body: `<header class="pty-hero">
++<p class="pty-eyebrow">OpenVibe.Party</p>
<h1>Watch together</h1>
--<p class="oc-lede">Start a party on any OpenVibe video: everyone watches the same moment, and the conversation is right there.</p>
--</header>
++<p class="oc-lede">Everyone sees the same moment, with the chat beside it. One person holds the remote, and can pass it on.</p>
${error}
--${form}`,
++${start}
++</header>
++${grid}
++<section class="pty-how" aria-labelledby="pty-how-h">
++<h2 id="pty-how-h">How it works</h2>
++<ol>
++<li><strong>Pick a video</strong><span>Any recording, clip or upload on OpenVibe.Video.</span></li>
++<li><strong>Send the invite</strong><span>The link lets your friends in. Nobody else finds the party.</span></li>
++<li><strong>Watch in step</strong><span>Whoever holds the remote plays, pauses and skips for everyone.</span></li>
++</ol>
++</section>`,
});
});
@@@ -102,16 +183,21 @@ ${form}`,
router.get('/new', async (req, res) => {
const actor = await viewerOf(req);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
-- const media = String(req.query.media || '').trim();
-- if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
-- const title = String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);
-- const source = req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '';
++ const asked = String(req.query.media || '').trim();
++ if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
++ const want = await playable({ media: asked, title: String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '' });
++ if (!want) return res.redirect(303, `/?error=${encodeURIComponent('That link is not a video anyone can watch together: it may be private, still processing, or not on OpenVibe.')}`);
++ const media = want.media;
++ const title = want.title || titleFromMedia(media);
++ const source = want.source || '';
let host = '';
-- try { host = new URL(media).hostname; } catch { host = ''; }
++ try { host = new URL(source || media).hostname; } catch { host = ''; }
await page(req, res, 200, {
actor, title: 'Start a party', path: '/new', robots: 'noindex, nofollow', footer: 'compact',
-- body: `<section class="oc-party-confirm">
++ partyHome: true,
++ body: `<section class="oc-party-confirm pty-confirm">
<h1>Start a watch party?</h1>
++${want.thumb && sized(want.thumb, 640) ? `<img class="pty-confirm-thumb" src="${esc(sized(want.thumb, 640))}" alt="" decoding="async">` : ''}
<p class="oc-lede"><strong>${esc(title)}</strong>${host ? ` <span class="oc-muted">from ${esc(host)}</span>` : ''}</p>
<p class="oc-muted">Everyone you invite watches the same moment, with a chat beside it. You hold the remote, and you can pass it on.</p>
<form method="post" action="/new">
@@@ -131,11 +217,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
const origin = String(req.get('origin') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');
-- const media = String((req.body && req.body.media) || '').trim();
-- if (!media) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
-- const title = String((req.body && req.body.title) || '').replace(/\s+/g, ' ').trim().slice(0, 200) || titleFromMedia(media);
++ const asked = String((req.body && req.body.media) || '').trim();
++ if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
++ // A watch page posted here directly (not through the confirmation) is made playable the same way.
++ const want = await playable({ media: asked, title: String((req.body && req.body.title) || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: (req.body && req.body.source_url) || '' });
++ if (!want) return res.redirect(303, `/?error=${encodeURIComponent('That link is not a video anyone can watch together: it may be private, still processing, or not on OpenVibe.')}`);
++ const media = want.media;
++ const title = want.title || titleFromMedia(media);
try {
-- const out = await parties.create(actor.user, { media, source_url: (req.body && req.body.source_url) || null, title });
++ const out = await parties.create(actor.user, { media, source_url: want.source || null, title });
return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
} catch (err) {
const message = err instanceof parties.PartyError ? err.message : 'That party could not be started';
ddiff --git a/test/party-media.test.js b/test/party-media.test.js
iindex d7e1dd8..2188c86 100644
---- a/test/party-media.test.js
++++ b/test/party-media.test.js
@@@ -223,6 +223,70 @@ t('openvibe.party: the start form, sign-in, and /new', async () => {
assert.strictEqual(bobNow.body.party.member, true, 'opening the link makes the person a member');
});
++t('the home offers OpenVibe.Video\'s latest, and a pasted watch page plays its file', async () => {
++ // A stand-in for OpenVibe.Video's /api/v1/video/watchable (MediaHub): one public recording, one private page.
++ const PAGE = 'https://openvibe.video/watch?v=AbCdEfGhIjK';
++ const PRIVATE = 'https://openvibe.video/watch?v=PrIvAtEpAgE';
++ const FILE = 'https://openvibe.media/v/4242';
++ let up = true;
++ const video = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (!up) { res.writeHead(503); return res.end(); }
++ res.setHeader('content-type', 'application/json');
++ if (u.pathname === '/api/v1/video/watchable/latest') {
++ return res.end(JSON.stringify({ items: [
++ { kind: 'vod', title: 'Robots <3 paint', page_url: PAGE, thumbnail_url: 'https://openvibe.media/thumb/4242.jpg', duration_s: 3725, channel: 'ann' },
++ { kind: 'clip', title: 'Elsewhere', page_url: 'https://evil.example/watch?v=x', thumbnail_url: null, duration_s: 9, channel: null },
++ ] }));
++ }
++ if (u.pathname === '/api/v1/video/watchable' && u.searchParams.get('url') === PAGE) {
++ return res.end(JSON.stringify({ kind: 'vod', title: 'Robots <3 paint', page_url: PAGE, file_url: FILE, thumbnail_url: 'https://openvibe.media/thumb/4242.jpg', duration_s: 3725 }));
++ }
++ res.writeHead(404); res.end('{}');
++ });
++ await new Promise((r) => video.listen(0, '127.0.0.1', r));
++ process.env.CHAT_PARTY_VIDEO_URL = `http://127.0.0.1:${video.address().port}`;
++ try {
++ const home = await raw('GET', '/', { cookie: cookieOf(ann) });
++ assert.strictEqual(home.status, 200);
++ assert.match(home.text, /Pick something to watch/);
++ assert.match(home.text, /Robots &lt;3 paint/, 'the title, escaped');
++ assert.match(home.text, /href="\/new\?media=https%3A%2F%2Fopenvibe\.video%2Fwatch%3Fv%3DAbCdEfGhIjK&amp;title=/, 'a card starts a party on its page');
++ assert.match(home.text, /1:02:05/, 'its length');
++ assert.ok(!home.text.includes('evil.example'), 'a page on another site is never offered');
++ assert.match(home.text, /\/web\/party-home\.css/, 'the home\'s styles');
++ assert.match(home.text, /How it works/);
++
++ // A pasted watch page: the confirmation names it, and the party plays its file with the page as its source.
++ const asks = await raw('GET', `/new?media=${encodeURIComponent(PAGE)}`, { cookie: cookieOf(ann) });
++ assert.strictEqual(asks.status, 200, asks.text.slice(0, 300));
++ assert.match(asks.text, /Robots &lt;3 paint/, 'the title from Video');
++ assert.match(asks.text, /name="media" value="https:\/\/openvibe\.media\/v\/4242"/, 'the file, not the page');
++ const made = await raw('POST', '/new', { cookie: cookieOf(ann), form: { media: PAGE }, headers: { origin: 'https://openvibe.party', 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
++ const slug = String(made.headers.location).match(/\/p\/([^?]+)/)[1];
++ const shell = await raw('GET', made.headers.location, { cookie: cookieOf(ann) });
++ const data = JSON.parse(shell.text.match(/<script type="application\/json" id="party-data">([\s\S]*?)<\/script>/)[1]);
++ assert.strictEqual(data.party.clock.media, FILE, 'the party plays the file');
++ assert.strictEqual(data.party.source_url, PAGE, 'and keeps the page it came from');
++ assert.ok(slug);
++
++ // A private (or unknown) page is not made into a party.
++ const refused = await raw('GET', `/new?media=${encodeURIComponent(PRIVATE)}`, { cookie: cookieOf(ann) });
++ assert.strictEqual(refused.status, 303);
++ assert.match(String(refused.headers.location), /^\/\?error=That%20link%20is%20not%20a%20video/);
++
++ // Video down: the home still answers, without the grid.
++ up = false;
++ await new Promise((r) => setTimeout(r, 10));
++ const quiet = await raw('GET', '/?fresh=1', { cookie: cookieOf(bob) });
++ assert.strictEqual(quiet.status, 200);
++ } finally {
++ delete process.env.CHAT_PARTY_VIDEO_URL;
++ await new Promise((r) => video.close(r));
++ }
++});
++
t('close', async () => {
await h.close();
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 929a697..1ec8035 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -123,6 +123,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/tts-engine.js': 'Google / AWS TTS (fixed hosts)',
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",
++ 'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',
'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
};
const root = path.join(__dirname, '..');