openvibers/openvibe.media Commits

Public pages: shared chrome + SEO on /v, /c and /p; pastes canonical on Community

OP OpenVibers committed 18 Sept 2026 · b00adc6065 · 5 files, +550 −59

page-chrome.js is the shell every server-rendered page shares: the
Network's theme-loader before paint, navbar (service media, with the
item's history entry) and compact footer after, and the SEO head —
title, description, canonical, robots, Open Graph, Twitter card, JSON-LD.

/v/:id and /c/:id stay the media bytes for players, fetches, Range
requests and ?raw=1; a browser navigating there (or a link-preview
crawler) now gets a watch page with a player, the source link and a
VideoObject. Live-owned items keep openvibe.live/vod|clip/:id as
canonical (noindex here); anything else is self-canonical.

/p/:slug is a thin viewer now: canonical https://openvibe.community/p/<slug>,
noindex, header link to the Community page, Article / ImageObject JSON-LD.
/og-image.png serves the share card. Tests cover the negotiation rules,
the canonical decisions and the router end to end.

Changes

ddiff --git a/server/public/page-chrome.js b/server/public/page-chrome.js
nnew file mode 100644
iindex 0000000..f3f97bb
---- /dev/null
++++ b/server/public/page-chrome.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++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,
++};
ddiff --git a/server/public/pages.js b/server/public/pages.js
nnew file mode 100644
iindex 0000000..c4b6160
---- /dev/null
++++ b/server/public/pages.js
@@@ -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 &amp; 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 };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 6a0c241..29a5cad 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
-- .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
--}
--
--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');
ddiff --git a/server/static/og-image.png b/server/static/og-image.png
nnew file mode 100644
iindex 0000000..9596ab7
BBinary files /dev/null and b/server/static/og-image.png differ
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
nnew file mode 100644
iindex 0000000..6b080b5
---- /dev/null
++++ b/test/public-pages.test.js
@@@ -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 &lt;build&gt; — 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(&quot;hi&quot;)'));
++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); });