openvibers/openvibe.media Commits
Visitor sign-in (OAuth client media), single navbar, scoped tab styles, legal pages, vendor sync
Changes
@@@ -27,6 +27,25 @@ auth.startJwksRefresh();const app = express();app.set('trust proxy', true);app.use(express.json({ limit: '2mb' }));++++// ── Visitor sign-in (OAuth client `media` on the Network; same module as Community/Tools) ──++// Host-only cookies on openvibe.media. No new dependency: cookies are parsed here.++app.use((req, _res, next) => {++ req.cookies = {};++ for (const part of String(req.headers.cookie || '').split(';')) {++ const i = part.indexOf('='); if (i < 1) continue;++ try { req.cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } catch { /* malformed value */ }++ }++ next();++});++{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'media', service: 'media', host: 'openvibe.media', name: 'OpenVibe.Media', profile: 'hosting' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }++const userAuth = require('./user-auth');++const userAuthConfig = {++ baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,++ cookies: { secure: /^https:/.test(config.publicUrl) },++ oauth: { clientId: process.env.OV_OAUTH_CLIENT_ID || 'media', clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '', redirectUri: process.env.OV_OAUTH_REDIRECT_URI || `${config.publicUrl}/auth/callback` },++};++app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuth.createAuthClient(userAuthConfig)));app.use(express.urlencoded({ extended: true, limit: '2mb' }));// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).@@@ -241,14 +241,13 @@ function renderPage(tab, page, data, counts) {<style>:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}--header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);background:var(--bg-secondary,rgba(15,20,32,.92))}--header .logo{width:30px;height:30px}--header h1{font-size:1.15rem}header h1 b{color:var(--accent-page)}--header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}--nav{display:flex;gap:.25rem;padding:.7rem 1.4rem;flex-wrap:wrap}--nav a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}--nav a:hover{color:var(--text)}nav a.on{color:var(--on-accent,#fff);background:var(--accent-page)}--nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}++header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:1rem 1.4rem .4rem}++header.intro h1{font-size:1.15rem;margin:0}++header.intro .sub{color:var(--muted);font-size:.85rem}++nav.tabs{display:flex;gap:.25rem;padding:.2rem 1.4rem .7rem;flex-wrap:wrap}++nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}++nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent,#fff);background:var(--accent-page)}++nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}@@@ -269,12 +268,8 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"></head><body><div id="navbar-mount"></div>--<header>-- <svg class="logo" viewBox="0 0 100 100" fill="none" stroke="var(--accent-page)" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>-- <h1>OpenVibe<b>.Media</b></h1>-- <span class="sub">every public file on the network — videos, clips, images, pastes & thumbnails</span>--</header>--<nav>${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>++<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>++<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div><footer id="ov-footer" class="ovf"></footer>@@@ -134,7 +134,7 @@ function baseCss() {* footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }*/function chromeScripts({ history, footer } = {}) {-- const navOpts = { service: 'media', apiBase: NETWORK_URL };++ const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, sessionUrl: '/auth/me' };if (history) navOpts.history = history;const footOpts = {service: 'media',nnew file mode 100644@@@ -0,0 +1,422 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// openvibe.media — OAuth2 CLIENT session layer++//++// The identity provider is OpenVibe.Network (https://openvibe.network).++// This site is registered there as OAuth client `media`, redirect++// https://openvibe.media/auth/callback. Same shape as the Tools gateway.++//++// GET /auth/login → redirect to Network /oauth/authorize++// ?silent=1 adds prompt=none (one quiet round trip; a++// signed-out browser comes straight back with++// error=login_required and lands on next + ?sso=none)++// ?next=… same-site path or an https://openvibe.network/…++// URL (sign-in-everywhere chains)++// A silent login whose ov_token already verifies skips the++// Network round trip and 302s straight to next.++// GET /auth/callback → server-side code exchange, set cookies++// POST /auth/fedcm → browser-native FedCM: the navbar posts the Network's++// assertion JWT + the nonce it minted; we check the nonce,++// swap the assertion for tokens (jwt-bearer grant), set the++// same cookies as the callback++// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest++// GET /auth/me → offline-verify ov_token, return profile++// POST /auth/refresh → rotate tokens via refresh_token grant++//++// Cookies (all host-only — openvibe.media has no subdomains):++// ov_token — access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)++// ov_refresh — opaque refresh token, httpOnly, Path=/auth++// ov_sso_hint — 'account' after a sign-in, 'guest' after a sign-out; 1 year,++// JS-readable. navbar.js only attempts a silent login when it says++// 'account', so signed-out visitors never get bounced.++// ═══════════════════════════════════════════════════════════════++++const express = require('express');++const crypto = require('crypto');++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');++++const ACCESS_COOKIE = 'ov_token';++const REFRESH_COOKIE = 'ov_refresh';++const HINT_COOKIE = 'ov_sso_hint';++const STATE_COOKIE = 'ov_oauth_state';++const NEXT_COOKIE = 'ov_oauth_next';++const SILENT_COOKIE = 'ov_oauth_silent';++++/**++ * Create the auth client + JWKS fetcher shared by the whole app.++ * Verification is OFFLINE: we cache the Network's RS256 public key from++ * GET /api/.well-known/jwks and verify JWTs locally on every request.++ */++function createAuthClient(config) {++ const client = new OpenVibeAuthClient({++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ redirectUri: config.oauth.redirectUri,++ publicKey: null, // filled by ensureKey()++ authBase: config.networkUrl,++ internalBase: config.networkInternalUrl,++ });++++ let lastFetch = 0;++ async function ensureKey() {++ if (client.publicKey) return client.publicKey;++ // Don't hammer the Network if it's down — retry at most every 30s++ if (Date.now() - lastFetch < 30_000) return null;++ lastFetch = Date.now();++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });++ if (!res.ok) continue;++ const jwks = await res.json();++ if (jwks.public_key) {++ client.publicKey = jwks.public_key;++ console.log(`[Auth] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);++ return client.publicKey;++ }++ } catch (err) {++ console.warn(`[Auth] JWKS fetch failed from ${base}: ${err.message}`);++ }++ }++ return null;++ }++++ /** Offline JWT verification. Returns decoded claims or null. */++ async function verify(token) {++ if (!token) return null;++ await ensureKey();++ return client.verifyToken(token);++ }++++ // Warm the key cache at boot (non-fatal if the Network is down)++ ensureKey().catch(() => {});++++ return { client, ensureKey, verify };++}++++/** Token from Authorization header or the ov_token cookie. */++function extractToken(req) {++ const h = req.headers.authorization;++ if (h && h.startsWith('Bearer ')) return h.slice(7);++ return req.cookies?.[ACCESS_COOKIE] || null;++}++++/** Strip registered claims — what the site shows as "the user". */++function claimsToUser(claims) {++ if (!claims) return null;++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;++ return user;++}++++/**++ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims++ * we cross-check locally (the FedCM nonce) before the Network — which does verify++ * the signature — sees the assertion. Never use it to trust an identity.++ */++function decodeJwtPayload(token) {++ if (typeof token !== 'string') return null;++ const parts = token.split('.');++ if (parts.length !== 3) return null;++ try {++ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;++ } catch { return null; }++}++++/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */++function fedcmNonceMatches(token, nonce) {++ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;++ const claims = decodeJwtPayload(token);++ if (!claims || typeof claims.nonce !== 'string') return false;++ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);++ return a.length === b.length && crypto.timingSafeEqual(a, b);++}++++/**++ * Only allow same-site relative paths, this site's own https origin, or an++ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.++ */++function sanitizeNext(next, config) {++ if (!next || typeof next !== 'string') return '/';++ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative++ try {++ const u = new URL(next);++ if (u.protocol !== 'https:') return '/';++ const allowed = [config.baseUrl, config.networkUrl]++ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })++ .filter(Boolean);++ if (allowed.includes(u.hostname)) return u.toString();++ } catch { /* fall through */ }++ return '/';++}++++/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */++function withParam(target, key, value) {++ const hashAt = target.indexOf('#');++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;++ const sep = base.includes('?') ? '&' : '?';++ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;++}++++/**++ * Build the Network authorize URL for a login. Exported (and pure) so tests can++ * cover the silent-login shape without a browser.++ */++function buildAuthorizeUrl(config, auth, { silent = false } = {}) {++ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);++ if (!silent) return { url, state };++ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the++ // network has no session either, it bounces back with error=login_required and we stay quiet.++ const u = new URL(url);++ u.searchParams.set('prompt', 'none');++ return { url: u.toString(), state };++}++++function createAuthRoutes(config, auth) {++ const router = express.Router();++++ const accessCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side++ path: '/',++ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT++ });++++ const refreshCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: true,++ path: '/auth', // only ever sent back to this session layer++ maxAge: 30 * 24 * 60 * 60 * 1000,++ });++++ const flowCookieOpts = () => ({++ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,++ path: '/auth', maxAge: 10 * 60 * 1000,++ });++++ const hintCookieOpts = () => ({++ sameSite: 'lax',++ secure: config.cookies.secure,++ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying++ path: '/',++ maxAge: 365 * 24 * 60 * 60 * 1000,++ });++++ function setSessionCookies(res, accessToken, refreshToken) {++ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());++ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());++ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());++ }++++ function clearSessionCookies(res) {++ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });++ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });++ }++++ function clearFlowCookies(res) {++ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });++ }++++ /** Exchange at the Network — internal URL first, public as fallback. */++ async function tokenGrant(body) {++ let lastErr = null;++ for (const base of [config.networkInternalUrl, config.networkUrl]) {++ if (!base) continue;++ try {++ const res = await fetch(`${base}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ ...body,++ }),++ signal: AbortSignal.timeout(10_000),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) {++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);++ err.status = res.status;++ err.error = data.error || 'invalid_grant';++ err.error_description = data.error_description || null;++ throw err;++ }++ return data;++ } catch (err) {++ lastErr = err;++ // 4xx = the grant itself is bad; retrying against another base won't help++ if (err.status && err.status < 500) throw err;++ }++ }++ throw lastErr || new Error('Network unreachable');++ }++++ // ── GET /auth/login ──────────────────────────────────────++ router.get('/login', async (req, res) => {++ const silent = !!req.query.silent && req.query.silent !== '0';++ if (silent) {++ // Already signed in here? Then the silent round trip through the Network would++ // only hand back the session we have: go straight to where the caller wanted.++ const existing = req.cookies?.[ACCESS_COOKIE];++ if (existing && await auth.verify(existing)) {++ clearFlowCookies(res);++ return res.redirect(sanitizeNext(req.query.next, config));++ }++ }++ const { url, state } = buildAuthorizeUrl(config, auth, { silent });++ res.cookie(STATE_COOKIE, state, flowCookieOpts());++ const next = sanitizeNext(req.query.next, config);++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());++ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });++ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());++ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });++ res.redirect(url);++ });++++ // ── GET /auth/callback ───────────────────────────────────++ router.get('/callback', async (req, res) => {++ const { code, state, error } = req.query;++ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);++ const silent = req.cookies?.[SILENT_COOKIE] === '1';++++ if (error) {++ clearFlowCookies(res);++ // Silent sign-in found no network session — go back quietly as a guest. The++ // ?sso=none marker stops navbar.js from trying again on that page.++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));++ return res.redirect(withParam('/', 'auth_error', String(error)));++ }++ if (!code) return res.status(400).send('Missing authorization code');++++ const expectedState = req.cookies?.[STATE_COOKIE];++ clearFlowCookies(res);++ // The state cookie is required, not just compared when present: skipping the check when++ // the cookie is missing would let a crafted link sign a visitor into someone else's account.++ if (!expectedState || !state || !crypto.timingSafeEqual(++ Buffer.from(String(state).padEnd(64).slice(0, 64)),++ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))++ )) {++ return res.status(400).send('OAuth state mismatch — please try signing in again.');++ }++++ try {++ const data = await tokenGrant({++ grant_type: 'authorization_code',++ redirect_uri: config.oauth.redirectUri,++ code,++ });++ setSessionCookies(res, data.access_token, data.refresh_token);++ return res.redirect(next);++ } catch (err) {++ console.error('[Auth] Code exchange failed:', err.message);++ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');++ }++ });++++ // ── POST /auth/fedcm ─────────────────────────────────────++ // The shared navbar obtained a FedCM assertion from openvibe.network (the++ // browser's own account chooser) and posts it same-origin with the nonce it++ // put in the FedCM request. We only pre-check the nonce; the Network verifies++ // the assertion's signature when it swaps it for tokens.++ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });++ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());++ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {++ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });++ const { token, nonce } = req.body || {};++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });++ }++ if (!fedcmNonceMatches(token, nonce)) {++ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });++ }++ try {++ const data = await tokenGrant({++ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',++ assertion: token,++ });++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const user = data.user || claimsToUser(await auth.verify(data.access_token));++ return res.json({ ok: true, user });++ } catch (err) {++ if (err.status && err.status < 500) {++ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });++ }++ console.error('[Auth] FedCM exchange failed:', err.message);++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });++ }++ });++++ // ── GET /auth/logout ─────────────────────────────────────++ router.get('/logout', async (req, res) => {++ // Best-effort refresh revocation. The Network's rotating refresh tokens++ // self-invalidate, so a failure here is harmless.++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (refresh) {++ try {++ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({++ client_id: config.oauth.clientId,++ client_secret: config.oauth.clientSecret,++ token: refresh,++ }),++ signal: AbortSignal.timeout(3000),++ });++ } catch { /* optional */ }++ }++ clearSessionCookies(res);++ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());++ res.redirect(sanitizeNext(req.query.next, config));++ });++++ // ── GET /auth/me ─────────────────────────────────────────++ router.get('/me', async (req, res) => {++ const token = extractToken(req);++ if (!token) return res.status(401).json({ error: 'Not authenticated' });++ const claims = await auth.verify(token);++ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });++ });++++ // ── POST /auth/refresh ───────────────────────────────────++ // Small endpoint the shared navbar can call when ov_token expires.++ router.post('/refresh', async (req, res) => {++ const refresh = req.cookies?.[REFRESH_COOKIE];++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });++ setSessionCookies(res, data.access_token, data.refresh_token);++ const claims = await auth.verify(data.access_token);++ return res.json({ token: data.access_token, user: claimsToUser(claims) });++ } catch (err) {++ if (err.status && err.status < 500) {++ clearSessionCookies(res);++ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });++ }++ console.error('[Auth] Refresh failed:', err.message);++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });++ }++ });++++ return router;++}++++/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */++function optionalAuth(auth) {++ return async (req, _res, next) => {++ const token = extractToken(req);++ if (token) {++ const claims = await auth.verify(token);++ if (claims) { req.user = claimsToUser(claims); req.token = token; }++ }++ next();++ };++}++++module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };@@@ -27,6 +27,41 @@})(typeof globalThis !== 'undefined' ? globalThis : this, function () {'use strict';++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++const NETWORK_URL = 'https://openvibe.network';const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -50,9 +85,9 @@];const LEGAL = [-- { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },-- { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },-- { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },++ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },++ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },++ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },];const SOCIAL = [@@@ -106,6 +141,9 @@}function legalHref(item, cfg) {++ // Each site answers for itself: the chrome service names this domain's own documents.++ const chrome = cfg._chrome;++ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;@@@ -114,7 +152,9 @@// ── Markup ─────────────────────────────────────────────────function linkTag(item) {-- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';++ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.++ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);++ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';const title = item.desc ? ` title="${esc(item.desc)}"` : '';return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;@@@ -124,7 +164,14 @@const c = { ...DEFAULTS, ...(cfg || {}) };const service = c.service || detectService();const brand = c.brandName || brandFor(service);-- const network = NETWORK.filter(n => n.id !== service);++ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });++ // Most used sites first when the chrome service has spoken; the built-in list otherwise.++ const network = chrome && chrome.nav.length++ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))++ : NETWORK.filter(n => n.id !== service);++ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;++ const popular = chrome ? chrome.footer.popular : [];++ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];const year = new Date().getFullYear();if (c.variant === 'compact') {@@@ -149,7 +196,7 @@<div class="ovf-cols"><div class="ovf-col ovf-col--brand"><a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>-- <p class="ovf-tagline">${esc(c.tagline)}</p>++ <p class="ovf-tagline">${esc(tagline)}</p><div class="ovf-social">${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>@@@ -161,6 +208,11 @@${network.slice(0, 6).map(linkTag).join('')}<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a></nav>` : ''}++ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">++ <h3>Popular tools</h3>++ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <i class="fa-solid fa-arrow-right"></i></a>++ </nav>` : ''}<nav class="ovf-col" aria-label="Legal"><h3>Legal</h3>${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.++//++// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say++// what THAT site does. One template per document, with clauses switched by the site's profile:++// streaming live video, chat, payments tools files processed, then deleted++// ugc posts, pastes, comments games accounts and game state++// hosting stored user media account the identity provider++// info read-only / placeholder site++//++// const legal = require('openvibe-shared/legal');++// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));++// legal.page('privacy', site) → complete HTML document (static generators use this)++//++// Plain language on purpose. These are templates maintained by the project, not legal advice;++// the owner reviews them before relying on them. Facts stated here (what is stored, for how++// long) must match the code — change both together.++// ═══════════════════════════════════════════════════════════════++const UPDATED = '2026-09-18';++const UPDATED_TEXT = 'September 18, 2026';++const CONTACT = '[email protected]';++const PATHS = ['/terms', '/privacy', '/dmca'];++const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const has = (site, ...profiles) => profiles.includes(site.profile);++const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');++++function terms(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);++ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);++ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);++ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);++ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);++ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);++ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);++ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);++ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);++ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);++ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);++ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);++ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function privacy(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);++ const collect = [++ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',++ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',++ ];++ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);++ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');++ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');++ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');++ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');++ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');++ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');++ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);++ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);++ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);++ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);++ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);++ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);++ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);++ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function dmca(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);++ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);++ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);++ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);++ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);++ return S;++}++++const BUILDERS = { terms, privacy, dmca };++++/** { title, description, sections: [[heading, html]], updated } for one document of one site. */++function build(kind, site) {++ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);++ const s = Object.assign({ profile: 'info' }, site);++ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],++ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],++ updated: UPDATED, sections: BUILDERS[kind](s) };++}++++/** Article markup only, for sites that wrap it in their own layout. */++function article(kind, site) {++ const d = build(kind, site);++ const others = Object.keys(TITLES).filter(k => k !== kind);++ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>++${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}++<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;++}++++const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}++.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}++.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}++.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}++.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;++++/** A complete page: shared theme, navbar and footer, readable without JavaScript. */++function page(kind, site) {++ const d = build(kind, site);++ const url = `https://${site.host}/${kind}`;++ let head;++ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }++ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++${head}++<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>++<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>++<main>${article(kind, site)}</main><div id="ov-footer"></div>++<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>++<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}++try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}++try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>++</body></html>`;++}++++/** Express handler for legal.PATHS. */++function handler(site) {++ const cache = new Map();++ return function ovLegal(req, res, next) {++ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');++ if (!BUILDERS[kind]) return next();++ if (!cache.has(kind)) cache.set(kind, page(kind, site));++ res.set('Content-Type', 'text/html; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');++ res.send(cache.get(kind));++ };++}++++module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };@@@ -61,11 +61,28 @@color: var(--text-primary, #e0e0e0);}.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }-- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }-- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }-- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }-- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }-- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }++ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host++ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */++ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }++ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }++ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }++ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }++ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }++ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }++ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }++ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }++ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }@@@ -79,6 +96,9 @@.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }++ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }++ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -529,6 +549,42 @@${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;}++++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++// ── Network launcher ─────────────────────────────────────const LAUNCHER_SITES = [{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },@@@ -575,8 +631,12 @@body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';return;}-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>-- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;++ const chrome = OVChrome.get();++ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;++ const soon = chrome ? chrome.soon : [];++ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>++ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}btn.addEventListener('click', async (e) => {e.stopPropagation();@@@ -605,6 +665,19 @@});}++ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */++ function networkLinksHTML(pageLinks) {++ if (_config.networkLinks === false) return '';++ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });++ if (!chrome) return '';++ const here = currentHost().toLowerCase();++ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));++ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;++ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);++ if (!pick.length) return '';++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ }++// ── Notification bell (mounted by the navbar itself) ─────function mountBell(nav, u) {if (_config.notifications === false || !u || u.is_anon || !_config.token) return;@@@ -775,7 +848,7 @@/** Where "Sign In" goes on this host. */function resolveLoginHref(host, returnUrl) {-- if (_config.loginUrl) return _config.loginUrl;++ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));if (onToolsDomain(host)) {// Every *.openvibe.tools host signs in through the gateway apex: it// holds the OAuth state cookie + redirect_uri (both apex-host-only)@@@ -1122,6 +1195,7 @@${brandHTML(brand)}<div class="openvibe-navbar-links">${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}++ ${networkLinksHTML(links)}${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div>@@@ -13,7 +13,8 @@"./notifications": "./notifications.js","./url-resolver": "./url-resolver.js","./seo": "./seo.js",-- "./icons": "./ov-icons.js"++ "./icons": "./ov-icons.js",++ "./legal": "./legal.js"},"license": "MIT","dependencies": {
@@ -27,6 +27,25 @@ auth.startJwksRefresh();
@@ -27,6 +27,25 @@ auth.startJwksRefresh();
const app = express();
const app = express();
app.set('trust proxy', true);
app.set('trust proxy', true);
app.use(express.json({ limit: '2mb' }));
app.use(express.json({ limit: '2mb' }));
+
+// ── Visitor sign-in (OAuth client `media` on the Network; same module as Community/Tools) ──
+// Host-only cookies on openvibe.media. No new dependency: cookies are parsed here.
+app.use((req, _res, next) => {
+ req.cookies = {};
+ for (const part of String(req.headers.cookie || '').split(';')) {
+ const i = part.indexOf('='); if (i < 1) continue;
+ try { req.cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } catch { /* malformed value */ }
+ }
+ next();
+});
+{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'media', service: 'media', host: 'openvibe.media', name: 'OpenVibe.Media', profile: 'hosting' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
+const userAuth = require('./user-auth');
+const userAuthConfig = {
+ baseUrl: config.publicUrl, networkUrl: config.network.url, networkInternalUrl: config.network.internalUrl,
+ cookies: { secure: /^https:/.test(config.publicUrl) },
+ oauth: { clientId: process.env.OV_OAUTH_CLIENT_ID || 'media', clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '', redirectUri: process.env.OV_OAUTH_REDIRECT_URI || `${config.publicUrl}/auth/callback` },
+};
+app.use('/auth', userAuth.createAuthRoutes(userAuthConfig, userAuth.createAuthClient(userAuthConfig)));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
// CORS preflight for tenant API routes (per-app allow-list; auth-less OPTIONS).
@@ -241,14 +241,13 @@ function renderPage(tab, page, data, counts) {
@@ -241,14 +241,13 @@ function renderPage(tab, page, data, counts) {
<style>
<style>
:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}
:root{--bg:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--border:#1f2d47;--text:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--accent-page:var(--accent,#3b82f6)}
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
*{box-sizing:border-box;margin:0}body{background:var(--bg);color:var(--text);font:15px/1.5 system-ui,'Segoe UI',Arial,sans-serif;padding-bottom:3rem}
-header{display:flex;align-items:center;gap:.9rem;padding:1rem 1.4rem;border-bottom:1px solid var(--border);background:var(--bg-secondary,rgba(15,20,32,.92))}
-header .logo{width:30px;height:30px}
-header h1{font-size:1.15rem}header h1 b{color:var(--accent-page)}
-header .sub{color:var(--muted);font-size:.82rem;margin-left:auto}
-nav{display:flex;gap:.25rem;padding:.7rem 1.4rem;flex-wrap:wrap}
-nav a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
-nav a:hover{color:var(--text)}nav a.on{color:var(--on-accent,#fff);background:var(--accent-page)}
-nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
+header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:1rem 1.4rem .4rem}
+header.intro h1{font-size:1.15rem;margin:0}
+header.intro .sub{color:var(--muted);font-size:.85rem}
+nav.tabs{display:flex;gap:.25rem;padding:.2rem 1.4rem .7rem;flex-wrap:wrap}
+nav.tabs a{color:var(--muted);text-decoration:none;padding:.45rem .9rem;border-radius:999px;font-size:.88rem;border:1px solid transparent}
+nav.tabs a:hover{color:var(--text)}nav.tabs a.on{color:var(--on-accent,#fff);background:var(--accent-page)}
+nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;padding:0 1.4rem}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden;display:flex;flex-direction:column}
.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
.thumb{position:relative;display:block;aspect-ratio:16/9;background:#0b0f18}
@@ -269,12 +268,8 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
@@ -269,12 +268,8 @@ nav a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head><body>
</head><body>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
-<header>
- <svg class="logo" viewBox="0 0 100 100" fill="none" stroke="var(--accent-page)" stroke-width="7"><circle cx="28" cy="66" r="11"/><circle cx="72" cy="66" r="11"/><circle cx="50" cy="30" r="11"/><line x1="34" y1="58" x2="45" y2="40"/><line x1="66" y1="58" x2="55" y2="40"/><line x1="39" y1="66" x2="61" y2="66"/></svg>
- <h1>OpenVibe<b>.Media</b></h1>
- <span class="sub">every public file on the network — videos, clips, images, pastes & thumbnails</span>
-</header>
-<nav>${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
+<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>
+<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<footer id="ov-footer" class="ovf"></footer>
<footer id="ov-footer" class="ovf"></footer>
@@ -134,7 +134,7 @@ function baseCss() {
@@ -134,7 +134,7 @@ function baseCss() {
* footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }
* footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }
*/
*/
function chromeScripts({ history, footer } = {}) {
function chromeScripts({ history, footer } = {}) {
- const navOpts = { service: 'media', apiBase: NETWORK_URL };
+ const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, sessionUrl: '/auth/me' };
if (history) navOpts.history = history;
if (history) navOpts.history = history;
const footOpts = {
const footOpts = {
service: 'media',
service: 'media',
new file mode 100644
new file mode 100644
@@ -0,0 +1,422 @@
@@ -0,0 +1,422 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// openvibe.media — OAuth2 CLIENT session layer
+//
+// The identity provider is OpenVibe.Network (https://openvibe.network).
+// This site is registered there as OAuth client `media`, redirect
+// https://openvibe.media/auth/callback. Same shape as the Tools gateway.
+//
+// GET /auth/login → redirect to Network /oauth/authorize
+// ?silent=1 adds prompt=none (one quiet round trip; a
+// signed-out browser comes straight back with
+// error=login_required and lands on next + ?sso=none)
+// ?next=… same-site path or an https://openvibe.network/…
+// URL (sign-in-everywhere chains)
+// A silent login whose ov_token already verifies skips the
+// Network round trip and 302s straight to next.
+// GET /auth/callback → server-side code exchange, set cookies
+// POST /auth/fedcm → browser-native FedCM: the navbar posts the Network's
+// assertion JWT + the nonce it minted; we check the nonce,
+// swap the assertion for tokens (jwt-bearer grant), set the
+// same cookies as the callback
+// GET /auth/logout → clear cookies (+ best-effort refresh revoke), hint=guest
+// GET /auth/me → offline-verify ov_token, return profile
+// POST /auth/refresh → rotate tokens via refresh_token grant
+//
+// Cookies (all host-only — openvibe.media has no subdomains):
+// ov_token — access JWT, SameSite=Lax, Secure, JS-readable (navbar.js reads it)
+// ov_refresh — opaque refresh token, httpOnly, Path=/auth
+// ov_sso_hint — 'account' after a sign-in, 'guest' after a sign-out; 1 year,
+// JS-readable. navbar.js only attempts a silent login when it says
+// 'account', so signed-out visitors never get bounced.
+// ═══════════════════════════════════════════════════════════════
+
+const express = require('express');
+const crypto = require('crypto');
+const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
+
+const ACCESS_COOKIE = 'ov_token';
+const REFRESH_COOKIE = 'ov_refresh';
+const HINT_COOKIE = 'ov_sso_hint';
+const STATE_COOKIE = 'ov_oauth_state';
+const NEXT_COOKIE = 'ov_oauth_next';
+const SILENT_COOKIE = 'ov_oauth_silent';
+
+/**
+ * Create the auth client + JWKS fetcher shared by the whole app.
+ * Verification is OFFLINE: we cache the Network's RS256 public key from
+ * GET /api/.well-known/jwks and verify JWTs locally on every request.
+ */
+function createAuthClient(config) {
+ const client = new OpenVibeAuthClient({
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ redirectUri: config.oauth.redirectUri,
+ publicKey: null, // filled by ensureKey()
+ authBase: config.networkUrl,
+ internalBase: config.networkInternalUrl,
+ });
+
+ let lastFetch = 0;
+ async function ensureKey() {
+ if (client.publicKey) return client.publicKey;
+ // Don't hammer the Network if it's down — retry at most every 30s
+ if (Date.now() - lastFetch < 30_000) return null;
+ lastFetch = Date.now();
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/api/.well-known/jwks`, { signal: AbortSignal.timeout(5000) });
+ if (!res.ok) continue;
+ const jwks = await res.json();
+ if (jwks.public_key) {
+ client.publicKey = jwks.public_key;
+ console.log(`[Auth] Network public key loaded from ${base} (${jwks.algorithm || 'RS256'})`);
+ return client.publicKey;
+ }
+ } catch (err) {
+ console.warn(`[Auth] JWKS fetch failed from ${base}: ${err.message}`);
+ }
+ }
+ return null;
+ }
+
+ /** Offline JWT verification. Returns decoded claims or null. */
+ async function verify(token) {
+ if (!token) return null;
+ await ensureKey();
+ return client.verifyToken(token);
+ }
+
+ // Warm the key cache at boot (non-fatal if the Network is down)
+ ensureKey().catch(() => {});
+
+ return { client, ensureKey, verify };
+}
+
+/** Token from Authorization header or the ov_token cookie. */
+function extractToken(req) {
+ const h = req.headers.authorization;
+ if (h && h.startsWith('Bearer ')) return h.slice(7);
+ return req.cookies?.[ACCESS_COOKIE] || null;
+}
+
+/** Strip registered claims — what the site shows as "the user". */
+function claimsToUser(claims) {
+ if (!claims) return null;
+ const { iat, exp, aud, iss, nbf, jti, ...user } = claims;
+ return user;
+}
+
+/**
+ * Decode a JWT's payload WITHOUT checking its signature. Only for reading claims
+ * we cross-check locally (the FedCM nonce) before the Network — which does verify
+ * the signature — sees the assertion. Never use it to trust an identity.
+ */
+function decodeJwtPayload(token) {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ return claims && typeof claims === 'object' && !Array.isArray(claims) ? claims : null;
+ } catch { return null; }
+}
+
+/** True when the assertion's `nonce` claim is exactly the nonce the page posted. */
+function fedcmNonceMatches(token, nonce) {
+ if (typeof nonce !== 'string' || !nonce || nonce.length > 256) return false;
+ const claims = decodeJwtPayload(token);
+ if (!claims || typeof claims.nonce !== 'string') return false;
+ const a = Buffer.from(claims.nonce), b = Buffer.from(nonce);
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
+}
+
+/**
+ * Only allow same-site relative paths, this site's own https origin, or an
+ * https://openvibe.network/… URL (sign-in / sign-out-everywhere chains) as post-auth targets.
+ */
+function sanitizeNext(next, config) {
+ if (!next || typeof next !== 'string') return '/';
+ if (/^\/(?!\/|\\)/.test(next)) return next; // relative path, not protocol-relative
+ try {
+ const u = new URL(next);
+ if (u.protocol !== 'https:') return '/';
+ const allowed = [config.baseUrl, config.networkUrl]
+ .map((b) => { try { return new URL(b).hostname; } catch { return null; } })
+ .filter(Boolean);
+ if (allowed.includes(u.hostname)) return u.toString();
+ } catch { /* fall through */ }
+ return '/';
+}
+
+/** Append ?key=value to a same-site path or absolute URL, keeping any existing query/hash. */
+function withParam(target, key, value) {
+ const hashAt = target.indexOf('#');
+ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
+ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
+ const sep = base.includes('?') ? '&' : '?';
+ return `${base}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
+}
+
+/**
+ * Build the Network authorize URL for a login. Exported (and pure) so tests can
+ * cover the silent-login shape without a browser.
+ */
+function buildAuthorizeUrl(config, auth, { silent = false } = {}) {
+ const { url, state } = auth.client.getAuthorizationUrl(config.oauth.scope);
+ if (!silent) return { url, state };
+ // ?silent=1: continue as the account openvibe.network already knows (no chooser); if the
+ // network has no session either, it bounces back with error=login_required and we stay quiet.
+ const u = new URL(url);
+ u.searchParams.set('prompt', 'none');
+ return { url: u.toString(), state };
+}
+
+function createAuthRoutes(config, auth) {
+ const router = express.Router();
+
+ const accessCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // JS-readable by design — the shared navbar reads it client-side
+ path: '/',
+ maxAge: 24 * 60 * 60 * 1000, // matches the 24h access JWT
+ });
+
+ const refreshCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: true,
+ path: '/auth', // only ever sent back to this session layer
+ maxAge: 30 * 24 * 60 * 60 * 1000,
+ });
+
+ const flowCookieOpts = () => ({
+ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true,
+ path: '/auth', maxAge: 10 * 60 * 1000,
+ });
+
+ const hintCookieOpts = () => ({
+ sameSite: 'lax',
+ secure: config.cookies.secure,
+ httpOnly: false, // navbar.js reads it to decide whether a silent login is worth trying
+ path: '/',
+ maxAge: 365 * 24 * 60 * 60 * 1000,
+ });
+
+ function setSessionCookies(res, accessToken, refreshToken) {
+ res.cookie(ACCESS_COOKIE, accessToken, accessCookieOpts());
+ if (refreshToken) res.cookie(REFRESH_COOKIE, refreshToken, refreshCookieOpts());
+ res.cookie(HINT_COOKIE, 'account', hintCookieOpts());
+ }
+
+ function clearSessionCookies(res) {
+ res.clearCookie(ACCESS_COOKIE, { ...accessCookieOpts(), maxAge: undefined });
+ res.clearCookie(REFRESH_COOKIE, { ...refreshCookieOpts(), maxAge: undefined });
+ }
+
+ function clearFlowCookies(res) {
+ for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' });
+ }
+
+ /** Exchange at the Network — internal URL first, public as fallback. */
+ async function tokenGrant(body) {
+ let lastErr = null;
+ for (const base of [config.networkInternalUrl, config.networkUrl]) {
+ if (!base) continue;
+ try {
+ const res = await fetch(`${base}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ ...body,
+ }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
+ err.status = res.status;
+ err.error = data.error || 'invalid_grant';
+ err.error_description = data.error_description || null;
+ throw err;
+ }
+ return data;
+ } catch (err) {
+ lastErr = err;
+ // 4xx = the grant itself is bad; retrying against another base won't help
+ if (err.status && err.status < 500) throw err;
+ }
+ }
+ throw lastErr || new Error('Network unreachable');
+ }
+
+ // ── GET /auth/login ──────────────────────────────────────
+ router.get('/login', async (req, res) => {
+ const silent = !!req.query.silent && req.query.silent !== '0';
+ if (silent) {
+ // Already signed in here? Then the silent round trip through the Network would
+ // only hand back the session we have: go straight to where the caller wanted.
+ const existing = req.cookies?.[ACCESS_COOKIE];
+ if (existing && await auth.verify(existing)) {
+ clearFlowCookies(res);
+ return res.redirect(sanitizeNext(req.query.next, config));
+ }
+ }
+ const { url, state } = buildAuthorizeUrl(config, auth, { silent });
+ res.cookie(STATE_COOKIE, state, flowCookieOpts());
+ const next = sanitizeNext(req.query.next, config);
+ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowCookieOpts());
+ else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
+ if (silent) res.cookie(SILENT_COOKIE, '1', flowCookieOpts());
+ else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
+ res.redirect(url);
+ });
+
+ // ── GET /auth/callback ───────────────────────────────────
+ router.get('/callback', async (req, res) => {
+ const { code, state, error } = req.query;
+ const next = sanitizeNext(req.cookies?.[NEXT_COOKIE], config);
+ const silent = req.cookies?.[SILENT_COOKIE] === '1';
+
+ if (error) {
+ clearFlowCookies(res);
+ // Silent sign-in found no network session — go back quietly as a guest. The
+ // ?sso=none marker stops navbar.js from trying again on that page.
+ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
+ return res.redirect(withParam('/', 'auth_error', String(error)));
+ }
+ if (!code) return res.status(400).send('Missing authorization code');
+
+ const expectedState = req.cookies?.[STATE_COOKIE];
+ clearFlowCookies(res);
+ // The state cookie is required, not just compared when present: skipping the check when
+ // the cookie is missing would let a crafted link sign a visitor into someone else's account.
+ if (!expectedState || !state || !crypto.timingSafeEqual(
+ Buffer.from(String(state).padEnd(64).slice(0, 64)),
+ Buffer.from(String(expectedState).padEnd(64).slice(0, 64))
+ )) {
+ return res.status(400).send('OAuth state mismatch — please try signing in again.');
+ }
+
+ try {
+ const data = await tokenGrant({
+ grant_type: 'authorization_code',
+ redirect_uri: config.oauth.redirectUri,
+ code,
+ });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ return res.redirect(next);
+ } catch (err) {
+ console.error('[Auth] Code exchange failed:', err.message);
+ return res.status(502).send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
+ }
+ });
+
+ // ── POST /auth/fedcm ─────────────────────────────────────
+ // The shared navbar obtained a FedCM assertion from openvibe.network (the
+ // browser's own account chooser) and posts it same-origin with the nonce it
+ // put in the FedCM request. We only pre-check the nonce; the Network verifies
+ // the assertion's signature when it swaps it for tokens.
+ const fedcmBody = express.json({ limit: '16kb', type: 'application/json' });
+ const fedcmBodyError = (err, _req, res, next) => (err ? res.status(400).json({ error: 'invalid_request', error_description: 'Malformed JSON body' }) : next());
+ router.post('/fedcm', fedcmBody, fedcmBodyError, async (req, res) => {
+ if (!req.is('application/json')) return res.status(400).json({ error: 'invalid_request', error_description: 'Expected application/json' });
+ const { token, nonce } = req.body || {};
+ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
+ }
+ if (!fedcmNonceMatches(token, nonce)) {
+ return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
+ }
+ try {
+ const data = await tokenGrant({
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ assertion: token,
+ });
+ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const user = data.user || claimsToUser(await auth.verify(data.access_token));
+ return res.json({ ok: true, user });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ return res.status(401).json({ error: err.error || 'invalid_grant', error_description: err.error_description || err.message });
+ }
+ console.error('[Auth] FedCM exchange failed:', err.message);
+ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ // ── GET /auth/logout ─────────────────────────────────────
+ router.get('/logout', async (req, res) => {
+ // Best-effort refresh revocation. The Network's rotating refresh tokens
+ // self-invalidate, so a failure here is harmless.
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (refresh) {
+ try {
+ await fetch(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_id: config.oauth.clientId,
+ client_secret: config.oauth.clientSecret,
+ token: refresh,
+ }),
+ signal: AbortSignal.timeout(3000),
+ });
+ } catch { /* optional */ }
+ }
+ clearSessionCookies(res);
+ res.cookie(HINT_COOKIE, 'guest', hintCookieOpts());
+ res.redirect(sanitizeNext(req.query.next, config));
+ });
+
+ // ── GET /auth/me ─────────────────────────────────────────
+ router.get('/me', async (req, res) => {
+ const token = extractToken(req);
+ if (!token) return res.status(401).json({ error: 'Not authenticated' });
+ const claims = await auth.verify(token);
+ if (!claims) return res.status(401).json({ error: 'Invalid or expired token' });
+ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
+ });
+
+ // ── POST /auth/refresh ───────────────────────────────────
+ // Small endpoint the shared navbar can call when ov_token expires.
+ router.post('/refresh', async (req, res) => {
+ const refresh = req.cookies?.[REFRESH_COOKIE];
+ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
+ setSessionCookies(res, data.access_token, data.refresh_token);
+ const claims = await auth.verify(data.access_token);
+ return res.json({ token: data.access_token, user: claimsToUser(claims) });
+ } catch (err) {
+ if (err.status && err.status < 500) {
+ clearSessionCookies(res);
+ return res.status(401).json({ error: 'Refresh token rejected — please sign in again' });
+ }
+ console.error('[Auth] Refresh failed:', err.message);
+ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
+ }
+ });
+
+ return router;
+}
+
+/** Attaches req.user (JWT claims) and req.token when a valid ov_token is presented; never blocks. */
+function optionalAuth(auth) {
+ return async (req, _res, next) => {
+ const token = extractToken(req);
+ if (token) {
+ const claims = await auth.verify(token);
+ if (claims) { req.user = claimsToUser(claims); req.token = token; }
+ }
+ next();
+ };
+}
+
+module.exports = { createAuthClient, createAuthRoutes, extractToken, optionalAuth, sanitizeNext, withParam, buildAuthorizeUrl, claimsToUser, decodeJwtPayload, fedcmNonceMatches };
@@ -27,6 +27,41 @@
@@ -27,6 +27,41 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
'use strict';
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -50,9 +85,9 @@
@@ -50,9 +85,9 @@
];
];
const LEGAL = [
const LEGAL = [
- { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
- { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
- { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
+ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
+ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
+ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
];
const SOCIAL = [
const SOCIAL = [
@@ -106,6 +141,9 @@
@@ -106,6 +141,9 @@
}
}
function legalHref(item, cfg) {
function legalHref(item, cfg) {
+ // Each site answers for itself: the chrome service names this domain's own documents.
+ const chrome = cfg._chrome;
+ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];
// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
@@ -114,7 +152,9 @@
@@ -114,7 +152,9 @@
// ── Markup ─────────────────────────────────────────────────
// ── Markup ─────────────────────────────────────────────────
function linkTag(item) {
function linkTag(item) {
- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
+ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
+ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
+ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
@@ -124,7 +164,14 @@
@@ -124,7 +164,14 @@
const c = { ...DEFAULTS, ...(cfg || {}) };
const c = { ...DEFAULTS, ...(cfg || {}) };
const service = c.service || detectService();
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
const brand = c.brandName || brandFor(service);
- const network = NETWORK.filter(n => n.id !== service);
+ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });
+ // Most used sites first when the chrome service has spoken; the built-in list otherwise.
+ const network = chrome && chrome.nav.length
+ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
+ : NETWORK.filter(n => n.id !== service);
+ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;
+ const popular = chrome ? chrome.footer.popular : [];
+ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
const year = new Date().getFullYear();
if (c.variant === 'compact') {
if (c.variant === 'compact') {
@@ -149,7 +196,7 @@
@@ -149,7 +196,7 @@
<div class="ovf-cols">
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
<div class="ovf-col ovf-col--brand">
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
<a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
- <p class="ovf-tagline">${esc(c.tagline)}</p>
+ <p class="ovf-tagline">${esc(tagline)}</p>
<div class="ovf-social">
<div class="ovf-social">
${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
@@ -161,6 +208,11 @@
@@ -161,6 +208,11 @@
${network.slice(0, 6).map(linkTag).join('')}
${network.slice(0, 6).map(linkTag).join('')}
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
</nav>` : ''}
</nav>` : ''}
+ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
+ <h3>Popular tools</h3>
+ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
+ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <i class="fa-solid fa-arrow-right"></i></a>
+ </nav>` : ''}
<nav class="ovf-col" aria-label="Legal">
<nav class="ovf-col" aria-label="Legal">
<h3>Legal</h3>
<h3>Legal</h3>
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.
+//
+// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say
+// what THAT site does. One template per document, with clauses switched by the site's profile:
+// streaming live video, chat, payments tools files processed, then deleted
+// ugc posts, pastes, comments games accounts and game state
+// hosting stored user media account the identity provider
+// info read-only / placeholder site
+//
+// const legal = require('openvibe-shared/legal');
+// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));
+// legal.page('privacy', site) → complete HTML document (static generators use this)
+//
+// Plain language on purpose. These are templates maintained by the project, not legal advice;
+// the owner reviews them before relying on them. Facts stated here (what is stored, for how
+// long) must match the code — change both together.
+// ═══════════════════════════════════════════════════════════════
+const UPDATED = '2026-09-18';
+const UPDATED_TEXT = 'September 18, 2026';
+const CONTACT = '[email protected]';
+const PATHS = ['/terms', '/privacy', '/dmca'];
+const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const has = (site, ...profiles) => profiles.includes(site.profile);
+const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');
+
+function terms(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);
+ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);
+ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);
+ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);
+ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);
+ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);
+ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);
+ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);
+ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);
+ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);
+ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);
+ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);
+ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function privacy(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);
+ const collect = [
+ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',
+ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',
+ ];
+ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);
+ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');
+ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');
+ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');
+ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');
+ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');
+ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');
+ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);
+ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);
+ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);
+ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);
+ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);
+ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);
+ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);
+ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function dmca(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);
+ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);
+ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);
+ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);
+ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);
+ return S;
+}
+
+const BUILDERS = { terms, privacy, dmca };
+
+/** { title, description, sections: [[heading, html]], updated } for one document of one site. */
+function build(kind, site) {
+ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);
+ const s = Object.assign({ profile: 'info' }, site);
+ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],
+ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],
+ updated: UPDATED, sections: BUILDERS[kind](s) };
+}
+
+/** Article markup only, for sites that wrap it in their own layout. */
+function article(kind, site) {
+ const d = build(kind, site);
+ const others = Object.keys(TITLES).filter(k => k !== kind);
+ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>
+${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}
+<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;
+}
+
+const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}
+.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}
+.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}
+.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}
+.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;
+
+/** A complete page: shared theme, navbar and footer, readable without JavaScript. */
+function page(kind, site) {
+ const d = build(kind, site);
+ const url = `https://${site.host}/${kind}`;
+ let head;
+ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }
+ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+${head}
+<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>
+<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
+<main>${article(kind, site)}</main><div id="ov-footer"></div>
+<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
+<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
+try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
+try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>
+</body></html>`;
+}
+
+/** Express handler for legal.PATHS. */
+function handler(site) {
+ const cache = new Map();
+ return function ovLegal(req, res, next) {
+ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');
+ if (!BUILDERS[kind]) return next();
+ if (!cache.has(kind)) cache.set(kind, page(kind, site));
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
+ res.send(cache.get(kind));
+ };
+}
+
+module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };
@@ -61,11 +61,28 @@
@@ -61,11 +61,28 @@
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }
- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }
- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }
- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }
+ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
+ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
+ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
+ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
+ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
+ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
+ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
+ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
+ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
+ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
+ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
+ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
+ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@ -79,6 +96,9 @@
@@ -79,6 +96,9 @@
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
+ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
+ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -529,6 +549,42 @@
@@ -529,6 +549,42 @@
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
}
}
+
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
// ── Network launcher ─────────────────────────────────────
// ── Network launcher ─────────────────────────────────────
const LAUNCHER_SITES = [
const LAUNCHER_SITES = [
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
@@ -575,8 +631,12 @@
@@ -575,8 +631,12 @@
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
return;
return;
}
}
- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>
- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;
+ const chrome = OVChrome.get();
+ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
+ const soon = chrome ? chrome.soon : [];
+ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
+ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
btn.addEventListener('click', async (e) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
e.stopPropagation();
@@ -605,6 +665,19 @@
@@ -605,6 +665,19 @@
});
});
}
}
+ /** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
+ function networkLinksHTML(pageLinks) {
+ if (_config.networkLinks === false) return '';
+ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
+ if (!chrome) return '';
+ const here = currentHost().toLowerCase();
+ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
+ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;
+ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
+ if (!pick.length) return '';
+ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ }
+
// ── Notification bell (mounted by the navbar itself) ─────
// ── Notification bell (mounted by the navbar itself) ─────
function mountBell(nav, u) {
function mountBell(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
@@ -775,7 +848,7 @@
@@ -775,7 +848,7 @@
/** Where "Sign In" goes on this host. */
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
function resolveLoginHref(host, returnUrl) {
- if (_config.loginUrl) return _config.loginUrl;
+ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));
if (onToolsDomain(host)) {
if (onToolsDomain(host)) {
// Every *.openvibe.tools host signs in through the gateway apex: it
// Every *.openvibe.tools host signs in through the gateway apex: it
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
@@ -1122,6 +1195,7 @@
@@ -1122,6 +1195,7 @@
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
+ ${networkLinksHTML(links)}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
- "./icons": "./ov-icons.js"
+ "./icons": "./ov-icons.js",
+ "./legal": "./legal.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {