openvibers/openvibe.live Commits
Docs served on-site at /docs/<name>; WHIP ingest host → ingest.openvibe.live
Changes
@@@ -63,6 +63,8 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab## Documentation++Every file below is also served on the site at `/docs/<name>` (rendered by `server/docs/routes.js`, GitHub-compatible heading anchors) — link to `https://openvibe.live/docs/whip#…` rather than to GitHub when pointing users at a doc.++- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)@@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).--- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).++- WHIP/HTTP ingestion support (`ingest.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).- real-time broadcast and control channels.### Media (VODs / clips / pastes / thumbnails)@@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL## Deployment- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).--- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `whip.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).++- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).---@@@ -6,7 +6,7 @@#### There is also a generator (openvibe.network server/deploy/nginx-generator.js, driven## from the admin panel's Deploy → Nginx). Its entry for this site has drifted: it knows--## nothing about whip.openvibe.live or the connection limits below, so regenerating from++## nothing about whip.openvibe.live / ingest.openvibe.live or the connection limits below, so regenerating from## it would silently drop them. Update the generator too before using that path.## ═══════════════════════════════════════════════════════════════@@@ -22,7 +22,7 @@ limit_conn_zone $binary_remote_addr zone=asset_limit:10m;server {listen 80;listen [::]:80;-- server_name openvibe.live www.openvibe.live whip.openvibe.live;++ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;location /.well-known/acme-challenge/ { root /var/www/certbot; }location / { return 301 https://$host$request_uri; }@@@ -31,7 +31,7 @@ server {server {listen 443 ssl http2;listen [::]:443 ssl http2;-- server_name openvibe.live www.openvibe.live whip.openvibe.live;++ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;ssl_certificate /etc/letsencrypt/live/openvibe.live/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/openvibe.live/privkey.pem;@@@ -7,7 +7,7 @@ OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol,## Endpoint```--POST https://whip.openvibe.live/whip/{streamKey}++POST https://ingest.openvibe.live/whip/{streamKey}Content-Type: application/sdp```@@@ -76,7 +76,7 @@ Access-Control-Expose-Headers: Location, X-WHIP-ERRORThere are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:```bash--curl -si -X OPTIONS https://whip.openvibe.live/whip/x \++curl -si -X OPTIONS https://ingest.openvibe.live/whip/x \-H 'Origin: https://my-static-site.example' \-H 'Access-Control-Request-Method: POST' \-H 'Access-Control-Request-Headers: content-type'@@@ -85,14 +85,14 @@ curl -si -X OPTIONS https://whip.openvibe.live/whip/x \## Publishing from a browser--> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>++> **Direct link to this section:** <https://openvibe.live/docs/whip#publishing-from-a-browser> (also on [GitHub](https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser))> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.**Spec in five lines (paste-ready):**```textOpenVibe.Live WHIP ingest — browser publisher requirements--1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).++1. POST the SDP offer to https://ingest.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.@@@ -105,7 +105,7 @@ A complete publisher is about forty lines. This is the core of [`whip-publisher.<video id="preview" autoplay muted playsinline></video><button id="go">Go live</button><script>--const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';++const WHIP_URL = 'https://ingest.openvibe.live/whip/<your-stream-key>';let pc, resourceUrl;async function goLive() {@@@ -161,7 +161,7 @@ Things worth knowing:## Other clients- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.--- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`--- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).++- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://ingest.openvibe.live/whip/<streamKey>"`++- **GStreamer:** `... ! whipsink whip-endpoint="https://ingest.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.@@@ -14,7 +14,7 @@into your own site.Protocol walkthrough + API reference:-- https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md++ https://openvibe.live/docs/whip#publishing-from-a-browserProgrammatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })window.OpenVibeWhip.stop()@@@ -68,7 +68,7 @@<div class="card"><label for="whip-url">WHIP URL</label>-- <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">++ <input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false"><p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p><label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>@@@ -114,7 +114,7 @@<pre id="whip-log" class="log">Ready.</pre></div>-- <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>++ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://openvibe.live/docs/whip#publishing-from-a-browser">openvibe.live/docs/whip</a>. View-source this page to copy it.</p></main><script>@@@ -201,7 +201,7 @@const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {-- throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');++ throw new Error('WHIP URL should look like https://ingest.openvibe.live/whip/<stream-key>');}try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }nnew file mode 100644@@@ -0,0 +1,310 @@++/**++ * OpenVibe.Live — Docs pages++ *++ * Serves the Markdown files in ./docs as HTML at /docs/<name> (and /docs/<name>.md), so a++ * link such as https://openvibe.live/docs/whip#publishing-from-a-browser works without++ * sending people to GitHub. Heading ids follow GitHub's slug rules, so anchors copied from++ * GitHub keep working here and vice versa.++ *++ * The renderer covers exactly the Markdown subset the docs use — headings, paragraphs,++ * bullet/numbered lists, tables, fenced code, blockquotes, horizontal rules, and inline++ * code / bold / italic / links — with no dependency. Everything is HTML-escaped before++ * any inline formatting is applied, so document text can never inject markup.++ */++++const express = require('express');++const fs = require('fs');++const path = require('path');++++const router = express.Router();++const DOCS_DIR = path.join(__dirname, '../../docs');++const GITHUB_BLOB = 'https://github.com/OpenVibers/OpenVibe.Live/blob/main';++++// ── Helpers ──────────────────────────────────────────────────++++function escapeHtml(text) {++ return String(text)++ .replace(/&/g, '&')++ .replace(/</g, '<')++ .replace(/>/g, '>')++ .replace(/"/g, '"');++}++++// GitHub-style heading anchor: lowercase, drop punctuation, spaces → hyphens.++function slugify(text, used) {++ const base = String(text)++ .toLowerCase()++ .replace(/<[^>]+>/g, '')++ .replace(/&[a-z]+;|&#\d+;/g, '')++ .replace(/[^\p{L}\p{N}\s-]/gu, '')++ .trim()++ // Each space becomes a hyphen — GitHub does not collapse runs, so "VODs & Clips"++ // is "vods--clips" there, and anchors must round-trip between the two.++ .replace(/\s/g, '-');++ let slug = base || 'section';++ let n = 1;++ while (used.has(slug)) slug = `${base}-${n++}`;++ used.add(slug);++ return slug;++}++++/**++ * Rewrite a link target from the Markdown source so it works from /docs/<name>:++ * other-doc.md#x → /docs/other-doc#x (served here)++ * ../public/x.html → /x.html (served from the web root)++ * ../SETUP.md, hardware/README.md, … → the GitHub blob URL++ * Absolute URLs and same-page anchors pass through untouched.++ */++function rewriteHref(href) {++ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(href)) return href;++ const [target, hash] = href.split('#');++ const anchor = hash ? `#${hash}` : '';++ const clean = target.replace(/^\.\//, '');++ const docMatch = clean.match(/^([a-z0-9-]+)\.md$/i);++ if (docMatch) return `/docs/${docMatch[1]}${anchor}`;++ const publicMatch = clean.match(/^\.\.\/public\/(.+)$/);++ if (publicMatch) return `/${publicMatch[1]}${anchor}`;++ const repoPath = clean.startsWith('../') ? clean.slice(3) : `docs/${clean}`;++ return `${GITHUB_BLOB}/${repoPath}${anchor}`;++}++++/**++ * Inline formatting on already-escaped text. Code spans are lifted out first so that++ * `**`, `*`, `[`, and `<https://…>` inside them are left alone.++ */++function renderInline(escaped) {++ const codes = [];++ let text = escaped.replace(/`([^`]+)`/g, (_, code) => {++ codes.push(`<code>${code}</code>`);++ return `\u0000${codes.length - 1}\u0000`;++ });++ text = text++ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => {++ const target = rewriteHref(href.replace(/&/g, '&'));++ const external = /^https?:\/\//i.test(target) && !target.startsWith('https://openvibe.live');++ return `<a href="${escapeHtml(target)}"${external ? ' rel="noopener"' : ''}>${label}</a>`;++ })++ .replace(/<(https?:\/\/[^\s&]+)>/g, (_, url) => `<a href="${url}">${url}</a>`)++ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')++ .replace(/(^|[\s(])\*([^*\s][^*]*?)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');++ return text.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);++}++++function splitTableRow(line) {++ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());++}++++// ── Block renderer ───────────────────────────────────────────++++function renderMarkdown(markdown) {++ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');++ const out = [];++ const usedSlugs = new Set();++ const headings = [];++ let title = null;++ let i = 0;++++ const flushParagraph = (buf) => {++ if (buf.length) out.push(`<p>${renderInline(escapeHtml(buf.join(' ')))}</p>`);++ buf.length = 0;++ };++++ const para = [];++ while (i < lines.length) {++ const line = lines[i];++++ // Fenced code++ const fence = line.match(/^```\s*([\w+-]*)\s*$/);++ if (fence) {++ flushParagraph(para);++ const lang = fence[1];++ const code = [];++ i++;++ while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);++ i++; // closing fence++ out.push(`<pre${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code.join('\n'))}</code></pre>`);++ continue;++ }++++ // Heading++ const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);++ if (heading) {++ flushParagraph(para);++ const level = heading[1].length;++ const html = renderInline(escapeHtml(heading[2]));++ const id = slugify(heading[2], usedSlugs);++ if (level === 1 && title === null) title = heading[2];++ if (level === 2) headings.push({ id, text: heading[2] });++ out.push(`<h${level} id="${id}">${html}<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`);++ i++;++ continue;++ }++++ // Horizontal rule++ if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {++ flushParagraph(para);++ out.push('<hr>');++ i++;++ continue;++ }++++ // Blockquote++ if (/^>/.test(line)) {++ flushParagraph(para);++ const quote = [];++ while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ''));++ // Blank quote lines separate paragraphs; consecutive lines join.++ const paras = quote.join('\n').split(/\n\s*\n/).map(p => p.replace(/\n/g, ' ').trim()).filter(Boolean);++ out.push(`<blockquote>${paras.map(p => `<p>${renderInline(escapeHtml(p))}</p>`).join('')}</blockquote>`);++ continue;++ }++++ // Table: header row followed by a |---| separator++ if (/^\|/.test(line) && i + 1 < lines.length && /^\|?\s*:?-{2,}/.test(lines[i + 1])) {++ flushParagraph(para);++ const header = splitTableRow(line);++ i += 2;++ const rows = [];++ while (i < lines.length && /^\|/.test(lines[i])) rows.push(splitTableRow(lines[i++]));++ const cell = (tag, c) => `<${tag}>${renderInline(escapeHtml(c))}</${tag}>`;++ out.push('<div class="table-wrap"><table>'++ + `<thead><tr>${header.map(c => cell('th', c)).join('')}</tr></thead>`++ + `<tbody>${rows.map(r => `<tr>${r.map(c => cell('td', c)).join('')}</tr>`).join('')}</tbody>`++ + '</table></div>');++ continue;++ }++++ // Lists (bulleted or numbered); indented lines continue the current item.++ const listStart = line.match(/^(\s*)([-*+]|\d+[.)])\s+/);++ if (listStart && listStart[1].length === 0) {++ flushParagraph(para);++ const ordered = /\d/.test(listStart[2]);++ const items = [];++ while (i < lines.length) {++ const m = lines[i].match(/^([-*+]|\d+[.)])\s+(.*)$/);++ if (m && (/\d/.test(m[1]) === ordered)) {++ items.push([m[2]]);++ i++;++ } else if (items.length && /^\s{2,}\S/.test(lines[i])) {++ items[items.length - 1].push(lines[i].trim());++ i++;++ } else {++ break;++ }++ }++ const tag = ordered ? 'ol' : 'ul';++ out.push(`<${tag}>${items.map(parts => `<li>${parts.map(p => renderInline(escapeHtml(p))).join('<br>')}</li>`).join('')}</${tag}>`);++ continue;++ }++++ // Blank line ends a paragraph++ if (!line.trim()) {++ flushParagraph(para);++ i++;++ continue;++ }++++ para.push(line.trim());++ i++;++ }++ flushParagraph(para);++++ return { html: out.join('\n'), title, headings };++}++++// ── Page shell ───────────────────────────────────────────────++++function renderPage({ name, title, html, headings }) {++ const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';++ const toc = headings.length++ ? `<nav class="toc" aria-label="On this page">${headings.map(h => `<a href="#${h.id}">${escapeHtml(h.text)}</a>`).join('')}</nav>`++ : '';++ return `<!DOCTYPE html>++<html lang="en">++<head>++ <meta charset="UTF-8">++ <meta name="viewport" content="width=device-width, initial-scale=1.0">++ <title>${escapeHtml(pageTitle)}</title>++ <link rel="icon" href="/assets/favicon.ico">++ <style>++ :root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;++ --accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }++ * { box-sizing: border-box; }++ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++ a { color: var(--accent-light); }++ .top { display: flex; align-items: center; gap: 14px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-2); font-size: 0.9rem; }++ .top .brand { font-weight: 700; color: var(--text); text-decoration: none; display: flex; align-items: center; gap: 8px; }++ .top .brand::before { content: ''; width: 18px; height: 18px; border-radius: 5px; background: linear-gradient(135deg, #8b5cf6, #6d28d9); }++ .top .sep { color: var(--muted); }++ .top .right { margin-left: auto; display: flex; gap: 14px; }++ main { max-width: 880px; margin: 0 auto; padding: 24px 20px 64px; }++ h1 { font-size: 1.9rem; margin: 0 0 12px; line-height: 1.25; }++ h2 { font-size: 1.35rem; margin: 2.2em 0 0.6em; padding-bottom: 6px; border-bottom: 1px solid var(--border); }++ h3 { font-size: 1.1rem; margin: 1.8em 0 0.5em; }++ h4 { font-size: 1rem; margin: 1.5em 0 0.4em; }++ h1, h2, h3, h4 { scroll-margin-top: 16px; }++ .anchor { margin-left: 8px; color: var(--muted); text-decoration: none; opacity: 0; font-weight: 400; }++ h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, .anchor:focus { opacity: 1; }++ p, ul, ol, blockquote, pre, .table-wrap { margin: 0 0 1em; }++ li { margin: 0.25em 0; }++ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--bg-3); padding: 1px 5px; border-radius: 4px; }++ pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; overflow-x: auto; position: relative; }++ pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.5; }++ pre[data-lang]::before { content: attr(data-lang); position: absolute; top: 6px; right: 12px; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }++ blockquote { border-left: 3px solid var(--accent); background: rgba(139, 92, 246, 0.08); padding: 10px 16px; border-radius: 0 8px 8px 0; }++ blockquote p:last-child { margin: 0; }++ .table-wrap { overflow-x: auto; }++ table { border-collapse: collapse; width: 100%; font-size: 0.92rem; }++ th, td { text-align: left; padding: 8px 10px; border: 1px solid var(--border); vertical-align: top; }++ th { background: var(--bg-2); }++ tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }++ hr { border: 0; border-top: 1px solid var(--border); margin: 2em 0; }++ .toc { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; margin: 0 0 24px; padding: 12px 14px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; }++ .toc a { color: var(--text-2); text-decoration: none; }++ .toc a:hover { color: var(--accent-light); }++ .foot { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 0.85rem; color: var(--text-2); }++ </style>++</head>++<body>++<header class="top">++ <a class="brand" href="/">OpenVibe.Live</a><span class="sep">/</span><a href="/docs">Docs</a>++ <div class="right"><a href="${GITHUB_BLOB}/docs/${escapeHtml(name)}.md" rel="noopener">Source on GitHub</a></div>++</header>++<main>++${toc}++${html}++<p class="foot">This page is rendered from <code>docs/${escapeHtml(name)}.md</code> in the <a href="https://github.com/OpenVibers/OpenVibe.Live" rel="noopener">OpenVibe.Live repository</a>. Found a mistake? Edit it there.</p>++</main>++</body>++</html>`;++}++++// ── Routes ───────────────────────────────────────────────────++++function loadDoc(name) {++ if (!/^[a-z0-9-]+$/i.test(name)) return null;++ const file = path.join(DOCS_DIR, `${name}.md`);++ if (!file.startsWith(DOCS_DIR + path.sep)) return null;++ try {++ return fs.readFileSync(file, 'utf8');++ } catch {++ return null;++ }++}++++function sendDoc(req, res, name) {++ const markdown = loadDoc(name);++ if (markdown === null) return res.status(404).type('text/plain').send('No such doc');++ const rendered = renderMarkdown(markdown);++ res.set('Cache-Control', 'public, max-age=300');++ res.type('html').send(renderPage({ name, ...rendered }));++}++++router.get('/', (req, res) => sendDoc(req, res, 'README'));++router.get('/:name.md', (req, res) => sendDoc(req, res, req.params.name));++router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));++++module.exports = router;++module.exports._renderMarkdown = renderMarkdown;++module.exports._rewriteHref = rewriteHref;++module.exports._slugify = slugify;@@@ -213,7 +213,7 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the--// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different++// dedicated WHIP host when one is configured (ingest.openvibe.live), which is a different// origin from the page itself and would otherwise be blocked by connect-src 'self'.// Resolved per request, not at boot: config.whip is (re)filled by the URL-registry refresh// in start(), which runs after this middleware is built — a value captured here would be@@@ -640,6 +640,12 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re}});++// ── Docs ─────────────────────────────────────────────────────++// docs/*.md rendered as HTML at /docs/<name> (+ /docs/<name>.md) with GitHub-compatible++// heading anchors, so https://openvibe.live/docs/whip#publishing-from-a-browser is a++// link people can be sent to without leaving the site.++app.use('/docs', require('./docs/routes'));++// ── OBS Overlay Widgets ──────────────────────────────────────// Modular system: /obs/<widget>/<username>// Each widget is a standalone HTML page designed for OBS browser sources.@@@ -449,6 +449,13 @@ async function buildSitemap() {const urls = [];const seenChannels = new Set();const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];++ // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.++ try {++ for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {++ if (!f.endsWith('.md')) continue;++ statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', '0.5']);++ }++ } catch { /* docs folder absent in some deploys */ }for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));const page = async (fetch, per, cap, emit) => {let off = 0;nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++++// Regression tests for the on-site docs renderer (server/docs/routes.js).++// Every docs/*.md must render without leaking raw Markdown, heading anchors must match++// GitHub's slugs (links are shared between the site and the repo), and relative links++// must be rewritten to places that exist on the site.++++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { _renderMarkdown: render, _rewriteHref: rewriteHref, _slugify: slugify } = require('../server/docs/routes');++++// ── Heading slugs (GitHub-compatible) ──++const used = new Set();++assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser');++assert.strictEqual(slugify('Cross-origin (CORS)', used), 'cross-origin-cors');++assert.strictEqual(slugify('VODs & Clips', used), 'vods--clips');++assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser-1', 'duplicate headings get a numeric suffix');++console.log('✅ heading slugs match GitHub');++++// ── Link rewriting ──++assert.strictEqual(rewriteHref('broadcasting.md'), '/docs/broadcasting');++assert.strictEqual(rewriteHref('whip.md#publishing-from-a-browser'), '/docs/whip#publishing-from-a-browser');++assert.strictEqual(rewriteHref('../public/whip-publisher.html'), '/whip-publisher.html');++assert.strictEqual(rewriteHref('../SETUP.md'), 'https://github.com/OpenVibers/OpenVibe.Live/blob/main/SETUP.md');++assert.strictEqual(rewriteHref('https://example.com/x'), 'https://example.com/x');++assert.strictEqual(rewriteHref('#endpoint'), '#endpoint');++assert.strictEqual(rewriteHref('/whip-publisher.html'), '/whip-publisher.html');++console.log('✅ relative links rewritten to on-site or GitHub targets');++++// ── Inline formatting + escaping ──++const inline = render('Text (CORS `*`) and `fetch()`. **bold**, `code **kept**`, <https://a.b/c>, and <script>x</script>.').html;++assert.ok(inline.includes('(CORS <code>*</code>)'), 'code span with no stray placeholder spaces');++assert.ok(inline.includes('<code>code **kept**</code>'), 'markdown inside code spans is left alone');++assert.ok(inline.includes('<strong>bold</strong>'));++assert.ok(inline.includes('<a href="https://a.b/c">https://a.b/c</a>'), 'autolinks');++assert.ok(inline.includes('<script>x</script>'), 'raw HTML is escaped, never emitted');++assert.ok(!inline.includes(String.fromCharCode(0)), 'no placeholder bytes leak into output');++console.log('✅ inline formatting and HTML escaping');++++// ── Blocks ──++const blocks = render([++ '# Title',++ '',++ '> quoted line one',++ '> quoted line two',++ '',++ '| A | B |',++ '|---|---|',++ '| 1 | `2` |',++ '',++ '1. first',++ ' continued',++ '2. second',++ '',++ '- bullet',++ '',++ '```bash',++ 'curl -X POST "<url>"',++ '```',++].join('\n'));++assert.strictEqual(blocks.title, 'Title');++assert.ok(blocks.html.includes('<blockquote><p>quoted line one quoted line two</p></blockquote>'));++assert.ok(blocks.html.includes('<th>A</th>') && blocks.html.includes('<td><code>2</code></td>'), 'tables');++assert.ok(blocks.html.includes('<ol><li>first<br>continued</li><li>second</li></ol>'), 'ordered list with continuation line');++assert.ok(blocks.html.includes('<ul><li>bullet</li></ul>'));++assert.ok(blocks.html.includes('<pre data-lang="bash"><code>curl -X POST "<url>"</code></pre>'), 'fenced code is escaped verbatim');++console.log('✅ block elements');++++// ── Every shipped doc renders cleanly with the anchors people link to ──++const docsDir = path.join(__dirname, '../docs');++const leakPatterns = [/```/, /\*\*/, /^\s*\|/m, /\]\(/, /^#{1,6} /m];++for (const file of fs.readdirSync(docsDir).filter(f => f.endsWith('.md'))) {++ const result = render(fs.readFileSync(path.join(docsDir, file), 'utf8'));++ assert.ok(result.title, `${file}: has an H1 title`);++ const textOnly = result.html++ .replace(/<pre[\s\S]*?<\/pre>/g, '')++ .replace(/<code>[\s\S]*?<\/code>/g, '')++ .replace(/<[^>]+>/g, '');++ for (const re of leakPatterns) assert.ok(!re.test(textOnly), `${file}: raw markdown leaked (${re})`);++}++const whip = render(fs.readFileSync(path.join(docsDir, 'whip.md'), 'utf8')).html;++assert.ok(whip.includes('id="publishing-from-a-browser"'), 'the browser-publishing anchor that is linked publicly must exist');++assert.ok(whip.includes('href="/whip-publisher.html"'), 'link to the hosted publisher resolves on-site');++console.log('✅ all docs/*.md render without leaking markdown; public anchors present');++++console.log('\n✅ All docs renderer tests passed');
@@ -63,6 +63,8 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
@@ -63,6 +63,8 @@ Standalone Node scripts in `test/` using `assert`. They create temp SQLite datab
## Documentation
## Documentation
+Every file below is also served on the site at `/docs/<name>` (rendered by `server/docs/routes.js`, GitHub-compatible heading anchors) — link to `https://openvibe.live/docs/whip#…` rather than to GitHub when pointing users at a doc.
+
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/architecture.md](docs/architecture.md) — System design, data flows, module map
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
@@ -35,7 +35,7 @@ This repository contains the OpenVibe.Live server, browser assets, and runtime c
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- RTMP ingest using `node-media-server` (ports 1935 / 9935, public host `ingest.openvibe.live`).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- optional WebRTC SFU via `mediasoup` (ports 11000-11300 in production).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
- JSMPEG relay (TLS relay ports 9710-9789 via nginx stream).
-- WHIP/HTTP ingestion support (`whip.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
+- WHIP/HTTP ingestion support (`ingest.openvibe.live`) — open CORS, so even a backend-less web page can publish; see [docs/whip.md → Publishing from a browser](docs/whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html).
- real-time broadcast and control channels.
- real-time broadcast and control channels.
### Media (VODs / clips / pastes / thumbnails)
### Media (VODs / clips / pastes / thumbnails)
@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -118,7 +118,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
## Deployment
## Deployment
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
- Production path `/opt/openvibe.live`, env file `/etc/openvibe/live.env` (0600), unit `openvibe-live.service` (see `deploy/systemd/`).
-- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `whip.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
+- nginx reference config at `deploy/nginx/openvibe.live.conf` (`openvibe.live`, `www.openvibe.live`, `ingest.openvibe.live`; certs `/etc/letsencrypt/live/openvibe.live/`).
---
---
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
##
##
## There is also a generator (openvibe.network server/deploy/nginx-generator.js, driven
## There is also a generator (openvibe.network server/deploy/nginx-generator.js, driven
## from the admin panel's Deploy → Nginx). Its entry for this site has drifted: it knows
## from the admin panel's Deploy → Nginx). Its entry for this site has drifted: it knows
-## nothing about whip.openvibe.live or the connection limits below, so regenerating from
+## nothing about whip.openvibe.live / ingest.openvibe.live or the connection limits below, so regenerating from
## it would silently drop them. Update the generator too before using that path.
## it would silently drop them. Update the generator too before using that path.
## ═══════════════════════════════════════════════════════════════
## ═══════════════════════════════════════════════════════════════
@@ -22,7 +22,7 @@ limit_conn_zone $binary_remote_addr zone=asset_limit:10m;
@@ -22,7 +22,7 @@ limit_conn_zone $binary_remote_addr zone=asset_limit:10m;
server {
server {
listen 80;
listen 80;
listen [::]:80;
listen [::]:80;
- server_name openvibe.live www.openvibe.live whip.openvibe.live;
+ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
location / { return 301 https://$host$request_uri; }
@@ -31,7 +31,7 @@ server {
@@ -31,7 +31,7 @@ server {
server {
server {
listen 443 ssl http2;
listen 443 ssl http2;
listen [::]:443 ssl http2;
listen [::]:443 ssl http2;
- server_name openvibe.live www.openvibe.live whip.openvibe.live;
+ server_name openvibe.live www.openvibe.live whip.openvibe.live ingest.openvibe.live;
ssl_certificate /etc/letsencrypt/live/openvibe.live/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.live/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.live/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.live/privkey.pem;
@@ -7,7 +7,7 @@ OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol,
@@ -7,7 +7,7 @@ OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol,
## Endpoint
## Endpoint
```
```
-POST https://whip.openvibe.live/whip/{streamKey}
+POST https://ingest.openvibe.live/whip/{streamKey}
Content-Type: application/sdp
Content-Type: application/sdp
```
```
@@ -76,7 +76,7 @@ Access-Control-Expose-Headers: Location, X-WHIP-ERROR
@@ -76,7 +76,7 @@ Access-Control-Expose-Headers: Location, X-WHIP-ERROR
There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:
There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell:
```bash
```bash
-curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
+curl -si -X OPTIONS https://ingest.openvibe.live/whip/x \
-H 'Origin: https://my-static-site.example' \
-H 'Origin: https://my-static-site.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
-H 'Access-Control-Request-Headers: content-type'
@@ -85,14 +85,14 @@ curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
@@ -85,14 +85,14 @@ curl -si -X OPTIONS https://whip.openvibe.live/whip/x \
## Publishing from a browser
## Publishing from a browser
-> **Direct link to this section:** <https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser>
+> **Direct link to this section:** <https://openvibe.live/docs/whip#publishing-from-a-browser> (also on [GitHub](https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser))
> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.
> Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes.
**Spec in five lines (paste-ready):**
**Spec in five lines (paste-ready):**
```text
```text
OpenVibe.Live WHIP ingest — browser publisher requirements
OpenVibe.Live WHIP ingest — browser publisher requirements
-1. POST the SDP offer to https://whip.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
+1. POST the SDP offer to https://ingest.openvibe.live/whip/<streamKey> with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*).
2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).
2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS).
3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.
3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored.
4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.
4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional.
@@ -105,7 +105,7 @@ A complete publisher is about forty lines. This is the core of [`whip-publisher.
@@ -105,7 +105,7 @@ A complete publisher is about forty lines. This is the core of [`whip-publisher.
<video id="preview" autoplay muted playsinline></video>
<video id="preview" autoplay muted playsinline></video>
<button id="go">Go live</button>
<button id="go">Go live</button>
<script>
<script>
-const WHIP_URL = 'https://whip.openvibe.live/whip/<your-stream-key>';
+const WHIP_URL = 'https://ingest.openvibe.live/whip/<your-stream-key>';
let pc, resourceUrl;
let pc, resourceUrl;
async function goLive() {
async function goLive() {
@@ -161,7 +161,7 @@ Things worth knowing:
@@ -161,7 +161,7 @@ Things worth knowing:
## Other clients
## Other clients
- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.
- **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key.
-- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/<streamKey>"`
-- **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
+- **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://ingest.openvibe.live/whip/<streamKey>"`
+- **GStreamer:** `... ! whipsink whip-endpoint="https://ingest.openvibe.live/whip/<streamKey>"` (needs `gst-plugins-rs`).
The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.
The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods.
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
into your own site.
into your own site.
Protocol walkthrough + API reference:
Protocol walkthrough + API reference:
- https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md
+ https://openvibe.live/docs/whip#publishing-from-a-browser
Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })
Programmatic use: window.OpenVibeWhip.start({ url, token, source, maxBitrate })
window.OpenVibeWhip.stop()
window.OpenVibeWhip.stop()
@@ -68,7 +68,7 @@
@@ -68,7 +68,7 @@
<div class="card">
<div class="card">
<label for="whip-url">WHIP URL</label>
<label for="whip-url">WHIP URL</label>
- <input id="whip-url" type="url" placeholder="https://whip.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">
+ <input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">
<p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
<p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
@@ -114,7 +114,7 @@
@@ -114,7 +114,7 @@
<pre id="whip-log" class="log">Ready.</pre>
<pre id="whip-log" class="log">Ready.</pre>
</div>
</div>
- <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md">docs/whip.md</a>. View-source this page to copy it.</p>
+ <p class="hint">How it works: the page calls <code>getUserMedia</code>, builds a WebRTC offer, <code>POST</code>s it as <code>application/sdp</code> to the WHIP URL, applies the SDP answer, and <code>DELETE</code>s the returned resource when you stop. Full protocol notes in <a href="https://openvibe.live/docs/whip#publishing-from-a-browser">openvibe.live/docs/whip</a>. View-source this page to copy it.</p>
</main>
</main>
<script>
<script>
@@ -201,7 +201,7 @@
@@ -201,7 +201,7 @@
const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;
const maxBitrate = Number(opts.maxBitrate) || Number(kbps) * 1000;
if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {
if (!/^https?:\/\/[^/]+\/whip\/[^/?#]+\/?$/.test(url)) {
- throw new Error('WHIP URL should look like https://whip.openvibe.live/whip/<stream-key>');
+ throw new Error('WHIP URL should look like https://ingest.openvibe.live/whip/<stream-key>');
}
}
try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }
try { localStorage.setItem(STORAGE_KEY, url); } catch { /* private mode */ }
new file mode 100644
new file mode 100644
@@ -0,0 +1,310 @@
@@ -0,0 +1,310 @@
+/**
+ * OpenVibe.Live — Docs pages
+ *
+ * Serves the Markdown files in ./docs as HTML at /docs/<name> (and /docs/<name>.md), so a
+ * link such as https://openvibe.live/docs/whip#publishing-from-a-browser works without
+ * sending people to GitHub. Heading ids follow GitHub's slug rules, so anchors copied from
+ * GitHub keep working here and vice versa.
+ *
+ * The renderer covers exactly the Markdown subset the docs use — headings, paragraphs,
+ * bullet/numbered lists, tables, fenced code, blockquotes, horizontal rules, and inline
+ * code / bold / italic / links — with no dependency. Everything is HTML-escaped before
+ * any inline formatting is applied, so document text can never inject markup.
+ */
+
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+
+const router = express.Router();
+const DOCS_DIR = path.join(__dirname, '../../docs');
+const GITHUB_BLOB = 'https://github.com/OpenVibers/OpenVibe.Live/blob/main';
+
+// ── Helpers ──────────────────────────────────────────────────
+
+function escapeHtml(text) {
+ return String(text)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+// GitHub-style heading anchor: lowercase, drop punctuation, spaces → hyphens.
+function slugify(text, used) {
+ const base = String(text)
+ .toLowerCase()
+ .replace(/<[^>]+>/g, '')
+ .replace(/&[a-z]+;|&#\d+;/g, '')
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
+ .trim()
+ // Each space becomes a hyphen — GitHub does not collapse runs, so "VODs & Clips"
+ // is "vods--clips" there, and anchors must round-trip between the two.
+ .replace(/\s/g, '-');
+ let slug = base || 'section';
+ let n = 1;
+ while (used.has(slug)) slug = `${base}-${n++}`;
+ used.add(slug);
+ return slug;
+}
+
+/**
+ * Rewrite a link target from the Markdown source so it works from /docs/<name>:
+ * other-doc.md#x → /docs/other-doc#x (served here)
+ * ../public/x.html → /x.html (served from the web root)
+ * ../SETUP.md, hardware/README.md, … → the GitHub blob URL
+ * Absolute URLs and same-page anchors pass through untouched.
+ */
+function rewriteHref(href) {
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(href)) return href;
+ const [target, hash] = href.split('#');
+ const anchor = hash ? `#${hash}` : '';
+ const clean = target.replace(/^\.\//, '');
+ const docMatch = clean.match(/^([a-z0-9-]+)\.md$/i);
+ if (docMatch) return `/docs/${docMatch[1]}${anchor}`;
+ const publicMatch = clean.match(/^\.\.\/public\/(.+)$/);
+ if (publicMatch) return `/${publicMatch[1]}${anchor}`;
+ const repoPath = clean.startsWith('../') ? clean.slice(3) : `docs/${clean}`;
+ return `${GITHUB_BLOB}/${repoPath}${anchor}`;
+}
+
+/**
+ * Inline formatting on already-escaped text. Code spans are lifted out first so that
+ * `**`, `*`, `[`, and `<https://…>` inside them are left alone.
+ */
+function renderInline(escaped) {
+ const codes = [];
+ let text = escaped.replace(/`([^`]+)`/g, (_, code) => {
+ codes.push(`<code>${code}</code>`);
+ return `\u0000${codes.length - 1}\u0000`;
+ });
+ text = text
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, href) => {
+ const target = rewriteHref(href.replace(/&/g, '&'));
+ const external = /^https?:\/\//i.test(target) && !target.startsWith('https://openvibe.live');
+ return `<a href="${escapeHtml(target)}"${external ? ' rel="noopener"' : ''}>${label}</a>`;
+ })
+ .replace(/<(https?:\/\/[^\s&]+)>/g, (_, url) => `<a href="${url}">${url}</a>`)
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
+ .replace(/(^|[\s(])\*([^*\s][^*]*?)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');
+ return text.replace(/\u0000(\d+)\u0000/g, (_, i) => codes[Number(i)]);
+}
+
+function splitTableRow(line) {
+ return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim());
+}
+
+// ── Block renderer ───────────────────────────────────────────
+
+function renderMarkdown(markdown) {
+ const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
+ const out = [];
+ const usedSlugs = new Set();
+ const headings = [];
+ let title = null;
+ let i = 0;
+
+ const flushParagraph = (buf) => {
+ if (buf.length) out.push(`<p>${renderInline(escapeHtml(buf.join(' ')))}</p>`);
+ buf.length = 0;
+ };
+
+ const para = [];
+ while (i < lines.length) {
+ const line = lines[i];
+
+ // Fenced code
+ const fence = line.match(/^```\s*([\w+-]*)\s*$/);
+ if (fence) {
+ flushParagraph(para);
+ const lang = fence[1];
+ const code = [];
+ i++;
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) code.push(lines[i++]);
+ i++; // closing fence
+ out.push(`<pre${lang ? ` data-lang="${escapeHtml(lang)}"` : ''}><code>${escapeHtml(code.join('\n'))}</code></pre>`);
+ continue;
+ }
+
+ // Heading
+ const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
+ if (heading) {
+ flushParagraph(para);
+ const level = heading[1].length;
+ const html = renderInline(escapeHtml(heading[2]));
+ const id = slugify(heading[2], usedSlugs);
+ if (level === 1 && title === null) title = heading[2];
+ if (level === 2) headings.push({ id, text: heading[2] });
+ out.push(`<h${level} id="${id}">${html}<a class="anchor" href="#${id}" aria-label="Link to this section">#</a></h${level}>`);
+ i++;
+ continue;
+ }
+
+ // Horizontal rule
+ if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
+ flushParagraph(para);
+ out.push('<hr>');
+ i++;
+ continue;
+ }
+
+ // Blockquote
+ if (/^>/.test(line)) {
+ flushParagraph(para);
+ const quote = [];
+ while (i < lines.length && /^>/.test(lines[i])) quote.push(lines[i++].replace(/^>\s?/, ''));
+ // Blank quote lines separate paragraphs; consecutive lines join.
+ const paras = quote.join('\n').split(/\n\s*\n/).map(p => p.replace(/\n/g, ' ').trim()).filter(Boolean);
+ out.push(`<blockquote>${paras.map(p => `<p>${renderInline(escapeHtml(p))}</p>`).join('')}</blockquote>`);
+ continue;
+ }
+
+ // Table: header row followed by a |---| separator
+ if (/^\|/.test(line) && i + 1 < lines.length && /^\|?\s*:?-{2,}/.test(lines[i + 1])) {
+ flushParagraph(para);
+ const header = splitTableRow(line);
+ i += 2;
+ const rows = [];
+ while (i < lines.length && /^\|/.test(lines[i])) rows.push(splitTableRow(lines[i++]));
+ const cell = (tag, c) => `<${tag}>${renderInline(escapeHtml(c))}</${tag}>`;
+ out.push('<div class="table-wrap"><table>'
+ + `<thead><tr>${header.map(c => cell('th', c)).join('')}</tr></thead>`
+ + `<tbody>${rows.map(r => `<tr>${r.map(c => cell('td', c)).join('')}</tr>`).join('')}</tbody>`
+ + '</table></div>');
+ continue;
+ }
+
+ // Lists (bulleted or numbered); indented lines continue the current item.
+ const listStart = line.match(/^(\s*)([-*+]|\d+[.)])\s+/);
+ if (listStart && listStart[1].length === 0) {
+ flushParagraph(para);
+ const ordered = /\d/.test(listStart[2]);
+ const items = [];
+ while (i < lines.length) {
+ const m = lines[i].match(/^([-*+]|\d+[.)])\s+(.*)$/);
+ if (m && (/\d/.test(m[1]) === ordered)) {
+ items.push([m[2]]);
+ i++;
+ } else if (items.length && /^\s{2,}\S/.test(lines[i])) {
+ items[items.length - 1].push(lines[i].trim());
+ i++;
+ } else {
+ break;
+ }
+ }
+ const tag = ordered ? 'ol' : 'ul';
+ out.push(`<${tag}>${items.map(parts => `<li>${parts.map(p => renderInline(escapeHtml(p))).join('<br>')}</li>`).join('')}</${tag}>`);
+ continue;
+ }
+
+ // Blank line ends a paragraph
+ if (!line.trim()) {
+ flushParagraph(para);
+ i++;
+ continue;
+ }
+
+ para.push(line.trim());
+ i++;
+ }
+ flushParagraph(para);
+
+ return { html: out.join('\n'), title, headings };
+}
+
+// ── Page shell ───────────────────────────────────────────────
+
+function renderPage({ name, title, html, headings }) {
+ const pageTitle = title ? `${title} — OpenVibe.Live Docs` : 'OpenVibe.Live Docs';
+ const toc = headings.length
+ ? `<nav class="toc" aria-label="On this page">${headings.map(h => `<a href="#${h.id}">${escapeHtml(h.text)}</a>`).join('')}</nav>`
+ : '';
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>${escapeHtml(pageTitle)}</title>
+ <link rel="icon" href="/assets/favicon.ico">
+ <style>
+ :root { --bg: #0d0d0f; --bg-2: #16161a; --bg-3: #1e1e24; --text: #e8e6e3; --text-2: #9a9a9a; --muted: #666;
+ --accent: #8b5cf6; --accent-light: #a78bfa; --border: #2a2a32; }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+ a { color: var(--accent-light); }
+ .top { display: flex; align-items: center; gap: 14px; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--bg-2); font-size: 0.9rem; }
+ .top .brand { font-weight: 700; color: var(--text); text-decoration: none; display: flex; align-items: center; gap: 8px; }
+ .top .brand::before { content: ''; width: 18px; height: 18px; border-radius: 5px; background: linear-gradient(135deg, #8b5cf6, #6d28d9); }
+ .top .sep { color: var(--muted); }
+ .top .right { margin-left: auto; display: flex; gap: 14px; }
+ main { max-width: 880px; margin: 0 auto; padding: 24px 20px 64px; }
+ h1 { font-size: 1.9rem; margin: 0 0 12px; line-height: 1.25; }
+ h2 { font-size: 1.35rem; margin: 2.2em 0 0.6em; padding-bottom: 6px; border-bottom: 1px solid var(--border); }
+ h3 { font-size: 1.1rem; margin: 1.8em 0 0.5em; }
+ h4 { font-size: 1rem; margin: 1.5em 0 0.4em; }
+ h1, h2, h3, h4 { scroll-margin-top: 16px; }
+ .anchor { margin-left: 8px; color: var(--muted); text-decoration: none; opacity: 0; font-weight: 400; }
+ h1:hover .anchor, h2:hover .anchor, h3:hover .anchor, h4:hover .anchor, .anchor:focus { opacity: 1; }
+ p, ul, ol, blockquote, pre, .table-wrap { margin: 0 0 1em; }
+ li { margin: 0.25em 0; }
+ code { font: 0.9em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--bg-3); padding: 1px 5px; border-radius: 4px; }
+ pre { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; overflow-x: auto; position: relative; }
+ pre code { background: none; padding: 0; font-size: 0.85rem; line-height: 1.5; }
+ pre[data-lang]::before { content: attr(data-lang); position: absolute; top: 6px; right: 12px; font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
+ blockquote { border-left: 3px solid var(--accent); background: rgba(139, 92, 246, 0.08); padding: 10px 16px; border-radius: 0 8px 8px 0; }
+ blockquote p:last-child { margin: 0; }
+ .table-wrap { overflow-x: auto; }
+ table { border-collapse: collapse; width: 100%; font-size: 0.92rem; }
+ th, td { text-align: left; padding: 8px 10px; border: 1px solid var(--border); vertical-align: top; }
+ th { background: var(--bg-2); }
+ tr:nth-child(even) td { background: rgba(255, 255, 255, 0.02); }
+ hr { border: 0; border-top: 1px solid var(--border); margin: 2em 0; }
+ .toc { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.85rem; margin: 0 0 24px; padding: 12px 14px; background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; }
+ .toc a { color: var(--text-2); text-decoration: none; }
+ .toc a:hover { color: var(--accent-light); }
+ .foot { margin-top: 48px; padding-top: 16px; border-top: 1px solid var(--border); font-size: 0.85rem; color: var(--text-2); }
+ </style>
+</head>
+<body>
+<header class="top">
+ <a class="brand" href="/">OpenVibe.Live</a><span class="sep">/</span><a href="/docs">Docs</a>
+ <div class="right"><a href="${GITHUB_BLOB}/docs/${escapeHtml(name)}.md" rel="noopener">Source on GitHub</a></div>
+</header>
+<main>
+${toc}
+${html}
+<p class="foot">This page is rendered from <code>docs/${escapeHtml(name)}.md</code> in the <a href="https://github.com/OpenVibers/OpenVibe.Live" rel="noopener">OpenVibe.Live repository</a>. Found a mistake? Edit it there.</p>
+</main>
+</body>
+</html>`;
+}
+
+// ── Routes ───────────────────────────────────────────────────
+
+function loadDoc(name) {
+ if (!/^[a-z0-9-]+$/i.test(name)) return null;
+ const file = path.join(DOCS_DIR, `${name}.md`);
+ if (!file.startsWith(DOCS_DIR + path.sep)) return null;
+ try {
+ return fs.readFileSync(file, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+function sendDoc(req, res, name) {
+ const markdown = loadDoc(name);
+ if (markdown === null) return res.status(404).type('text/plain').send('No such doc');
+ const rendered = renderMarkdown(markdown);
+ res.set('Cache-Control', 'public, max-age=300');
+ res.type('html').send(renderPage({ name, ...rendered }));
+}
+
+router.get('/', (req, res) => sendDoc(req, res, 'README'));
+router.get('/:name.md', (req, res) => sendDoc(req, res, req.params.name));
+router.get('/:name', (req, res) => sendDoc(req, res, req.params.name));
+
+module.exports = router;
+module.exports._renderMarkdown = renderMarkdown;
+module.exports._rewriteHref = rewriteHref;
+module.exports._slugify = slugify;
@@ -213,7 +213,7 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
@@ -213,7 +213,7 @@ app.set('trust proxy', 2); // Two hops: Cloudflare → nginx → Node
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
// RTMP FLV is now proxied same-origin via /api/streams/rtmp-proxy/:id.flv — no external CSP entry needed
// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
// The hosted browser WHIP publisher (/whip-publisher.html) POSTs its SDP offer to the
-// dedicated WHIP host when one is configured (whip.openvibe.live), which is a different
+// dedicated WHIP host when one is configured (ingest.openvibe.live), which is a different
// origin from the page itself and would otherwise be blocked by connect-src 'self'.
// origin from the page itself and would otherwise be blocked by connect-src 'self'.
// Resolved per request, not at boot: config.whip is (re)filled by the URL-registry refresh
// Resolved per request, not at boot: config.whip is (re)filled by the URL-registry refresh
// in start(), which runs after this middleware is built — a value captured here would be
// in start(), which runs after this middleware is built — a value captured here would be
@@ -640,6 +640,12 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re
@@ -640,6 +640,12 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re
}
}
});
});
+// ── Docs ─────────────────────────────────────────────────────
+// docs/*.md rendered as HTML at /docs/<name> (+ /docs/<name>.md) with GitHub-compatible
+// heading anchors, so https://openvibe.live/docs/whip#publishing-from-a-browser is a
+// link people can be sent to without leaving the site.
+app.use('/docs', require('./docs/routes'));
+
// ── OBS Overlay Widgets ──────────────────────────────────────
// ── OBS Overlay Widgets ──────────────────────────────────────
// Modular system: /obs/<widget>/<username>
// Modular system: /obs/<widget>/<username>
// Each widget is a standalone HTML page designed for OBS browser sources.
// Each widget is a standalone HTML page designed for OBS browser sources.
@@ -449,6 +449,13 @@ async function buildSitemap() {
@@ -449,6 +449,13 @@ async function buildSitemap() {
const urls = [];
const urls = [];
const seenChannels = new Set();
const seenChannels = new Set();
const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];
const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5']];
+ // Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
+ try {
+ for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
+ if (!f.endsWith('.md')) continue;
+ statics.push([f === 'README.md' ? '/docs' : `/docs/${f.slice(0, -3)}`, 'weekly', '0.5']);
+ }
+ } catch { /* docs folder absent in some deploys */ }
for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));
for (const [u, cf, pr] of statics) urls.push(_urlTag(u, null, cf, pr));
const page = async (fetch, per, cap, emit) => {
const page = async (fetch, per, cap, emit) => {
let off = 0;
let off = 0;
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+
+// Regression tests for the on-site docs renderer (server/docs/routes.js).
+// Every docs/*.md must render without leaking raw Markdown, heading anchors must match
+// GitHub's slugs (links are shared between the site and the repo), and relative links
+// must be rewritten to places that exist on the site.
+
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { _renderMarkdown: render, _rewriteHref: rewriteHref, _slugify: slugify } = require('../server/docs/routes');
+
+// ── Heading slugs (GitHub-compatible) ──
+const used = new Set();
+assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser');
+assert.strictEqual(slugify('Cross-origin (CORS)', used), 'cross-origin-cors');
+assert.strictEqual(slugify('VODs & Clips', used), 'vods--clips');
+assert.strictEqual(slugify('Publishing from a browser', used), 'publishing-from-a-browser-1', 'duplicate headings get a numeric suffix');
+console.log('✅ heading slugs match GitHub');
+
+// ── Link rewriting ──
+assert.strictEqual(rewriteHref('broadcasting.md'), '/docs/broadcasting');
+assert.strictEqual(rewriteHref('whip.md#publishing-from-a-browser'), '/docs/whip#publishing-from-a-browser');
+assert.strictEqual(rewriteHref('../public/whip-publisher.html'), '/whip-publisher.html');
+assert.strictEqual(rewriteHref('../SETUP.md'), 'https://github.com/OpenVibers/OpenVibe.Live/blob/main/SETUP.md');
+assert.strictEqual(rewriteHref('https://example.com/x'), 'https://example.com/x');
+assert.strictEqual(rewriteHref('#endpoint'), '#endpoint');
+assert.strictEqual(rewriteHref('/whip-publisher.html'), '/whip-publisher.html');
+console.log('✅ relative links rewritten to on-site or GitHub targets');
+
+// ── Inline formatting + escaping ──
+const inline = render('Text (CORS `*`) and `fetch()`. **bold**, `code **kept**`, <https://a.b/c>, and <script>x</script>.').html;
+assert.ok(inline.includes('(CORS <code>*</code>)'), 'code span with no stray placeholder spaces');
+assert.ok(inline.includes('<code>code **kept**</code>'), 'markdown inside code spans is left alone');
+assert.ok(inline.includes('<strong>bold</strong>'));
+assert.ok(inline.includes('<a href="https://a.b/c">https://a.b/c</a>'), 'autolinks');
+assert.ok(inline.includes('<script>x</script>'), 'raw HTML is escaped, never emitted');
+assert.ok(!inline.includes(String.fromCharCode(0)), 'no placeholder bytes leak into output');
+console.log('✅ inline formatting and HTML escaping');
+
+// ── Blocks ──
+const blocks = render([
+ '# Title',
+ '',
+ '> quoted line one',
+ '> quoted line two',
+ '',
+ '| A | B |',
+ '|---|---|',
+ '| 1 | `2` |',
+ '',
+ '1. first',
+ ' continued',
+ '2. second',
+ '',
+ '- bullet',
+ '',
+ '```bash',
+ 'curl -X POST "<url>"',
+ '```',
+].join('\n'));
+assert.strictEqual(blocks.title, 'Title');
+assert.ok(blocks.html.includes('<blockquote><p>quoted line one quoted line two</p></blockquote>'));
+assert.ok(blocks.html.includes('<th>A</th>') && blocks.html.includes('<td><code>2</code></td>'), 'tables');
+assert.ok(blocks.html.includes('<ol><li>first<br>continued</li><li>second</li></ol>'), 'ordered list with continuation line');
+assert.ok(blocks.html.includes('<ul><li>bullet</li></ul>'));
+assert.ok(blocks.html.includes('<pre data-lang="bash"><code>curl -X POST "<url>"</code></pre>'), 'fenced code is escaped verbatim');
+console.log('✅ block elements');
+
+// ── Every shipped doc renders cleanly with the anchors people link to ──
+const docsDir = path.join(__dirname, '../docs');
+const leakPatterns = [/```/, /\*\*/, /^\s*\|/m, /\]\(/, /^#{1,6} /m];
+for (const file of fs.readdirSync(docsDir).filter(f => f.endsWith('.md'))) {
+ const result = render(fs.readFileSync(path.join(docsDir, file), 'utf8'));
+ assert.ok(result.title, `${file}: has an H1 title`);
+ const textOnly = result.html
+ .replace(/<pre[\s\S]*?<\/pre>/g, '')
+ .replace(/<code>[\s\S]*?<\/code>/g, '')
+ .replace(/<[^>]+>/g, '');
+ for (const re of leakPatterns) assert.ok(!re.test(textOnly), `${file}: raw markdown leaked (${re})`);
+}
+const whip = render(fs.readFileSync(path.join(docsDir, 'whip.md'), 'utf8')).html;
+assert.ok(whip.includes('id="publishing-from-a-browser"'), 'the browser-publishing anchor that is linked publicly must exist');
+assert.ok(whip.includes('href="/whip-publisher.html"'), 'link to the hosted publisher resolves on-site');
+console.log('✅ all docs/*.md render without leaking markdown; public anchors present');
+
+console.log('\n✅ All docs renderer tests passed');