openvibers/openvibe.media Commits
Public pages: shared chrome + SEO on /v, /c and /p; pastes canonical on Community
Changes
nnew file mode 100644@@@ -0,0 +1,200 @@++/**++ * OpenVibe.Media — the shell every server-rendered page shares.++ *++ * One place for: the network chrome (openvibe.network's theme-loader before++ * paint, navbar + footer after), the SEO head (title, description, canonical,++ * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which++ * reads the shared theme tokens (--bg-primary, --accent, …) that++ * theme-loader.js sets on <html> and falls back to the default Vibe palette.++ */++'use strict';++++const config = require('../config');++++const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Media';++const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;++++// Public base URL per app for canonical / "source" links (env-overridable JSON map).++const APP_PUBLIC_URLS = (() => {++ const defaults = {++ live: 'https://openvibe.live',++ games: 'https://openvibe.games',++ tools: 'https://openvibe.tools',++ network: NETWORK_URL,++ community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',++ };++ try {++ const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');++ if (m && typeof m === 'object') return { ...defaults, ...m };++ } catch { /* unset or malformed: defaults */ }++ return defaults;++})();++const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;++++const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));++const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };++/** JSON that is safe inside a <script>: no `</` can end the element early. */++const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');++++/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */++function isoDate(dt) {++ if (!dt) return null;++ const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */++function isoDuration(sec) {++ sec = Math.floor(Number(sec) || 0);++ if (sec <= 0) return null;++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;++}++function fmtDuration(sec) {++ sec = Math.floor(Number(sec) || 0);++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;++}++function fmtDate(dt) {++ const iso = isoDate(dt);++ if (!iso) return '';++ try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }++}++++/**++ * The <head> a page needs: shared theme before paint, then the SEO set.++ * title, description, canonical (absolute), robots, image (absolute),++ * ogType, jsonLd (array of objects), video ({ url, type, width, height }).++ */++function headTags(seo) {++ const title = esc(seo.title);++ const description = esc(snip(seo.description, 300));++ const image = seo.image || DEFAULT_OG_IMAGE;++ const twitterCard = seo.twitterCard || 'summary_large_image';++ const tags = [++ '<meta charset="utf-8">',++ '<meta name="viewport" content="width=device-width, initial-scale=1">',++ `<title>${title}</title>`,++ `<meta name="description" content="${description}">`,++ // Applies the user's theme to <html> synchronously; must run before any CSS paints.++ `<script src="${NETWORK_URL}/shared/theme-loader.js"></script>`,++ seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',++ `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,++ `<meta property="og:site_name" content="${SITE_NAME}">`,++ `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,++ `<meta property="og:title" content="${title}">`,++ `<meta property="og:description" content="${description}">`,++ seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',++ `<meta property="og:image" content="${esc(image)}">`,++ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',++ seo.video && seo.video.url ? [++ `<meta property="og:video" content="${esc(seo.video.url)}">`,++ `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,++ seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',++ ].join('\n') : '',++ `<meta name="twitter:card" content="${esc(twitterCard)}">`,++ `<meta name="twitter:title" content="${title}">`,++ `<meta name="twitter:description" content="${description}">`,++ `<meta name="twitter:image" content="${esc(image)}">`,++ ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),++ ];++ return tags.filter(Boolean).join('\n');++}++++/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */++function baseCss() {++ return `++ :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);++ --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);++ --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }++ * { box-sizing: border-box; }++ html, body { margin: 0; min-height: 100%; }++ body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }++ main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }++ a { color: var(--link); }++ h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }++ .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }++ .meta a { text-decoration: none; }++ .meta a:hover { text-decoration: underline; }++ .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }++ .note a { font-weight: 600; }++ .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }++ .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }++ .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }++ .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }++ #ov-footer { flex: none; }++`;++}++++/**++ * The scripts that mount the navbar + footer, after the page content so a++ * slow Network never blocks the body.++ * history { type, title } → recorded to the signed-in user's network history++ * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }++ */++function chromeScripts({ history, footer } = {}) {++ const navOpts = { service: 'media', apiBase: NETWORK_URL };++ if (history) navOpts.history = history;++ const footOpts = {++ service: 'media',++ variant: (footer && footer.variant) || 'compact',++ links: (footer && footer.links) || defaultFooterLinks(),++ mount: '#ov-footer',++ };++ return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++<script src="${NETWORK_URL}/shared/footer.js" defer></script>++<script>++(function () {++ var tries = 0;++ function boot() {++ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }++ try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }++ try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();++})();++</script>`;++}++++function defaultFooterLinks() {++ return [++ {++ heading: 'Media',++ items: [++ { label: 'Media index', href: `${config.publicUrl}/` },++ { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },++ { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },++ { label: 'Pastes', href: `${appUrl('community')}/pastes` },++ ],++ },++ ];++}++++/**++ * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>++ * contents, `history` / `footer` → chromeScripts.++ */++function page({ seo, css = '', body, history, footer }) {++ return `<!DOCTYPE html>++<html lang="en">++<head>++${headTags(seo)}++<style>${baseCss()}${css}</style>++</head>++<body>++<div id="navbar-mount"></div>++<main>++${body}++</main>++<footer id="ov-footer" class="ovf"></footer>++${chromeScripts({ history, footer })}++</body>++</html>`;++}++++module.exports = {++ NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,++ esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,++ headTags, baseCss, chromeScripts, defaultFooterLinks, page,++};nnew file mode 100644@@@ -0,0 +1,209 @@++/**++ * OpenVibe.Media — the human-facing pages behind the public URLs.++ *++ * /v/:id and /c/:id are, first of all, the media bytes (players, <video>++ * tags, curl, the object-store redirect). A browser NAVIGATING there — a++ * shared link, the address bar, a link-preview crawler — gets a watch page++ * instead: player, title, source link and full SEO. wantsHtmlPage() draws++ * that line; ?raw=1 always yields the bytes.++ *++ * /p/:slug is a thin viewer. Pastes are canonical on OpenVibe.Community,++ * which renders them from this API, so the page here points there++ * (canonical + noindex) and stays useful for direct links.++ */++'use strict';++++const path = require('path');++const config = require('../config');++const chrome = require('./page-chrome');++++const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = chrome;++++const VIDEO_MIME = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska' };++/** Link-preview and search crawlers: they want the page, never the bytes. */++const PAGE_BOTS = /facebookexternalhit|twitterbot|discordbot|slackbot|linkedinbot|whatsapp|telegrambot|googlebot|bingbot|applebot|duckduckbot|yandex|pinterest|redditbot|mastodon|embedly/i;++++/**++ * True when the request is a top-level browser navigation (or a preview++ * crawler) that should see the watch page rather than the media bytes.++ * Players and fetches (Sec-Fetch-Dest: video / empty, Accept without++ * text/html, Range requests) always get the bytes, as does ?raw=1.++ */++function wantsHtmlPage(req) {++ if (req.method !== 'GET' && req.method !== 'HEAD') return false;++ if (req.query && (req.query.raw != null || req.query.download != null)) return false;++ if (req.headers.range) return false;++ const dest = req.headers['sec-fetch-dest'];++ if (dest) return dest === 'document';++ if (PAGE_BOTS.test(String(req.headers['user-agent'] || ''))) return true;++ const accept = String(req.headers.accept || '');++ return /(^|,)\s*text\/html/.test(accept);++}++++function creatorLine(record) {++ // Media stores the owning app's user id only; the app's page carries the name.++ return record.username || record.display_name || null;++}++++/** Where the owning app shows this item — Live's /vod and /clip pages. */++function sourcePage(kind, record) {++ const base = appUrl(record.app_id || 'live');++ return `${base}/${kind === 'vod' ? 'vod' : 'clip'}/${record.id}`;++}++++/**++ * /v/:id and /c/:id as a page. Canonical stays with the owning app when that++ * app serves the item (Live's /vod/:id and /clip/:id); anything else is++ * self-canonical here.++ */++function renderWatchPage(kind, record) {++ const isVod = kind === 'vod';++ const id = record.id;++ const self = `${config.publicUrl}/${isVod ? 'v' : 'c'}/${id}`;++ const rawUrl = `${self}?raw=1`;++ const title = snip(record.title || (isVod ? `VOD #${id}` : `Clip #${id}`), 120);++ const by = creatorLine(record);++ const description = snip(record.description || record.ai_overview || `${isVod ? 'A recorded live stream' : 'A clip'}${by ? ` by ${by}` : ''} on the OpenVibe network.`, 200);++ const thumb = abs(record.thumbnail_url);++ const ownedByLive = (record.app_id || 'live') === 'live';++ const canonical = ownedByLive ? sourcePage(kind, record) : self;++ const visibility = record.visibility || (record.is_public ? 'public' : 'private');++ // The owning app has the canonical page; unlisted items never index.++ const robots = ownedByLive || visibility !== 'public' ? 'noindex, follow' : 'index, follow';++ const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';++ const uploadDate = isoDate(record.created_at);++ const duration = isoDuration(record.duration_seconds);++++ const videoObject = {++ '@context': 'https://schema.org',++ '@type': 'VideoObject',++ name: title,++ description,++ url: canonical,++ contentUrl: rawUrl,++ embedUrl: self,++ ...(thumb ? { thumbnailUrl: [thumb] } : {}),++ ...(uploadDate ? { uploadDate } : {}),++ ...(duration ? { duration } : {}),++ ...(by ? { author: { '@type': 'Person', name: by } } : {}),++ ...(record.view_count ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: record.view_count } } : {}),++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },++ isFamilyFriendly: true,++ };++++ const metaBits = [++ isVod ? 'VOD' : 'Clip',++ record.duration_seconds ? fmtDuration(record.duration_seconds) : null,++ record.view_count != null ? `${Number(record.view_count).toLocaleString('en-US')} views` : null,++ fmtDate(record.created_at) || null,++ by ? `by ${esc(by)}` : null,++ ].filter(Boolean);++++ const body = `++ <h1>${esc(title)}</h1>++ <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>++ <div class="player">++ <video controls playsinline preload="metadata"${thumb ? ` poster="${esc(thumb)}"` : ''} src="${esc(rawUrl)}">++ Your browser cannot play this video. <a href="${esc(rawUrl)}">Download it</a> instead.++ </video>++ </div>++ <div class="actions">++ ${ownedByLive ? `<a class="btn" href="${esc(canonical)}">${isVod ? 'Watch on OpenVibe.Live' : 'Watch on OpenVibe.Live'} — chat, transcript & more</a>` : ''}++ <a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</a>++ ${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}++ </div>++ ${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}++ ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}`;++++ const css = `++ .player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }++ .player video { display: block; width: 100%; max-height: 72vh; background: #000; }++ .h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;++++ return chrome.page({++ seo: {++ title: `${title} — ${SITE_NAME}`, description, canonical, robots,++ image: thumb || undefined, ogType: 'video.other',++ video: { url: rawUrl, type: mime },++ jsonLd: [videoObject],++ },++ css, body,++ history: { type: isVod ? 'vod' : 'clip', title },++ footer: { variant: 'compact' },++ });++}++++/** /p/:slug — thin viewer; the page's home is OpenVibe.Community. */++function renderPastePage(paste) {++ const slug = String(paste.slug);++ const title = snip(paste.title || 'Untitled', 120);++ const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;++ const self = `${config.publicUrl}/p/${encodeURIComponent(slug)}`;++ const communityUrl = `${appUrl('community')}/p/${encodeURIComponent(slug)}`;++ const screenshotUrl = `${self}/screenshot`;++ const language = paste.language || 'text';++ const description = snip(paste.ai_summary || (isScreenshot ? (paste.content || `Screenshot "${title}" shared on the OpenVibe network.`) : (paste.content || `${language} paste shared on the OpenVibe network.`)), 200);++ const created = isoDate(paste.created_at);++ const updated = isoDate(paste.updated_at) || created;++ const tags = (() => { try { const t = JSON.parse(paste.ai_tags || '[]'); return Array.isArray(t) ? t.map(String).slice(0, 12) : []; } catch { return String(paste.ai_tags || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); } })();++++ const ld = isScreenshot++ ? {++ '@context': 'https://schema.org', '@type': 'ImageObject',++ name: title, description, url: communityUrl, contentUrl: screenshotUrl, mainEntityOfPage: communityUrl,++ ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),++ ...(tags.length ? { keywords: tags.join(', ') } : {}),++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },++ }++ : {++ '@context': 'https://schema.org', '@type': 'Article',++ headline: title, description, url: communityUrl, mainEntityOfPage: communityUrl,++ ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),++ ...(tags.length ? { keywords: tags.join(', ') } : {}),++ genre: language === 'text' ? 'Text paste' : `${language} code paste`,++ isAccessibleForFree: true,++ hasPart: { '@type': 'CreativeWork', name: title, encodingFormat: 'text/plain', url: `${self}/raw` },++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },++ };++++ const content = isScreenshot++ ? `<figure class="shot"><img src="${esc(screenshotUrl)}" alt="${esc(title)}" loading="lazy"></figure>++ ${paste.content ? `<p class="desc">${esc(paste.content)}</p>` : ''}`++ : `<pre class="code" data-language="${esc(language)}"><code>${esc(paste.content || '')}</code></pre>`;++++ const metaBits = [++ esc(language),++ `${Number(paste.views || 0).toLocaleString('en-US')} views`,++ paste.unique_views != null ? `${Number(paste.unique_views).toLocaleString('en-US')} unique` : null,++ fmtDate(paste.created_at) || null,++ isScreenshot ? null : `<a href="${esc(`/p/${encodeURIComponent(slug)}/raw`)}">raw</a>`,++ ].filter(Boolean);++++ const body = `++ <p class="note">This paste lives on <a href="${esc(communityUrl)}">OpenVibe.Community</a> — comments, likes and forks are there. This is the plain viewer.</p>++ <h1>${esc(title)}</h1>++ <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>++ ${content}++ <div class="actions"><a class="btn" href="${esc(communityUrl)}">Open on OpenVibe.Community</a></div>`;++++ const css = `++ .code { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; margin: 0 0 1rem; }++ .shot { margin: 0 0 1rem; }++ .shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;++++ return chrome.page({++ seo: {++ title: `${title} — ${SITE_NAME}`, description,++ // Canonical on Community: search engines index the paste there, not here.++ canonical: communityUrl, robots: 'noindex, follow',++ image: isScreenshot ? screenshotUrl : undefined, ogType: 'article',++ twitterCard: isScreenshot ? 'summary_large_image' : 'summary',++ jsonLd: [ld],++ },++ css, body,++ history: { type: 'paste', title },++ footer: { variant: 'compact' },++ });++}++++module.exports = { wantsHtmlPage, renderWatchPage, renderPastePage, sourcePage };@@@ -3,16 +3,20 @@** GET /v/:id VOD playback: local stream w/ Range, or 302 presigned* B2/R2 (inherited logic). Live recordings serve the-- * fully-indexed .seekable sidecar for DVR.-- * GET /c/:id clip playback (same tiering/range logic)-- * GET /p/:slug paste HTML page (indexable)++ * fully-indexed .seekable sidecar for DVR. A browser++ * NAVIGATING here gets a watch page instead (see++ * pages.js — ?raw=1 always yields the bytes).++ * GET /c/:id clip playback (same tiering/range logic + watch page)++ * GET /p/:slug paste viewer — canonical on OpenVibe.Community* GET /p/:slug/raw paste raw text* GET /p/:slug/screenshot paste screenshot image* GET /t/:id thumbnails (id = filename)* GET /f/:key files with correct Content-Type + Range++ * GET /og-image.png the site's share card*-- * /v /c /t /f carry X-Robots-Tag: noindex — the owning app has the canonical-- * page. Pastes are Media-canonical and stay indexable.++ * /v /c /t /f bytes carry X-Robots-Tag: noindex — the owning app has the++ * canonical page (the watch page says so with rel=canonical). The paste++ * viewer is noindex too: Community owns that URL now.*/'use strict';@@@ -22,6 +26,7 @@ const fs = require('fs');const db = require('../db/database');const tools = require('../vod/media-tools');const { optionalIdentity } = require('../auth');++const pages = require('./pages');const router = express.Router();@@@ -111,6 +116,13 @@ async function serveMediaRecord(kind, record, req, res) {return res.status(403).json({ error: 'This media is private' });}++ // A person (or a link-preview crawler) landing on the URL gets the watch++ // page; its <video> comes back here with ?raw=1 for the bytes.++ if (pages.wantsHtmlPage(req)) {++ res.set('Cache-Control', visibility === 'public' ? 'public, max-age=60' : 'private, no-store');++ return res.type('html').send(pages.renderWatchPage(kind, record));++ }++trackUniqueView(kind, record.id, req, record.user_id);// Track last access time for storage tier decisions@@@ -196,6 +208,12 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {}});++// ── Share card (og:image default) ────────────────────────────++router.get('/og-image.png', (req, res) => {++ res.set('Cache-Control', 'public, max-age=86400');++ res.sendFile(path.join(__dirname, '..', 'static', 'og-image.png'));++});++// ── Thumbnails ───────────────────────────────────────────────router.get('/t/:id', (req, res) => {require('../thumbnails/thumbnail-service').serveThumbnail(req, res);@@@ -415,59 +433,7 @@ router.get('/f/:key', (req, res) => {// ── Pastes ───────────────────────────────────────────────────--function escapeHtml(s) {-- return String(s ?? '')-- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')-- .replace(/"/g, '"').replace(/'/g, ''');--}----function renderPastePage(paste) {-- const title = escapeHtml(paste.title || 'Untitled');-- const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;-- const body = isScreenshot-- ? `<figure class="shot"><img src="/p/${escapeHtml(paste.slug)}/screenshot" alt="${title}"></figure>-- ${paste.content ? `<p class="desc">${escapeHtml(paste.content)}</p>` : ''}`-- : `<pre class="code" data-language="${escapeHtml(paste.language || 'text')}"><code>${escapeHtml(paste.content || '')}</code></pre>`;-- const created = escapeHtml(String(paste.created_at || ''));-- return `<!DOCTYPE html>--<html lang="en">--<head>--<meta charset="utf-8">--<meta name="viewport" content="width=device-width, initial-scale=1">--<title>${title} — OpenVibe.Media</title>--<meta name="description" content="${title} — shared via OpenVibe.Media pastes">--<style>-- :root { --accent: #8b5cf6; --accent-light: #a78bfa; --bg: #131318; --panel: #1b1b22; --text: #e8e8ee; --muted: #9a9aa8; }-- * { box-sizing: border-box; }-- body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; }-- header { display: flex; align-items: center; gap: .6rem; padding: .8rem 1.2rem; border-bottom: 1px solid #26262f; }-- header .brand { font-weight: 700; color: var(--accent-light); text-decoration: none; font-size: 1.05rem; }-- header .brand span { color: var(--text); }-- main { max-width: 960px; margin: 0 auto; padding: 1.2rem; }-- h1 { font-size: 1.3rem; margin: .2rem 0 .3rem; }-- .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1rem; }-- .meta a { color: var(--accent-light); text-decoration: none; }-- .code { background: var(--panel); border: 1px solid #26262f; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; }-- .shot img { max-width: 100%; border-radius: 10px; border: 1px solid #26262f; }-- .desc { color: var(--muted); }-- footer { max-width: 960px; margin: 0 auto; padding: 1rem 1.2rem 2rem; color: var(--muted); font-size: .82rem; }-- footer a { color: var(--accent-light); text-decoration: none; }--</style>--</head>--<body>--<header>-- <a class="brand" href="https://openvibe.network">OpenVibe<span>.Media</span></a>--</header>--<main>-- <h1>${title}</h1>-- <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${paste.unique_views || 0} unique · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>-- ${body}--</main>--<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>--</body>--</html>`;--}--++// The page itself is rendered in pages.js (thin viewer; canonical on Community).router.get('/p/:slug', optionalIdentity, (req, res) => {try {const paste = db.getPasteBySlug(String(req.params.slug));@@@ -494,7 +460,8 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {return res.status(410).send('This paste has been burned after reading.');}-- res.type('html').send(renderPastePage(paste));++ res.set('Cache-Control', 'private, no-store');++ res.type('html').send(pages.renderPastePage(paste));} catch (err) {console.error('[Public] /p error:', err.message);res.status(500).send('Error');nnew file mode 100644BBinary files /dev/null and b/server/static/og-image.png differnnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),++// the paste viewer's Community canonical, and the shared chrome + SEO on all of them.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-media-pages-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++const db = require('../server/db/database');++require('../server/views/service').ensureSchema();++const pages = require('../server/public/pages');++const chrome = require('../server/public/page-chrome');++++// ── wantsHtmlPage: navigations and preview bots get the page, players get bytes ──++const req = (headers, query = {}) => ({ method: 'GET', headers, query });++assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html,*/*' })), true);++assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'video', accept: 'video/webm,*/*;q=0.5' })), false);++assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html' }, { raw: '1' })), false, '?raw=1 always yields bytes');++assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html', range: 'bytes=0-' })), false, 'range requests are bytes');++assert.strictEqual(pages.wantsHtmlPage(req({ accept: 'text/html,application/xhtml+xml' })), true, 'Accept-based fallback');++assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*' })), false, 'curl / fetch defaults get bytes');++assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*', 'user-agent': 'Mozilla/5.0 (compatible; Discordbot/2.0)' })), true, 'preview bots get the page');++assert.strictEqual(pages.wantsHtmlPage({ method: 'POST', headers: { accept: 'text/html' }, query: {} }), false);++++// ── Watch page: SEO + chrome + canonical rules ──++const vod = {++ id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',++ file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,++ is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.',++};++const html = pages.renderWatchPage('vod', vod);++assert.ok(html.includes('<title>Late night <build> — OpenVibe.Media</title>'));++assert.ok(html.includes('<link rel="canonical" href="https://openvibe.live/vod/42">'), 'Live-owned VOD is canonical on Live');++assert.ok(html.includes('<meta name="robots" content="noindex, follow">'));++assert.ok(html.includes('https://openvibe.network/shared/theme-loader.js'));++assert.ok(html.indexOf('theme-loader.js') < html.indexOf('<style>'), 'theme-loader runs before the styles');++assert.ok(html.includes('https://openvibe.network/shared/navbar.js') && html.includes('https://openvibe.network/shared/footer.js'));++assert.ok(html.includes('"service":"media"') && html.includes('"history":{"type":"vod","title":"Late night <build>"}'.replace('<', '\\u003c')));++assert.ok(html.includes('"variant":"compact"'));++assert.ok(html.includes('<meta property="og:type" content="video.other">'));++assert.ok(html.includes('<meta property="og:video" content="https://media.test/v/42?raw=1">'));++assert.ok(html.includes('<meta name="twitter:card" content="summary_large_image">'));++assert.ok(html.includes('src="https://media.test/v/42?raw=1"'), 'the player fetches the bytes with ?raw=1');++const ld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html)[1]);++assert.strictEqual(ld['@type'], 'VideoObject');++assert.strictEqual(ld.duration, 'PT1H2M5S');++assert.strictEqual(ld.uploadDate, '2026-09-10T20:15:00.000Z');++assert.deepStrictEqual(ld.thumbnailUrl, ['https://media.test/t/vod-42-1.jpg']);++assert.strictEqual(ld.url, 'https://openvibe.live/vod/42');++assert.ok(!html.includes('</script>"'), 'no script-closing sequence smuggled through JSON');++++const other = pages.renderWatchPage('clip', { ...vod, id: 7, app_id: 'games', title: 'Frag' });++assert.ok(other.includes('<link rel="canonical" href="https://media.test/c/7">'), 'non-Live items are self-canonical');++assert.ok(other.includes('<meta name="robots" content="index, follow">'));++const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });++assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');++++// ── Paste viewer: canonical on Community, noindex, Article JSON-LD ──++const paste = { slug: 'abc-123', title: 'hello.js', type: 'paste', content: 'console.log("hi")', language: 'javascript', views: 3, unique_views: 2, created_at: '2026-09-01 10:00:00', updated_at: '2026-09-02 10:00:00', ai_tags: '["js","demo"]' };++const ph = pages.renderPastePage(paste);++assert.ok(ph.includes('<link rel="canonical" href="https://openvibe.community/p/abc-123">'));++assert.ok(ph.includes('<meta name="robots" content="noindex, follow">'));++assert.ok(ph.includes('href="https://openvibe.community/p/abc-123"'), 'header links to the Community page');++assert.ok(ph.includes('console.log("hi")'));++assert.ok(ph.includes('"history":{"type":"paste","title":"hello.js"}'));++const pld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(ph)[1]);++assert.strictEqual(pld['@type'], 'Article');++assert.strictEqual(pld.mainEntityOfPage, 'https://openvibe.community/p/abc-123');++assert.strictEqual(pld.keywords, 'js, demo');++const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });++assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));++assert.ok(shot.includes('"@type":"ImageObject"'));++assert.ok(!/\bfree\b/i.test(html + ph + shot + chrome.baseCss()), 'no cost claims in served HTML');++++// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──++const express = require('express');++const raw = db.getDb();++raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, duration_seconds) VALUES (?, ?, ?, ?, 1, 'public', 10)`).run(42, 'live', 'Router VOD', '/nonexistent/vod-42.mp4');++raw.prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility) VALUES (?, 'live', 'P', 'paste', 'x', 'text', 'public')`).run('slug1');++const app = express();++app.use('/', require('../server/public/routes'));++const server = app.listen(0);++const port = server.address().port;++const get = (p, headers) => new Promise((resolve, reject) => {++ http.get({ host: '127.0.0.1', port, path: p, headers }, (res) => {++ let body = ''; res.setEncoding('utf8'); res.on('data', c => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));++ }).on('error', reject);++});++(async () => {++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };++ let r = await get('/v/42', nav);++ assert.strictEqual(r.status, 200);++ assert.ok(r.headers['content-type'].startsWith('text/html'));++ assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));++ r = await get('/v/42?raw=1', nav);++ assert.strictEqual(r.status, 404, 'bytes path (no file on disk → 404 JSON), not the page');++ assert.ok(r.headers['content-type'].startsWith('application/json'));++ r = await get('/v/42', { accept: 'video/webm,video/ogg,video/*;q=0.9,*/*;q=0.5', 'sec-fetch-dest': 'video' });++ assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');++ r = await get('/p/slug1', nav);++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.includes('https://openvibe.community/p/slug1'));++ r = await get('/og-image.png', {});++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['content-type'], 'image/png');++ server.close();++ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ console.log('public pages: all checks passed');++ process.exit(0);++})().catch((err) => { console.error(err); server.close(); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
+/**
+ * OpenVibe.Media — the shell every server-rendered page shares.
+ *
+ * One place for: the network chrome (openvibe.network's theme-loader before
+ * paint, navbar + footer after), the SEO head (title, description, canonical,
+ * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which
+ * reads the shared theme tokens (--bg-primary, --accent, …) that
+ * theme-loader.js sets on <html> and falls back to the default Vibe palette.
+ */
+'use strict';
+
+const config = require('../config');
+
+const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Media';
+const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;
+
+// Public base URL per app for canonical / "source" links (env-overridable JSON map).
+const APP_PUBLIC_URLS = (() => {
+ const defaults = {
+ live: 'https://openvibe.live',
+ games: 'https://openvibe.games',
+ tools: 'https://openvibe.tools',
+ network: NETWORK_URL,
+ community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',
+ };
+ try {
+ const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');
+ if (m && typeof m === 'object') return { ...defaults, ...m };
+ } catch { /* unset or malformed: defaults */ }
+ return defaults;
+})();
+const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
+
+const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
+const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
+/** JSON that is safe inside a <script>: no `</` can end the element early. */
+const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
+
+/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */
+function isoDate(dt) {
+ if (!dt) return null;
+ const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */
+function isoDuration(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ if (sec <= 0) return null;
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;
+}
+function fmtDuration(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
+}
+function fmtDate(dt) {
+ const iso = isoDate(dt);
+ if (!iso) return '';
+ try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }
+}
+
+/**
+ * The <head> a page needs: shared theme before paint, then the SEO set.
+ * title, description, canonical (absolute), robots, image (absolute),
+ * ogType, jsonLd (array of objects), video ({ url, type, width, height }).
+ */
+function headTags(seo) {
+ const title = esc(seo.title);
+ const description = esc(snip(seo.description, 300));
+ const image = seo.image || DEFAULT_OG_IMAGE;
+ const twitterCard = seo.twitterCard || 'summary_large_image';
+ const tags = [
+ '<meta charset="utf-8">',
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
+ `<title>${title}</title>`,
+ `<meta name="description" content="${description}">`,
+ // Applies the user's theme to <html> synchronously; must run before any CSS paints.
+ `<script src="${NETWORK_URL}/shared/theme-loader.js"></script>`,
+ seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',
+ `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,
+ `<meta property="og:site_name" content="${SITE_NAME}">`,
+ `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,
+ `<meta property="og:title" content="${title}">`,
+ `<meta property="og:description" content="${description}">`,
+ seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',
+ `<meta property="og:image" content="${esc(image)}">`,
+ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',
+ seo.video && seo.video.url ? [
+ `<meta property="og:video" content="${esc(seo.video.url)}">`,
+ `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,
+ seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',
+ ].join('\n') : '',
+ `<meta name="twitter:card" content="${esc(twitterCard)}">`,
+ `<meta name="twitter:title" content="${title}">`,
+ `<meta name="twitter:description" content="${description}">`,
+ `<meta name="twitter:image" content="${esc(image)}">`,
+ ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),
+ ];
+ return tags.filter(Boolean).join('\n');
+}
+
+/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */
+function baseCss() {
+ return `
+ :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);
+ --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);
+ --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; min-height: 100%; }
+ body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }
+ main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }
+ a { color: var(--link); }
+ h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }
+ .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }
+ .meta a { text-decoration: none; }
+ .meta a:hover { text-decoration: underline; }
+ .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }
+ .note a { font-weight: 600; }
+ .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }
+ .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }
+ .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }
+ .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
+ #ov-footer { flex: none; }
+`;
+}
+
+/**
+ * The scripts that mount the navbar + footer, after the page content so a
+ * slow Network never blocks the body.
+ * history { type, title } → recorded to the signed-in user's network history
+ * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }
+ */
+function chromeScripts({ history, footer } = {}) {
+ const navOpts = { service: 'media', apiBase: NETWORK_URL };
+ if (history) navOpts.history = history;
+ const footOpts = {
+ service: 'media',
+ variant: (footer && footer.variant) || 'compact',
+ links: (footer && footer.links) || defaultFooterLinks(),
+ mount: '#ov-footer',
+ };
+ return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+<script src="${NETWORK_URL}/shared/footer.js" defer></script>
+<script>
+(function () {
+ var tries = 0;
+ function boot() {
+ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
+ try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }
+ try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
+})();
+</script>`;
+}
+
+function defaultFooterLinks() {
+ return [
+ {
+ heading: 'Media',
+ items: [
+ { label: 'Media index', href: `${config.publicUrl}/` },
+ { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },
+ { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },
+ { label: 'Pastes', href: `${appUrl('community')}/pastes` },
+ ],
+ },
+ ];
+}
+
+/**
+ * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>
+ * contents, `history` / `footer` → chromeScripts.
+ */
+function page({ seo, css = '', body, history, footer }) {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+${headTags(seo)}
+<style>${baseCss()}${css}</style>
+</head>
+<body>
+<div id="navbar-mount"></div>
+<main>
+${body}
+</main>
+<footer id="ov-footer" class="ovf"></footer>
+${chromeScripts({ history, footer })}
+</body>
+</html>`;
+}
+
+module.exports = {
+ NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
+ esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
+ headTags, baseCss, chromeScripts, defaultFooterLinks, page,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+/**
+ * OpenVibe.Media — the human-facing pages behind the public URLs.
+ *
+ * /v/:id and /c/:id are, first of all, the media bytes (players, <video>
+ * tags, curl, the object-store redirect). A browser NAVIGATING there — a
+ * shared link, the address bar, a link-preview crawler — gets a watch page
+ * instead: player, title, source link and full SEO. wantsHtmlPage() draws
+ * that line; ?raw=1 always yields the bytes.
+ *
+ * /p/:slug is a thin viewer. Pastes are canonical on OpenVibe.Community,
+ * which renders them from this API, so the page here points there
+ * (canonical + noindex) and stays useful for direct links.
+ */
+'use strict';
+
+const path = require('path');
+const config = require('../config');
+const chrome = require('./page-chrome');
+
+const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = chrome;
+
+const VIDEO_MIME = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska' };
+/** Link-preview and search crawlers: they want the page, never the bytes. */
+const PAGE_BOTS = /facebookexternalhit|twitterbot|discordbot|slackbot|linkedinbot|whatsapp|telegrambot|googlebot|bingbot|applebot|duckduckbot|yandex|pinterest|redditbot|mastodon|embedly/i;
+
+/**
+ * True when the request is a top-level browser navigation (or a preview
+ * crawler) that should see the watch page rather than the media bytes.
+ * Players and fetches (Sec-Fetch-Dest: video / empty, Accept without
+ * text/html, Range requests) always get the bytes, as does ?raw=1.
+ */
+function wantsHtmlPage(req) {
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false;
+ if (req.query && (req.query.raw != null || req.query.download != null)) return false;
+ if (req.headers.range) return false;
+ const dest = req.headers['sec-fetch-dest'];
+ if (dest) return dest === 'document';
+ if (PAGE_BOTS.test(String(req.headers['user-agent'] || ''))) return true;
+ const accept = String(req.headers.accept || '');
+ return /(^|,)\s*text\/html/.test(accept);
+}
+
+function creatorLine(record) {
+ // Media stores the owning app's user id only; the app's page carries the name.
+ return record.username || record.display_name || null;
+}
+
+/** Where the owning app shows this item — Live's /vod and /clip pages. */
+function sourcePage(kind, record) {
+ const base = appUrl(record.app_id || 'live');
+ return `${base}/${kind === 'vod' ? 'vod' : 'clip'}/${record.id}`;
+}
+
+/**
+ * /v/:id and /c/:id as a page. Canonical stays with the owning app when that
+ * app serves the item (Live's /vod/:id and /clip/:id); anything else is
+ * self-canonical here.
+ */
+function renderWatchPage(kind, record) {
+ const isVod = kind === 'vod';
+ const id = record.id;
+ const self = `${config.publicUrl}/${isVod ? 'v' : 'c'}/${id}`;
+ const rawUrl = `${self}?raw=1`;
+ const title = snip(record.title || (isVod ? `VOD #${id}` : `Clip #${id}`), 120);
+ const by = creatorLine(record);
+ const description = snip(record.description || record.ai_overview || `${isVod ? 'A recorded live stream' : 'A clip'}${by ? ` by ${by}` : ''} on the OpenVibe network.`, 200);
+ const thumb = abs(record.thumbnail_url);
+ const ownedByLive = (record.app_id || 'live') === 'live';
+ const canonical = ownedByLive ? sourcePage(kind, record) : self;
+ const visibility = record.visibility || (record.is_public ? 'public' : 'private');
+ // The owning app has the canonical page; unlisted items never index.
+ const robots = ownedByLive || visibility !== 'public' ? 'noindex, follow' : 'index, follow';
+ const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';
+ const uploadDate = isoDate(record.created_at);
+ const duration = isoDuration(record.duration_seconds);
+
+ const videoObject = {
+ '@context': 'https://schema.org',
+ '@type': 'VideoObject',
+ name: title,
+ description,
+ url: canonical,
+ contentUrl: rawUrl,
+ embedUrl: self,
+ ...(thumb ? { thumbnailUrl: [thumb] } : {}),
+ ...(uploadDate ? { uploadDate } : {}),
+ ...(duration ? { duration } : {}),
+ ...(by ? { author: { '@type': 'Person', name: by } } : {}),
+ ...(record.view_count ? { interactionStatistic: { '@type': 'InteractionCounter', interactionType: 'https://schema.org/WatchAction', userInteractionCount: record.view_count } } : {}),
+ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
+ isFamilyFriendly: true,
+ };
+
+ const metaBits = [
+ isVod ? 'VOD' : 'Clip',
+ record.duration_seconds ? fmtDuration(record.duration_seconds) : null,
+ record.view_count != null ? `${Number(record.view_count).toLocaleString('en-US')} views` : null,
+ fmtDate(record.created_at) || null,
+ by ? `by ${esc(by)}` : null,
+ ].filter(Boolean);
+
+ const body = `
+ <h1>${esc(title)}</h1>
+ <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
+ <div class="player">
+ <video controls playsinline preload="metadata"${thumb ? ` poster="${esc(thumb)}"` : ''} src="${esc(rawUrl)}">
+ Your browser cannot play this video. <a href="${esc(rawUrl)}">Download it</a> instead.
+ </video>
+ </div>
+ <div class="actions">
+ ${ownedByLive ? `<a class="btn" href="${esc(canonical)}">${isVod ? 'Watch on OpenVibe.Live' : 'Watch on OpenVibe.Live'} — chat, transcript & more</a>` : ''}
+ <a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</a>
+ ${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}
+ </div>
+ ${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
+ ${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}`;
+
+ const css = `
+ .player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }
+ .player video { display: block; width: 100%; max-height: 72vh; background: #000; }
+ .h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
+
+ return chrome.page({
+ seo: {
+ title: `${title} — ${SITE_NAME}`, description, canonical, robots,
+ image: thumb || undefined, ogType: 'video.other',
+ video: { url: rawUrl, type: mime },
+ jsonLd: [videoObject],
+ },
+ css, body,
+ history: { type: isVod ? 'vod' : 'clip', title },
+ footer: { variant: 'compact' },
+ });
+}
+
+/** /p/:slug — thin viewer; the page's home is OpenVibe.Community. */
+function renderPastePage(paste) {
+ const slug = String(paste.slug);
+ const title = snip(paste.title || 'Untitled', 120);
+ const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;
+ const self = `${config.publicUrl}/p/${encodeURIComponent(slug)}`;
+ const communityUrl = `${appUrl('community')}/p/${encodeURIComponent(slug)}`;
+ const screenshotUrl = `${self}/screenshot`;
+ const language = paste.language || 'text';
+ const description = snip(paste.ai_summary || (isScreenshot ? (paste.content || `Screenshot "${title}" shared on the OpenVibe network.`) : (paste.content || `${language} paste shared on the OpenVibe network.`)), 200);
+ const created = isoDate(paste.created_at);
+ const updated = isoDate(paste.updated_at) || created;
+ const tags = (() => { try { const t = JSON.parse(paste.ai_tags || '[]'); return Array.isArray(t) ? t.map(String).slice(0, 12) : []; } catch { return String(paste.ai_tags || '').split(',').map(s => s.trim()).filter(Boolean).slice(0, 12); } })();
+
+ const ld = isScreenshot
+ ? {
+ '@context': 'https://schema.org', '@type': 'ImageObject',
+ name: title, description, url: communityUrl, contentUrl: screenshotUrl, mainEntityOfPage: communityUrl,
+ ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),
+ ...(tags.length ? { keywords: tags.join(', ') } : {}),
+ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
+ }
+ : {
+ '@context': 'https://schema.org', '@type': 'Article',
+ headline: title, description, url: communityUrl, mainEntityOfPage: communityUrl,
+ ...(created ? { datePublished: created } : {}), ...(updated ? { dateModified: updated } : {}),
+ ...(tags.length ? { keywords: tags.join(', ') } : {}),
+ genre: language === 'text' ? 'Text paste' : `${language} code paste`,
+ isAccessibleForFree: true,
+ hasPart: { '@type': 'CreativeWork', name: title, encodingFormat: 'text/plain', url: `${self}/raw` },
+ publisher: { '@type': 'Organization', name: 'OpenVibe', url: NETWORK_URL },
+ };
+
+ const content = isScreenshot
+ ? `<figure class="shot"><img src="${esc(screenshotUrl)}" alt="${esc(title)}" loading="lazy"></figure>
+ ${paste.content ? `<p class="desc">${esc(paste.content)}</p>` : ''}`
+ : `<pre class="code" data-language="${esc(language)}"><code>${esc(paste.content || '')}</code></pre>`;
+
+ const metaBits = [
+ esc(language),
+ `${Number(paste.views || 0).toLocaleString('en-US')} views`,
+ paste.unique_views != null ? `${Number(paste.unique_views).toLocaleString('en-US')} unique` : null,
+ fmtDate(paste.created_at) || null,
+ isScreenshot ? null : `<a href="${esc(`/p/${encodeURIComponent(slug)}/raw`)}">raw</a>`,
+ ].filter(Boolean);
+
+ const body = `
+ <p class="note">This paste lives on <a href="${esc(communityUrl)}">OpenVibe.Community</a> — comments, likes and forks are there. This is the plain viewer.</p>
+ <h1>${esc(title)}</h1>
+ <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
+ ${content}
+ <div class="actions"><a class="btn" href="${esc(communityUrl)}">Open on OpenVibe.Community</a></div>`;
+
+ const css = `
+ .code { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; margin: 0 0 1rem; }
+ .shot { margin: 0 0 1rem; }
+ .shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;
+
+ return chrome.page({
+ seo: {
+ title: `${title} — ${SITE_NAME}`, description,
+ // Canonical on Community: search engines index the paste there, not here.
+ canonical: communityUrl, robots: 'noindex, follow',
+ image: isScreenshot ? screenshotUrl : undefined, ogType: 'article',
+ twitterCard: isScreenshot ? 'summary_large_image' : 'summary',
+ jsonLd: [ld],
+ },
+ css, body,
+ history: { type: 'paste', title },
+ footer: { variant: 'compact' },
+ });
+}
+
+module.exports = { wantsHtmlPage, renderWatchPage, renderPastePage, sourcePage };
@@ -3,16 +3,20 @@
@@ -3,16 +3,20 @@
*
*
* GET /v/:id VOD playback: local stream w/ Range, or 302 presigned
* GET /v/:id VOD playback: local stream w/ Range, or 302 presigned
* B2/R2 (inherited logic). Live recordings serve the
* B2/R2 (inherited logic). Live recordings serve the
- * fully-indexed .seekable sidecar for DVR.
- * GET /c/:id clip playback (same tiering/range logic)
- * GET /p/:slug paste HTML page (indexable)
+ * fully-indexed .seekable sidecar for DVR. A browser
+ * NAVIGATING here gets a watch page instead (see
+ * pages.js — ?raw=1 always yields the bytes).
+ * GET /c/:id clip playback (same tiering/range logic + watch page)
+ * GET /p/:slug paste viewer — canonical on OpenVibe.Community
* GET /p/:slug/raw paste raw text
* GET /p/:slug/raw paste raw text
* GET /p/:slug/screenshot paste screenshot image
* GET /p/:slug/screenshot paste screenshot image
* GET /t/:id thumbnails (id = filename)
* GET /t/:id thumbnails (id = filename)
* GET /f/:key files with correct Content-Type + Range
* GET /f/:key files with correct Content-Type + Range
+ * GET /og-image.png the site's share card
*
*
- * /v /c /t /f carry X-Robots-Tag: noindex — the owning app has the canonical
- * page. Pastes are Media-canonical and stay indexable.
+ * /v /c /t /f bytes carry X-Robots-Tag: noindex — the owning app has the
+ * canonical page (the watch page says so with rel=canonical). The paste
+ * viewer is noindex too: Community owns that URL now.
*/
*/
'use strict';
'use strict';
@@ -22,6 +26,7 @@ const fs = require('fs');
@@ -22,6 +26,7 @@ const fs = require('fs');
const db = require('../db/database');
const db = require('../db/database');
const tools = require('../vod/media-tools');
const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const { optionalIdentity } = require('../auth');
+const pages = require('./pages');
const router = express.Router();
const router = express.Router();
@@ -111,6 +116,13 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -111,6 +116,13 @@ async function serveMediaRecord(kind, record, req, res) {
return res.status(403).json({ error: 'This media is private' });
return res.status(403).json({ error: 'This media is private' });
}
}
+ // A person (or a link-preview crawler) landing on the URL gets the watch
+ // page; its <video> comes back here with ?raw=1 for the bytes.
+ if (pages.wantsHtmlPage(req)) {
+ res.set('Cache-Control', visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
+ return res.type('html').send(pages.renderWatchPage(kind, record));
+ }
+
trackUniqueView(kind, record.id, req, record.user_id);
trackUniqueView(kind, record.id, req, record.user_id);
// Track last access time for storage tier decisions
// Track last access time for storage tier decisions
@@ -196,6 +208,12 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {
@@ -196,6 +208,12 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {
}
}
});
});
+// ── Share card (og:image default) ────────────────────────────
+router.get('/og-image.png', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=86400');
+ res.sendFile(path.join(__dirname, '..', 'static', 'og-image.png'));
+});
+
// ── Thumbnails ───────────────────────────────────────────────
// ── Thumbnails ───────────────────────────────────────────────
router.get('/t/:id', (req, res) => {
router.get('/t/:id', (req, res) => {
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
@@ -415,59 +433,7 @@ router.get('/f/:key', (req, res) => {
@@ -415,59 +433,7 @@ router.get('/f/:key', (req, res) => {
// ── Pastes ───────────────────────────────────────────────────
// ── Pastes ───────────────────────────────────────────────────
-function escapeHtml(s) {
- return String(s ?? '')
- .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
- .replace(/"/g, '"').replace(/'/g, ''');
-}
-
-function renderPastePage(paste) {
- const title = escapeHtml(paste.title || 'Untitled');
- const isScreenshot = paste.type === 'screenshot' && paste.screenshot_path;
- const body = isScreenshot
- ? `<figure class="shot"><img src="/p/${escapeHtml(paste.slug)}/screenshot" alt="${title}"></figure>
- ${paste.content ? `<p class="desc">${escapeHtml(paste.content)}</p>` : ''}`
- : `<pre class="code" data-language="${escapeHtml(paste.language || 'text')}"><code>${escapeHtml(paste.content || '')}</code></pre>`;
- const created = escapeHtml(String(paste.created_at || ''));
- return `<!DOCTYPE html>
-<html lang="en">
-<head>
-<meta charset="utf-8">
-<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>${title} — OpenVibe.Media</title>
-<meta name="description" content="${title} — shared via OpenVibe.Media pastes">
-<style>
- :root { --accent: #8b5cf6; --accent-light: #a78bfa; --bg: #131318; --panel: #1b1b22; --text: #e8e8ee; --muted: #9a9aa8; }
- * { box-sizing: border-box; }
- body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; }
- header { display: flex; align-items: center; gap: .6rem; padding: .8rem 1.2rem; border-bottom: 1px solid #26262f; }
- header .brand { font-weight: 700; color: var(--accent-light); text-decoration: none; font-size: 1.05rem; }
- header .brand span { color: var(--text); }
- main { max-width: 960px; margin: 0 auto; padding: 1.2rem; }
- h1 { font-size: 1.3rem; margin: .2rem 0 .3rem; }
- .meta { color: var(--muted); font-size: .85rem; margin-bottom: 1rem; }
- .meta a { color: var(--accent-light); text-decoration: none; }
- .code { background: var(--panel); border: 1px solid #26262f; border-radius: 10px; padding: 1rem 1.2rem; overflow-x: auto; white-space: pre; font: 13px/1.5 ui-monospace, 'Cascadia Code', Menlo, monospace; }
- .shot img { max-width: 100%; border-radius: 10px; border: 1px solid #26262f; }
- .desc { color: var(--muted); }
- footer { max-width: 960px; margin: 0 auto; padding: 1rem 1.2rem 2rem; color: var(--muted); font-size: .82rem; }
- footer a { color: var(--accent-light); text-decoration: none; }
-</style>
-</head>
-<body>
-<header>
- <a class="brand" href="https://openvibe.network">OpenVibe<span>.Media</span></a>
-</header>
-<main>
- <h1>${title}</h1>
- <p class="meta">${escapeHtml(paste.language || 'text')} · ${paste.views || 0} views · ${paste.unique_views || 0} unique · ${created}${isScreenshot ? '' : ` · <a href="/p/${escapeHtml(paste.slug)}/raw">raw</a>`}</p>
- ${body}
-</main>
-<footer>Shared via OpenVibe.Media — <a href="https://openvibe.network">One Account. All of OpenVibe.</a></footer>
-</body>
-</html>`;
-}
-
+// The page itself is rendered in pages.js (thin viewer; canonical on Community).
router.get('/p/:slug', optionalIdentity, (req, res) => {
router.get('/p/:slug', optionalIdentity, (req, res) => {
try {
try {
const paste = db.getPasteBySlug(String(req.params.slug));
const paste = db.getPasteBySlug(String(req.params.slug));
@@ -494,7 +460,8 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
@@ -494,7 +460,8 @@ router.get('/p/:slug', optionalIdentity, (req, res) => {
return res.status(410).send('This paste has been burned after reading.');
return res.status(410).send('This paste has been burned after reading.');
}
}
- res.type('html').send(renderPastePage(paste));
+ res.set('Cache-Control', 'private, no-store');
+ res.type('html').send(pages.renderPastePage(paste));
} catch (err) {
} catch (err) {
console.error('[Public] /p error:', err.message);
console.error('[Public] /p error:', err.message);
res.status(500).send('Error');
res.status(500).send('Error');
new file mode 100644
new file mode 100644
Binary files /dev/null and b/server/static/og-image.png differ
Binary files /dev/null and b/server/static/og-image.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),
+// the paste viewer's Community canonical, and the shared chrome + SEO on all of them.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-media-pages-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+const db = require('../server/db/database');
+require('../server/views/service').ensureSchema();
+const pages = require('../server/public/pages');
+const chrome = require('../server/public/page-chrome');
+
+// ── wantsHtmlPage: navigations and preview bots get the page, players get bytes ──
+const req = (headers, query = {}) => ({ method: 'GET', headers, query });
+assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html,*/*' })), true);
+assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'video', accept: 'video/webm,*/*;q=0.5' })), false);
+assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html' }, { raw: '1' })), false, '?raw=1 always yields bytes');
+assert.strictEqual(pages.wantsHtmlPage(req({ 'sec-fetch-dest': 'document', accept: 'text/html', range: 'bytes=0-' })), false, 'range requests are bytes');
+assert.strictEqual(pages.wantsHtmlPage(req({ accept: 'text/html,application/xhtml+xml' })), true, 'Accept-based fallback');
+assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*' })), false, 'curl / fetch defaults get bytes');
+assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*', 'user-agent': 'Mozilla/5.0 (compatible; Discordbot/2.0)' })), true, 'preview bots get the page');
+assert.strictEqual(pages.wantsHtmlPage({ method: 'POST', headers: { accept: 'text/html' }, query: {} }), false);
+
+// ── Watch page: SEO + chrome + canonical rules ──
+const vod = {
+ id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',
+ file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,
+ is_public: 1, visibility: 'public', created_at: '2026-09-10 20:15:00', ai_overview: 'AI says: nice.',
+};
+const html = pages.renderWatchPage('vod', vod);
+assert.ok(html.includes('<title>Late night <build> — OpenVibe.Media</title>'));
+assert.ok(html.includes('<link rel="canonical" href="https://openvibe.live/vod/42">'), 'Live-owned VOD is canonical on Live');
+assert.ok(html.includes('<meta name="robots" content="noindex, follow">'));
+assert.ok(html.includes('https://openvibe.network/shared/theme-loader.js'));
+assert.ok(html.indexOf('theme-loader.js') < html.indexOf('<style>'), 'theme-loader runs before the styles');
+assert.ok(html.includes('https://openvibe.network/shared/navbar.js') && html.includes('https://openvibe.network/shared/footer.js'));
+assert.ok(html.includes('"service":"media"') && html.includes('"history":{"type":"vod","title":"Late night <build>"}'.replace('<', '\\u003c')));
+assert.ok(html.includes('"variant":"compact"'));
+assert.ok(html.includes('<meta property="og:type" content="video.other">'));
+assert.ok(html.includes('<meta property="og:video" content="https://media.test/v/42?raw=1">'));
+assert.ok(html.includes('<meta name="twitter:card" content="summary_large_image">'));
+assert.ok(html.includes('src="https://media.test/v/42?raw=1"'), 'the player fetches the bytes with ?raw=1');
+const ld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html)[1]);
+assert.strictEqual(ld['@type'], 'VideoObject');
+assert.strictEqual(ld.duration, 'PT1H2M5S');
+assert.strictEqual(ld.uploadDate, '2026-09-10T20:15:00.000Z');
+assert.deepStrictEqual(ld.thumbnailUrl, ['https://media.test/t/vod-42-1.jpg']);
+assert.strictEqual(ld.url, 'https://openvibe.live/vod/42');
+assert.ok(!html.includes('</script>"'), 'no script-closing sequence smuggled through JSON');
+
+const other = pages.renderWatchPage('clip', { ...vod, id: 7, app_id: 'games', title: 'Frag' });
+assert.ok(other.includes('<link rel="canonical" href="https://media.test/c/7">'), 'non-Live items are self-canonical');
+assert.ok(other.includes('<meta name="robots" content="index, follow">'));
+const unlisted = pages.renderWatchPage('clip', { ...vod, id: 8, app_id: 'games', visibility: 'unlisted' });
+assert.ok(unlisted.includes('content="noindex, follow"'), 'unlisted never indexes');
+
+// ── Paste viewer: canonical on Community, noindex, Article JSON-LD ──
+const paste = { slug: 'abc-123', title: 'hello.js', type: 'paste', content: 'console.log("hi")', language: 'javascript', views: 3, unique_views: 2, created_at: '2026-09-01 10:00:00', updated_at: '2026-09-02 10:00:00', ai_tags: '["js","demo"]' };
+const ph = pages.renderPastePage(paste);
+assert.ok(ph.includes('<link rel="canonical" href="https://openvibe.community/p/abc-123">'));
+assert.ok(ph.includes('<meta name="robots" content="noindex, follow">'));
+assert.ok(ph.includes('href="https://openvibe.community/p/abc-123"'), 'header links to the Community page');
+assert.ok(ph.includes('console.log("hi")'));
+assert.ok(ph.includes('"history":{"type":"paste","title":"hello.js"}'));
+const pld = JSON.parse(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(ph)[1]);
+assert.strictEqual(pld['@type'], 'Article');
+assert.strictEqual(pld.mainEntityOfPage, 'https://openvibe.community/p/abc-123');
+assert.strictEqual(pld.keywords, 'js, demo');
+const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });
+assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));
+assert.ok(shot.includes('"@type":"ImageObject"'));
+assert.ok(!/\bfree\b/i.test(html + ph + shot + chrome.baseCss()), 'no cost claims in served HTML');
+
+// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──
+const express = require('express');
+const raw = db.getDb();
+raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, duration_seconds) VALUES (?, ?, ?, ?, 1, 'public', 10)`).run(42, 'live', 'Router VOD', '/nonexistent/vod-42.mp4');
+raw.prepare(`INSERT INTO pastes (slug, app_id, title, type, content, language, visibility) VALUES (?, 'live', 'P', 'paste', 'x', 'text', 'public')`).run('slug1');
+const app = express();
+app.use('/', require('../server/public/routes'));
+const server = app.listen(0);
+const port = server.address().port;
+const get = (p, headers) => new Promise((resolve, reject) => {
+ http.get({ host: '127.0.0.1', port, path: p, headers }, (res) => {
+ let body = ''; res.setEncoding('utf8'); res.on('data', c => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
+ }).on('error', reject);
+});
+(async () => {
+ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
+ let r = await get('/v/42', nav);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.headers['content-type'].startsWith('text/html'));
+ assert.ok(r.body.includes('Router VOD') && r.body.includes('id="navbar-mount"'));
+ r = await get('/v/42?raw=1', nav);
+ assert.strictEqual(r.status, 404, 'bytes path (no file on disk → 404 JSON), not the page');
+ assert.ok(r.headers['content-type'].startsWith('application/json'));
+ r = await get('/v/42', { accept: 'video/webm,video/ogg,video/*;q=0.9,*/*;q=0.5', 'sec-fetch-dest': 'video' });
+ assert.strictEqual(r.status, 404, 'a <video> element never gets HTML');
+ r = await get('/p/slug1', nav);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.includes('https://openvibe.community/p/slug1'));
+ r = await get('/og-image.png', {});
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['content-type'], 'image/png');
+ server.close();
+ for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ console.log('public pages: all checks passed');
+ process.exit(0);
+})().catch((err) => { console.error(err); server.close(); process.exit(1); });