openvibers/openvibe.chat Commits
openvibe.party's home: pick something to watch, paste a watch page, how it works (#394)
Changes
nnew file mode 100644@@@ -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; }@@@ -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>@@@ -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';@@@ -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 <3 paint/, 'the title, escaped');++ assert.match(home.text, /href="\/new\?media=https%3A%2F%2Fopenvibe\.video%2Fwatch%3Fv%3DAbCdEfGhIjK&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 <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();});@@@ -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, '..');
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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; }
@@ -158,6 +158,7 @@ ${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
@@ -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.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.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.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.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')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
@@ -74,26 +74,107 @@ function createPartyRoutes({ config }) {
@@ -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>`;
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) => {
router.get('/', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const signedIn = actor.kind === 'user';
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
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>`
</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, {
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,
description: DESCRIPTION,
- body: `<header class="oc-party-hero">
+ body: `<header class="pty-hero">
+<p class="pty-eyebrow">OpenVibe.Party</p>
<h1>Watch together</h1>
<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}
${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}`,
@@ -102,16 +183,21 @@ ${form}`,
router.get('/new', async (req, res) => {
router.get('/new', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
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 = '';
let host = '';
- try { host = new URL(media).hostname; } catch { host = ''; }
+ try { host = new URL(source || media).hostname; } catch { host = ''; }
await page(req, res, 200, {
await page(req, res, 200, {
actor, title: 'Start a party', path: '/new', robots: 'noindex, nofollow', footer: 'compact',
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>
<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-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>
<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">
<form method="post" action="/new">
@@ -131,11 +217,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
@@ -131,11 +217,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
const origin = String(req.get('origin') || '');
const origin = String(req.get('origin') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
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.');
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 {
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)}`);
return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
} catch (err) {
} catch (err) {
const message = err instanceof parties.PartyError ? err.message : 'That party could not be started';
const message = err instanceof parties.PartyError ? err.message : 'That party could not be started';
@@ -223,6 +223,70 @@ t('openvibe.party: the start form, sign-in, and /new', async () => {
@@ -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');
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 <3 paint/, 'the title, escaped');
+ assert.match(home.text, /href="\/new\?media=https%3A%2F%2Fopenvibe\.video%2Fwatch%3Fv%3DAbCdEfGhIjK&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 <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 () => {
t('close', async () => {
await h.close();
await h.close();
});
});
@@ -123,6 +123,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -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/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/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/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',
'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, '..');
const root = path.join(__dirname, '..');