openvibers/openvibe.sites Commits
openvibe-shared is the pinned OpenVibe.Shared v1.0.0 release (package.json), not a vendored copy; deploy runs npm ci before the build; dist/ unchanged
Changes
@@@ -8,11 +8,12 @@ network, sign-in — with the shared navbar, footer and theme loader from openviSEO (canonical, Open Graph, Twitter, JSON-LD, robots, sitemap, manifest).```++npm ci # openvibe-shared: the pinned OpenVibe.Shared release (package.json)node build.js # dist/<domain>/… + deploy/nginx/<domain>.confnode build.js --check # fails when dist/ is stale```--Deploy (host): the repo lives at `/opt/openvibe.sites`; `deploy/scripts/deploy.sh` rebuilds, installs++Deploy (host): the repo lives at `/opt/openvibe.sites`; `deploy/scripts/deploy.sh` runs `npm ci`, rebuilds, installsthe vhosts into `/etc/nginx/sites-available`, enables them and reloads nginx. Each domain has aLet's Encrypt wildcard certificate (`certbot --dns-cloudflare`, see the host's renewal configs).@@@ -13,8 +13,8 @@*/const fs = require('fs');const path = require('path');--// Vendored from OpenVibe.Network/packages/openvibe-shared (copy, never edit): the network's legal documents.--const legal = require('./vendor/openvibe-shared/legal');++// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).++const legal = require('openvibe-shared/legal');// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/chrome/sites.js).const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };// Sites whose own server has no page routes: their legal pages are built here and served by nginx.@@@ -62,8 +62,8 @@ function page(site) {<meta name="robots" content="index, follow, max-image-preview:large"><meta name="theme-color" content="${esc(site.accent)}"><link rel="canonical" href="${url}">--${require('./vendor/openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}--${require('./vendor/openvibe-shared/app-icon').CRITICAL}++${require('openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}++${require('openvibe-shared/app-icon').CRITICAL}<link rel="manifest" href="/manifest.webmanifest"><link rel="alternate" type="text/html" hreflang="en" href="${url}"><meta property="og:type" content="website">@@@ -192,7 +192,7 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo<a class="btn" href="${esc(NET.github)}" rel="noopener"><i class="fa-brands fa-github"></i> OpenVibers on GitHub</a></div></main>--${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}<script src="${NET.networkUrl}/shared/ov-mark.js" async></script><script src="${NET.networkUrl}/shared/navbar.js"></script><script src="${NET.networkUrl}/shared/footer.js"></script>@@@ -209,7 +209,7 @@ ${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant:function robots(site) { return `User-agent: *\nAllow: /\nDisallow: /auth/\nSitemap: https://${site.domain}/sitemap.xml\n`; }function sitemap(site) { return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://${site.domain}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>\n`; }--function manifest(site) { return JSON.stringify(require('./vendor/openvibe-shared/app-icon').manifest({ site: 'network', name: site.name, shortName: site.name.split('.').pop(), description: site.tagline, iconBase: `${NET.networkUrl}/assets` }), null, 2) + '\n'; }++function manifest(site) { return JSON.stringify(require('openvibe-shared/app-icon').manifest({ site: 'network', name: site.name, shortName: site.name.split('.').pop(), description: site.tagline, iconBase: `${NET.networkUrl}/assets` }), null, 2) + '\n'; }/** nginx vhost: static root, wildcard cert, www → apex, long cache for the immutable bits. */function vhost(site) {@@@ -4,6 +4,8 @@set -euo pipefailREPO="${REPO:-/opt/openvibe.sites}"cd "$REPO"++# openvibe-shared is the pinned OpenVibe.Shared release (package.json); needs outbound HTTPS to codeload.github.com.++npm ci --omit=dev --no-audit --no-fundnode build.jschanged=0for conf in deploy/nginx/*.conf; donnew file mode 100644@@@ -0,0 +1,160 @@++{++ "name": "openvibe-sites",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-sites",++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/openvibe-shared": {++ "version": "1.0.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",++ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "name": "openvibe-sites",++ "private": true,++ "description": "Placeholder and hub pages for the planned OpenVibe domains (static build)",++ "scripts": { "build": "node build.js" },++ "dependencies": {++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"++ }++}ddeleted file mode 100644@@@ -1,81 +0,0 @@--'use strict';--// ═══════════════════════════════════════════════════════════════--// openvibe-shared/app-icon — one app icon design for every OpenVibe site.--//--// The icon is the OV mark as it appears in the navbar (ring, comet, white V, dot) on the network's--// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.--// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius--// safe zone, so round, squircle and teardrop masks never clip it.--// favicon({ site }) the same mark on a rounded tile, tuned for 16–32 px.--// manifest({ site, name, shortName, description, startUrl }) web app manifest with shared colours.--// SITES accent + dot colour per site (the dot is the one thing that differs,--// matching ov-mark.js variants: Live is "on air" red).--// PNG sets are rendered from these by scripts/build-app-icons.js.--// ═══════════════════════════════════════════════════════════════--const BG = '#0a0f1c', BG_EDGE = '#060912';--const SITES = {-- network: { accent: ['#60a5fa', '#2563eb'], dot: '#ffffff' },-- live: { accent: ['#60a5fa', '#2563eb'], dot: '#ef4444' },-- tools: { accent: ['#38bdf8', '#2563eb'], dot: '#ffffff' },-- community: { accent: ['#34d399', '#2563eb'], dot: '#ffffff' },-- games: { accent: ['#a78bfa', '#2563eb'], dot: '#facc15' },-- media: { accent: ['#fbbf24', '#2563eb'], dot: '#ffffff' },--};--const of = (site) => SITES[site] || SITES.network;----function mark(site, scale) {-- // Drawn in a 48-unit box centred on 0,0 (same geometry as ov-mark.js), then scaled.-- const c = of(site);-- return `<g transform="scale(${scale})">--<circle r="21" fill="url(#glow)"/>--<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>--<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>--<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>--<circle cx="17.4" cy="4.6" r="3.2" fill="${c.dot}"/></g>`;--}----function defs(site) {-- const [a, b] = of(site).accent;-- return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>--<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>--<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;--}----/** Full-bleed square. `maskable`: mark radius ≤ 40 % of the half-width; otherwise it fills more of the tile. */--function svg({ site = 'network', maskable = true, size = 512 } = {}) {-- const scale = maskable ? 3.7 : 4.6; // 21u glow radius × 3.7 ≈ 78u of the 256u half-width… well inside 40 %-- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-256 -256 512 512" width="${size}" height="${size}">${defs(site)}--<rect x="-256" y="-256" width="512" height="512" fill="url(#bg)"${maskable ? '' : ' rx="112"'}/>${mark(site, scale * (maskable ? 1.55 : 1.6))}</svg>`;--}----/** Small sizes: thicker strokes read better, and the tile is rounded because nothing masks a favicon. */--function favicon({ site = 'network' } = {}) {-- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64">${defs(site)}<rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/>${mark(site, 1.22)}</svg>`;--}----function manifest({ site = 'network', name, shortName, description, startUrl = '/', iconBase = '/assets' }) {-- const sizes = [72, 192, 512];-- return {-- name, short_name: shortName || name, description, start_url: startUrl, scope: '/', display: 'standalone',-- background_color: BG, theme_color: '#3b82f6',-- icons: [...sizes.map(s => ({ src: `${iconBase}/logo-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'any' })),-- ...[192, 512].map(s => ({ src: `${iconBase}/logo-maskable-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'maskable' })),-- { src: `${iconBase}/logo.svg`, sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],-- };--}----// First paint must never depend on another server. These two inline tags give every page the network's--// dark canvas and the visitor's cached theme immediately; theme-loader.js (deferred, from the Network)--// reconciles with the account afterwards. If the Network is slow or restarting, the page is still--// styled and readable instead of blank white.--const CRITICAL = `<style id="ov-critical">html{background:${BG};color:#e6edf7;color-scheme:dark}</style>`-- + `<script>(function(){try{var r=localStorage.getItem('ov_theme');if(!r)return;var t=JSON.parse(r),v=t&&t.variables;if(!v)return;var e=document.documentElement;for(var k in v)if(k.charAt(0)==='-')e.style.setProperty(k,v[k]);if(v['--bg-primary'])e.style.background=v['--bg-primary'];if(v['--text-primary'])e.style.color=v['--text-primary'];if(v['--color-scheme']==='light'||v['--color-scheme']==='dark')e.style.colorScheme=v['--color-scheme'];if(t.id)e.setAttribute('data-theme',t.id)}catch(_){}})();</script>`;----/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */--function headTags({ site = 'network', iconBase = null } = {}) {-- const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');-- return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', '<meta name="color-scheme" content="dark">', CRITICAL,-- iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');--}----module.exports = { svg, favicon, manifest, headTags, CRITICAL, SITES, BG };ddeleted file mode 100644@@@ -1,17 +0,0 @@--'use strict';--// openvibe-shared/chrome-ssr — the parts of the shared chrome that can be baked into HTML on the server.--// footer(cfg) the full shared footer (HTML + CSS); the browser's footer.js renders into the same element--// noscriptNav(site) a plain navigation bar inside <noscript>: the JavaScript navbar cannot exist without--// scripts, so visitors who block them (and text browsers) still get the brand and the network--const footerModule = require('./footer');----const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];--const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));----function noscriptNav({ name, home = '/', links = [] } = {}) {-- const own = links.map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');-- const net = SITES.filter(([n]) => !String(name || '').endsWith('.' + n)).map(([n, u]) => `<a href="${u}">${n}</a>`).join('');-- return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;--}----module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav };ddeleted file mode 100644@@@ -1,395 +0,0 @@--/**-- * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.-- *-- * OpenVibeFooter.init({ service, variant, links, apiBase, mount })-- * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)-- * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR-- *-- * Why this exists: every property was hand-rolling its own footer, so the network links, the-- * legal links and the styling drifted apart, and a site that gained a new sibling had to be-- * edited by hand. Here the site passes only the links that are *its own*; the Network column,-- * the Legal column, the signed-in row and all the styling come from this module, so adding a-- * property or changing a policy link updates every site at once.-- *-- * Variants:-- * full the multi-column footer, for home and browse pages-- * compact one quiet line, for pages where the footer is not the point — a streamer's-- * channel, a player, a tool someone is mid-task in-- *-- * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools-- * hub, so the network behaves like one product instead of a ring of separate sites. It reads-- * the shared ov_token the same way navbar.js does and never blocks rendering on it.-- */--(function (root, factory) {-- const api = factory();-- if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)-- if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser--})(typeof globalThis !== 'undefined' ? globalThis : this, function () {-- 'use strict';---- const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});-- // ── 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 (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out-- 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]));---- // One place that knows what the network contains. Adding a property here gives every site-- // the link, with no edit anywhere else.-- const NETWORK = [-- { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },-- { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },-- { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },-- { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },-- { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },-- { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },-- { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },-- { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },-- { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },-- { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },-- { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },-- { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },-- { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },-- { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },-- ];---- const LEGAL = [-- { 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 = [-- { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },-- { name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },-- ];---- const DEFAULTS = {-- service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)-- brandName: null,-- tagline: 'Open source and community run. Built in the open by the people using it.',-- variant: 'full',-- mount: '#ov-footer',-- links: [], // [{ heading, items: [{ name, url, onclick? }] }]-- legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site-- apiBase: NETWORK_URL,-- user: null,-- showNetwork: true,-- showAccount: true,-- sitemap: '/sitemap.xml',-- };---- let _cfg = { ...DEFAULTS };-- let _mounted = null;---- function detectService() {-- if (typeof location === 'undefined') return 'network';-- const h = location.hostname;-- if (h.endsWith('openvibe.live')) return 'live';-- if (h.endsWith('openvibe.network')) return 'network';-- if (h.endsWith('openvibe.games')) return 'games';-- const sub = h.replace(/\.openvibe\.tools$/, '');-- return sub === h ? 'tools' : (sub || 'tools');-- }---- const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };-- /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */-- function brandFor(service) {-- // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two-- // never disagree on the same page.-- try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }-- const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';-- const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';-- let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);-- if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }-- if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';-- const found = NETWORK.find(n => n.id === service);-- if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;-- if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;-- return 'OpenVibe';-- }---- 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;-- return item.fallback;-- }---- // ── Icons ──────────────────────────────────────────────────-- // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not-- // load that font (the server-rendered Tools and legal pages) every icon was an empty box.-- const ICONS = {-- github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>',-- discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>',-- sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',-- arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',-- channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',-- dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',-- account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',-- themes: '<path d="M12 4.500a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',-- tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',-- };-- const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;---- // ── Markup ─────────────────────────────────────────────────-- function linkTag(item) {-- // 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>`;-- }---- function buildHTML(cfg) {-- const c = { ...DEFAULTS, ...(cfg || {}) };-- const service = c.service || detectService();-- const brand = c.brandName || brandFor(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') {-- return `<div class="ovf-inner ovf-inner--compact">-- <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>-- <nav class="ovf-compact-links" aria-label="Site links">-- ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}-- <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>-- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}-- </nav>-- <span class="ovf-compact-meta">Open source · ${year}</span>-- </div>`;-- }---- const cols = (c.links || []).map(group => `-- <nav class="ovf-col" aria-label="${esc(group.heading)}">-- <h3>${esc(group.heading)}</h3>-- ${group.items.map(linkTag).join('')}-- </nav>`).join('');---- return `<div class="ovf-inner">-- <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(tagline)}</p>-- <div class="ovf-social">-- ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}-- <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</a>-- </div>-- </div>-- ${cols}-- ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">-- <h3>Network</h3>-- ${network.slice(0, 6).map(linkTag).join('')}-- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</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 ${icon('arrow')}</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('')}-- </nav>-- </div>-- <div class="ovf-account" id="ovf-account" hidden></div>-- <div class="ovf-bar">-- <span class="ovf-copy">Open source & community driven · built in the open</span>-- <span class="ovf-legal-inline">-- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}-- </span>-- </div>-- </div>`;-- }---- // ── Signed-in row: the same account, wherever you are ──────-- function accountHTML(user, service) {-- const name = esc(user.display_name || user.username || 'you');-- const avatar = user.avatar_url-- ? `<img src="${esc(user.avatar_url)}" alt="">`-- : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;-- const quick = [-- service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },-- { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' },-- { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },-- { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },-- ];-- return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>-- <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;-- }---- function storedToken() {-- try {-- const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);-- return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;-- } catch { return null; }-- }---- async function fillAccount(cfg) {-- const host = document.getElementById('ovf-account');-- if (!host || !cfg.showAccount || cfg.variant === 'compact') return;-- let user = cfg.user;-- if (!user) {-- const token = storedToken();-- if (!token) return;-- try {-- const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });-- if (!res.ok) return;-- const data = await res.json();-- user = data.user || data;-- } catch { return; }-- }-- if (!user || !user.username) return;-- host.innerHTML = accountHTML(user, cfg.service || detectService());-- host.hidden = false;-- }---- // ── Styles (injected once) ─────────────────────────────────-- const CSS = `--.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;-- padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}--.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}--.ovf-inner{max-width:1240px;margin:0 auto}--.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}--.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}--.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}--.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}--.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}--.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}--.ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85}--.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}--.ovf-brand--sm{font-size:.9rem}--.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}--.ovf-social{display:flex;gap:8px;margin-top:4px}--.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);-- background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}--.ovf-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)}--.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;-- margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;-- background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));-- border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08)));-- animation:ovfIn .4s ease-out both}--@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}--.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}--.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}--.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;-- background:var(--accent,#60a5fa);color:#fff;font-size:.7rem;font-weight:800;flex:none}--.ovf-avatar img{width:100%;height:100%;object-fit:cover}--.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}--.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;-- color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));-- text-decoration:none;transition:color .15s,border-color .15s,transform .15s}--.ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)}--.ovf-account-links i{font-size:.72rem;opacity:.8}--.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;-- margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));-- padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}--.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}--.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}--.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}--.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}--.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}--@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}--@media (max-width:560px){-- .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}-- .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}-- .ovf-cols{gap:22px 16px}-- .ovf-bar{justify-content:center;text-align:center;padding-inline:0}-- .ovf-legal-inline{justify-content:center}-- .ovf-account{flex-direction:column;align-items:flex-start}--}--@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;---- function injectCSS() {-- if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;-- const st = document.createElement('style');-- st.id = 'ovf-css';-- st.textContent = CSS;-- document.head.appendChild(st);-- }---- // ── Mount ──────────────────────────────────────────────────-- function render() {-- if (!_mounted) return;-- _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');-- _mounted.setAttribute('data-variant', _cfg.variant);-- _mounted.innerHTML = buildHTML(_cfg);-- try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }-- fillAccount(_cfg);-- }---- function init(cfg = {}) {-- _cfg = { ...DEFAULTS, ..._cfg, ...cfg };-- _cfg.service = _cfg.service || detectService();-- injectCSS();-- const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;-- if (!el) return null;-- _mounted = el;-- render();-- return el;-- }---- function setVariant(variant) {-- if (variant !== 'full' && variant !== 'compact') return;-- if (_cfg.variant === variant) return;-- _cfg.variant = variant;-- render();-- }---- /**-- * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for-- * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.-- * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'-- */-- function ssr(cfg = {}) {-- const c = { ...DEFAULTS, ...cfg };-- const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');-- return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;-- }---- return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };--});ddeleted file mode 100644@@@ -1,205 +0,0 @@--'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');----// Clause helpers. Every statement of fact below is backed by code in one of the repositories:--// retention windows (OpenVibe.Tools apps/*/server/config.js), cookies (server/auth/*), the anonymous--// page-view count (Network server/chrome), cross-site history (Network server/history), display and--// theme preferences (theme-loader.js), AI site copy (Live server/internal/routes.js).--const P = (...t) => t.map(x => `<p>${x}</p>`).join('');--const UL = (items) => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;----function terms(site) {-- const n = esc(site.name), host = esc(site.host);-- 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. Most of it works without an account. We can remove content or accounts that break these terms. The service is provided as it is, without warranties.`,-- `These terms cover ${host}, its subdomains, and any other domain that serves the same service. Other OpenVibe sites have their own terms, written for what those sites do.`)]);-- 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 banned from the network, or if the law where you live forbids it.`)]);-- if (has(site, 'info')) S.push(['This site is not open yet', P(`${n} is a reserved address that currently shows a preview page. There is nothing to sign up for or post here yet. When it opens, these terms will be replaced with ones written for what it does, and the date above will change.`)]);-- if (!has(site, 'info')) S.push(['Your account', P(`An OpenVibe account is managed at <a href="https://openvibe.network/">openvibe.network</a> and works on every OpenVibe site. One sign-in can quietly sign you in on the other sites you visit; you can sign out everywhere from your account page.`,-- `You are responsible for what happens under your account. Keep your password to yourself, use a working email address so you can recover access, and tell us if you think someone else has got in. One person per account; do not sell, lend or share it. We may reclaim usernames that impersonate someone or sit unused.`)]);-- if (has(site, 'account')) S.push(['What the account does', P(`The account holds your sign-in, profile, theme, display and notification preferences, linked services, cross-site history and any balances shown in your wallet. Linking a third-party service (for example Discord) lets us read the basic profile that service shares and nothing you did not approve. You can unlink a service at any time.`,-- `Themes you publish to the theme gallery are shared with other users under the licence shown when you publish; do not publish a theme that copies someone else's work.`,-- `Balances, points, payouts and purchases are governed by the rules shown at the place where you earn, buy or cash them out. Unless those rules say otherwise, balances have no cash value and cannot be transferred.`)]);-- if (has(site, 'tools')) {-- S.push(['Files, text and links you give the tools', P(`A tool processes what you upload, paste or point it at and hands 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. ${n} is not storage; keep your own copies.`,-- `Many text and developer tools run entirely in your browser and send nothing to us. Tools that need a server say so by their nature: converters, downloaders, PDF tools and network lookups.`,-- `You keep all rights in what you process and in the results. We claim no ownership of either. Logos, wordmarks and other generated designs are yours to use, but we do not check them against trademarks; clearing a name or a design is your job.`)]);-- S.push(['Rules for specific tools', UL([-- `<b>Downloaders</b> are for your own uploads and for content you are allowed to keep: public-domain and openly licensed work, or where the law gives you the right. You are responsible for respecting the terms of the site the content comes from. We do not host or index the downloaded content.`,-- `<b>Network tools</b> (DNS, WHOIS, ping, traceroute, port and SSL checks, HTTP fetchers) send real traffic to the target you enter. Only point them at systems you own or are authorised to test. Do not use them to scan, probe or load-test other people's infrastructure.`,-- `<b>Maps and food finder</b> data comes from public and community sources and can be out of date. Check opening hours, access rules and land status before you travel. It is not an emergency service; in an emergency call your local emergency number.`,-- `<b>Converters and PDF tools</b>: do not upload files you have no right to process, and do not use the unlock or protect tools to defeat protection on documents that are not yours.`,-- `<b>Automation</b>: the tool catalog is public JSON and crawlers are welcome. Automated use of the tools themselves is rate limited; heavy or commercial automation needs our agreement first.`])]);-- }-- if (has(site, 'ugc')) S.push(['Pastes, posts and comments', P(`You choose whether an item is public or unlisted. Public items appear in listings and feeds, can be indexed by search engines, and may be copied by other people while they are public. Unlisted items are reachable by anyone who has the link; they are not private. Mark adult content as such.`,-- `Never post passwords, API keys, private keys, payment details or other people's personal information. We remove leaked credentials, malware, phishing kits and doxxing when we find them, and may do so without notice to limit harm.`)]);-- if (has(site, 'hosting')) S.push(['Stored media', P(`${n} stores recorded streams, clips, thumbnails, images and files for the OpenVibe sites that use them. Whether an item is public follows the setting on the site it came from. Public media can be embedded and linked from elsewhere. Media may be kept on third-party object storage and served through a content delivery network.`,-- `Deleting an item on the site it came from removes it here; copies in caches expire on their own. We may remove media that breaks these terms, and we may expire old or unused media according to the storage limits shown on the originating site.`)]);-- if (has(site, 'streaming')) S.push(['Streams, clips and chat', P(`You are responsible for everything in your broadcast, including music, games, other people who appear in it, and what your viewers can control. Get consent before streaming people in private settings. Label streams honestly and put adult or graphic content behind the mature flag.`,-- `Streams may be recorded, clipped by viewers, transcribed and summarised by automated systems so they can be searched and highlighted. Restreaming to other platforms is done on your behalf and is subject to those platforms' rules.`)]);-- if (has(site, 'games')) S.push(['Games', P(`Games here are in active development. Worlds, characters, items and progress may be changed, rebalanced, rolled back or reset, and servers may be unavailable. Items and in-game currency have no cash value and may not be sold or traded for money.`,-- `Play fair: no cheats, bots, packet tampering, exploiting bugs for advantage, or harassing other players in game chat. What you draw on shared canvases is public, may be captured in public snapshots, and follows the same content rules as the rest of the network.`)]);-- if (userContent(site)) S.push(['Your content and the licence you give us', P(`You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'create, upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, store, reproduce, transcode, display and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear, and to show it in previews and search results. 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.`,-- `You promise that you have the rights to everything you post and that it does not break the law or anyone's rights.`)]);-- S.push(['Rules for everyone', P(`OpenVibe leans toward open expression. These limits apply on every site:`) + UL([-- 'Nothing illegal where you are or in the United States, where our servers are.',-- 'No sexual content involving minors and no sexualisation of minors, ever. We report it to the authorities.',-- 'No credible threats, incitement to violence, or publishing private information to harm someone.',-- 'No content you do not have the rights to.',-- 'No malware, phishing, spam, fraud, or impersonation of a person or organisation to deceive.',-- 'No attempts to disrupt the service, get around bans, rate limits or access controls, or reach other people\'s accounts or data.',-- 'No scraping that ignores robots.txt or our rate limits. Respectful crawlers, archives and AI agents are welcome; a machine-readable map is at /llms.txt where available.'])]);-- 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. Payment processors handle the payment under their own terms. Virtual currencies have no cash value outside the payout rules shown on the site. Creators are responsible for their own taxes, and we may withhold payouts connected to fraud or chargebacks.`)]);-- S.push(['Automated and AI-written text', P(`Some descriptive text on OpenVibe, such as footer blurbs, summaries and suggestions, is written by automated systems and checked by rules rather than by a person. It can be wrong. Do not rely on it for decisions that matter.`)]);-- S.push(['Reporting and moderation', P(`Report content with the report controls where they exist, or write to <a href="mailto:${CONTACT}">${CONTACT}</a>. Copyright complaints follow the <a href="/dmca">DMCA process</a>. We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk, and we may act without notice when the harm is urgent. If you think we got it wrong, reply to the notice or write to the same address and a person will look again.`)]);-- S.push(['Leaving', P(`You can stop using ${n} at any time${has(site, 'info') ? '' : ' and delete your account from your account settings. Deleting the account removes your access on every OpenVibe site'}. Sections that by their nature should survive (licences for content already shared, liability, governing law) continue after you leave.`)]);-- S.push(['Open source, links and feedback', P(`OpenVibe's code is public. Each repository's licence governs your use of the code; these terms govern your use of the hosted service at ${host}. Links to other sites are provided for convenience and we are not responsible for them. If you send us ideas or fixes, we may use them without owing you anything, and contributions to the code are made under the repository's licence.`)]);-- S.push(['No warranty, limited liability', P(`The service is provided "as is" and "as available", without warranties of any kind, including fitness for a particular purpose, accuracy of results, or uninterrupted availability. To the extent the law allows, OpenVibe and the people who run it are not liable for indirect, incidental or consequential losses, lost data or lost profits arising from your use of ${n}, and our total liability is limited to the greater of what you paid us in the past twelve months or fifty US dollars. Nothing here limits liability that cannot be limited by law, and consumer rights you have under the law of your country remain.`,-- `If your use of ${n} breaks these terms or the law and someone brings a claim against us because of it, you agree to cover our reasonable costs.`)]);-- 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, unless the consumer law of your country gives you the right to use your local courts. If part of these terms cannot be enforced, the rest still applies. Not enforcing a term once does not waive it. These terms are the whole agreement between us about ${n}.`)]);-- S.push(['Changes and contact', P(`We may update these terms. Material changes are announced on the site before they take effect, and the date at the top changes. Continuing to use ${n} after a change means you accept it. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.`)]);-- 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 or rent personal information, we do not share it for advertising, and we do not run advertising trackers. Most of ${n} works without an account.`)]);-- S.push(['What we collect', UL([-- '<b>Request logs.</b> Like every web server, ours record your IP address, browser type, the address requested and the time. We use them for security, rate limiting, debugging and aggregate traffic statistics.',-- '<b>Usage statistics.</b> First-party page views with a random session identifier, kept on our own servers, so we can see which parts of the network are used.',-- '<b>An anonymous page count.</b> The shared navigation bar reports one count per page load containing only the site\'s hostname and the day. No user, IP address or page is stored with it. It decides the order of sites in our menus. It is skipped when your browser sends Global Privacy Control or Do Not Track.',-- has(site, 'info') ? '' : `<b>Account information, if you sign in.</b> Your OpenVibe account is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${has(site, 'account') ? 'See the next section.' : `${n} receives your user id, username, avatar and role so it can show you as signed in. It does not receive your password or your email address.`}`,-- has(site, 'account') ? '<b>Your account.</b> Username, email address, a salted hash of your password, profile and avatar, theme, display and notification preferences, push-notification subscriptions for devices where you switched them on, linked services and the basic profile they share, sign-in sessions, wallet balances and transactions, and your cross-site history while it is switched on.' : '',-- has(site, 'tools') ? '<b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploads and results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Files can contain hidden metadata such as location or author; the tools only read what they need, but consider removing it first.' : '',-- has(site, 'tools') ? '<b>Targets of network tools.</b> A lookup necessarily sends the domain or address you enter to third parties such as DNS servers, WHOIS and RDAP registries and the target itself. Downloaders fetch the link you give from its source on your behalf. Maps load tiles and search results from third-party map providers, which see your IP address and the area you view.' : '',-- has(site, 'streaming') ? '<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. Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.' : '',-- has(site, 'streaming') ? '<b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.' : '',-- has(site, 'ugc') ? '<b>What you post.</b> Pastes, posts and comments, their visibility setting, and the account that made them. Public items are visible to everyone and may be indexed and archived by third parties. Unlisted items are visible to anyone with the link.' : '',-- has(site, 'hosting') ? '<b>Media.</b> Videos, clips, images, thumbnails and files, with technical metadata (size, duration, format), the site and account they came from, and view counts. Files may contain embedded metadata that becomes public with the file.' : '',-- has(site, 'games') ? '<b>Game data.</b> Characters, items, progress, world edits, canvas pixels and in-game chat, linked to your account or to a guest identifier stored in your browser.' : ''])]);-- S.push(['Why we use it', UL(['To provide the service you asked for and keep you signed in (performance of our agreement with you).', 'To keep the service secure, prevent abuse and understand usage in aggregate (our legitimate interests).', 'To send account email such as verification and password resets, and notifications you switched on (agreement and consent).', 'To comply with the law.'])]);-- S.push(['Cookies and storage in your browser', P(`We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token limited to the sign-in paths, a short-lived state cookie during sign-in, and a hint (<code>ov_sso_hint</code>) that remembers whether you were signed in. openvibe.network also keeps a sign-in cookie that lets other OpenVibe sites check, in a hidden frame or through your browser's built-in federated sign-in, whether to sign you in quietly. It is never used for anything except that check.`,-- `Your theme, display settings (text size, reduced motion), recent pages, a cached copy of the navigation and similar conveniences are kept in your browser's local storage. There are no third-party advertising or social cookies. Our sites sit behind Cloudflare, which may set its own security cookies and measure performance.`)]);-- 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. While cross-site history is switched on, the pages and tools you open while signed in are listed for you, and only you, at <a href="https://openvibe.network/history">openvibe.network/history</a>, where you can clear the list or pause it.`)]);-- S.push(['Who we share with', P(`Only the services needed to run the network: hosting (OVH, servers in the United States), network security and delivery (Cloudflare), object storage for media (Backblaze B2 or Cloudflare R2)${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. They process data on our instructions. We disclose information when the law requires it, and we tell the person affected when we are allowed to. If OpenVibe is ever reorganised or handed to new maintainers, the data moves under the same promises.`)]);-- S.push(['How long we keep things', UL(['Request logs and raw usage events: a limited rolling period, then totals only.', 'The anonymous page count: totals per site and day, nothing personal to delete.',-- has(site, 'tools') ? 'Tool uploads and results: one hour for visitors, 24 hours for signed-in users, temporary upload fragments about ten minutes.' : '',-- userContent(site) ? 'Content you post: until you delete it or your account, plus short-lived caches and backups.' : '',-- has(site, 'info') ? '' : 'Account data: until you delete your account. Backups expire on their own shortly afterwards. Records we must keep by law, such as payment records, are kept for the required period.'])]);-- S.push(['Where your data is', P(`Our servers are in the United States. If you use OpenVibe from elsewhere, your information is transferred to and processed in the United States, which may have different data protection rules from your country.`)]);-- S.push(['Security', P(`Traffic is encrypted in transit. Passwords are stored as salted hashes, sign-in tokens are signed and short-lived, and internal service routes are not reachable from the internet. No system is perfectly secure; if a breach affects your personal information we will tell you and the relevant authorities as the law requires.`)]);-- S.push(['Your choices and rights', P(`You can use most of ${n} without an account, block cookies, and switch on Global Privacy Control to skip the anonymous page count. With an account you can view, correct, export and delete your data from your account settings, or ask us to do it.`,-- `Depending on where you live, including the EEA, the UK and US states such as California, you may have rights to access, correct, delete, restrict or object to the use of your data, to data portability, and to complain to your data protection authority. We do not sell or share personal information as those laws define it, and we do not make automated decisions with legal effect. Write to <a href="mailto:${CONTACT}">${CONTACT}</a>; we answer within 30 days and will not treat you differently for asking.`)]);-- 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.`)]);-- S.push(['Changes and contact', P(`We will post changes here and update the date above; significant changes are announced on the site. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.`)]);-- return S;--}----function dmca(site) {-- const n = esc(site.name), host = esc(site.host);-- const S = [];-- const position = has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: uploads and results are deleted automatically within hours, and downloaders neither host nor index what they fetch. If the material you are concerned about lives on another platform, a notice to that platform removes it at the source.`-- : has(site, 'info') ? ` ${n} currently publishes only its own material and does not host uploads from the public.`-- : has(site, 'hosting') ? ` ${n} stores media for other OpenVibe sites. Removing an item here removes it from the sites that embed it.`-- : has(site, 'account') ? ` ${n} hosts account profiles, avatars and published themes; content on the other OpenVibe sites is handled by the notice process on those sites.` : '';-- S.push(['Our position', P(`${n} respects copyright and responds to clear notices of alleged infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${position}`)]);-- S.push(['Before you send a notice', P(`Consider whether the use is permitted, for example as fair use, commentary, criticism, or under a licence. Notices must come from the copyright owner or someone authorised to act for them. Misrepresentations in a notice carry legal liability.`)]);-- S.push(['Send a takedown notice', P(`Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:`) + `<ol><li>Your name, postal address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed, or a representative list if there are several.</li><li>The exact URL on ${host} of each item you want removed. General descriptions, search results and channel links 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 a copy of your notice, including your contact details. We act on complete notices as quickly as we can, normally within a few business days. Incomplete notices get a reply saying what is missing.${userContent(site) ? ' Copies held in caches and in third-party archives are outside our control and expire or must be requested separately.' : ''}`)]);-- if (!has(site, 'info', 'tools')) S.push(['Counter-notice', P(`If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your name, address, phone number and email; the material and where it appeared before removal; a statement under penalty of perjury that you believe in good faith it was removed by mistake or misidentification; your consent to the jurisdiction of the federal district court for your address (or for Washington State if you are outside the United States) and to accept service from the complainant; and your signature.`,-- `We forward the counter-notice to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.`)]);-- S.push(['Repeat infringers and false claims', P(`Accounts that receive repeated valid notices lose the ability to post and, in appropriate circumstances, are terminated across the network. Knowingly false notices and counter-notices carry liability under § 512(f); we refuse notices that are clearly abusive or automated without review.`)]);-- S.push(['Other complaints', P(`Trademark, privacy, impersonation, harassment and other non-copyright complaints go to the same address; say which kind of complaint it is and give the exact URL. Content that sexually exploits children should be reported to us immediately and to the <a href="https://report.cybertip.org/" rel="noopener">NCMEC CyberTipline</a> or your national hotline; we remove it and report it to the authorities.`)]);-- 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>--<nav class="ov-legal-toc" aria-label="On this page"><ol>${d.sections.map(([h], i) => `<li><a href="#s${i + 1}">${esc(h)}</a></li>`).join('')}</ol></nav>--${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-toc{margin:0 0 8px;padding:14px 18px;border:1px solid var(--border,rgba(255,255,255,.1));border-radius:12px;background:var(--bg-secondary,#111826)}.ov-legal-toc ol{margin:0;padding-left:20px;columns:2 220px;column-gap:28px}.ov-legal-toc li{margin:3px 0;font-size:14px;break-inside:avoid}.ov-legal-toc a{text-decoration:none}.ov-legal section{scroll-margin-top:80px}--.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}--${require('./app-icon').headTags({ site: site.id })}--<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>--${require('./chrome-ssr').noscriptNav({ name: site.name })}--<main>${article(kind, site)}</main>${require('./footer').ssr({ service: site.service || site.id || 'network', variant: 'compact' })}--<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 };ddeleted file mode 100644@@@ -1,174 +0,0 @@--'use strict';--/**-- * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).-- *-- * const seo = require('openvibe-shared/seo');-- * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })-- * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})-- *-- * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at-- * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD-- * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that-- * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so-- * language-model crawlers get a clean map of the site.-- */----const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));--const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));----/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */--function clip(text, max) {-- const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();-- if (t.length <= max) return t;-- const cut = t.slice(0, max - 1);-- const at = cut.lastIndexOf(' ');-- return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';--}----function absolute(url, base) {-- if (!url) return '';-- try { return new URL(url, base || undefined).toString(); } catch { return String(url); }--}----/** JSON-LD script tag; `<` is escaped so content can never close the script element. */--function jsonLdTag(obj) {-- return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;--}----/**-- * The whole <head> SEO block as an HTML string.-- * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),-- * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,-- * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),-- * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)-- */--function headTags(o = {}) {-- let title = String(o.title || o.siteName || 'OpenVibe');-- if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;-- title = clip(title, 60);-- const description = clip(o.description || '', 160);-- const canonical = absolute(o.canonical);-- const image = o.image ? absolute(o.image, canonical) : '';-- const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');-- const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';-- const out = [];-- if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);-- if (description) out.push(`<meta name="description" content="${esc(description)}">`);-- if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);-- out.push(`<meta name="robots" content="${esc(robots)}">`);-- if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);-- if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);-- if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);-- for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);-- if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);-- if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);-- out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);-- if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);-- out.push(`<meta property="og:title" content="${esc(title)}">`);-- if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);-- if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);-- out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);-- if (image) {-- out.push(`<meta property="og:image" content="${esc(image)}">`);-- if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);-- }-- out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);-- if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);-- out.push(`<meta name="twitter:title" content="${esc(title)}">`);-- if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);-- if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);-- const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];-- for (const item of ld) if (item) out.push(jsonLdTag(item));-- return out.join('\n');--}----const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };----const jsonLd = {-- organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },-- /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */-- website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {-- const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };-- if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };-- return o;-- },-- webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {-- return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };-- },-- /** A web tool. No price/offer fields: the platform makes no cost claims. */-- softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {-- return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };-- },-- breadcrumbs(items = []) {-- return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };-- },-- itemList(name, items = [], { url } = {}) {-- return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };-- },-- faq(pairs = []) {-- return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };-- },-- howTo({ name, description, steps = [] } = {}) {-- return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };-- },-- article({ headline, url, description, author, datePublished, dateModified, image } = {}) {-- return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };-- },-- video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {-- return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };-- },--};----/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */--function sitemapXml(urls = []) {-- const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {-- const parts = [`<loc>${xml(u.loc)}</loc>`];-- if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);-- if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);-- if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);-- for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);-- for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);-- return ` <url>${parts.join('')}</url>`;-- });-- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;--}----function sitemapIndexXml(maps = []) {-- const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);-- return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;--}----// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.--const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];----function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {-- const lines = ['User-agent: *'];-- for (const a of allow) lines.push(`Allow: ${a}`);-- for (const d of disallow) lines.push(`Disallow: ${d}`);-- if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);-- if (allowAI) {-- lines.push('');-- for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);-- lines.push('Allow: /');-- for (const d of disallow) lines.push(`Disallow: ${d}`);-- }-- for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }-- lines.push('');-- for (const s of sitemaps) lines.push(`Sitemap: ${s}`);-- return lines.join('\n') + '\n';--}----/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */--function llmsTxt({ name, summary, details, sections = [] } = {}) {-- const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];-- if (details) out.push(String(details).trim(), '');-- for (const sec of sections) {-- if (!sec || !sec.links || !sec.links.length) continue;-- out.push(`## ${sec.title}`, '');-- for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);-- out.push('');-- }-- return out.join('\n');--}----module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };
@@ -8,11 +8,12 @@ network, sign-in — with the shared navbar, footer and theme loader from openvi
@@ -8,11 +8,12 @@ network, sign-in — with the shared navbar, footer and theme loader from openvi
SEO (canonical, Open Graph, Twitter, JSON-LD, robots, sitemap, manifest).
SEO (canonical, Open Graph, Twitter, JSON-LD, robots, sitemap, manifest).
```
```
+npm ci # openvibe-shared: the pinned OpenVibe.Shared release (package.json)
node build.js # dist/<domain>/… + deploy/nginx/<domain>.conf
node build.js # dist/<domain>/… + deploy/nginx/<domain>.conf
node build.js --check # fails when dist/ is stale
node build.js --check # fails when dist/ is stale
```
```
-Deploy (host): the repo lives at `/opt/openvibe.sites`; `deploy/scripts/deploy.sh` rebuilds, installs
+Deploy (host): the repo lives at `/opt/openvibe.sites`; `deploy/scripts/deploy.sh` runs `npm ci`, rebuilds, installs
the vhosts into `/etc/nginx/sites-available`, enables them and reloads nginx. Each domain has a
the vhosts into `/etc/nginx/sites-available`, enables them and reloads nginx. Each domain has a
Let's Encrypt wildcard certificate (`certbot --dns-cloudflare`, see the host's renewal configs).
Let's Encrypt wildcard certificate (`certbot --dns-cloudflare`, see the host's renewal configs).
@@ -13,8 +13,8 @@
@@ -13,8 +13,8 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
-// Vendored from OpenVibe.Network/packages/openvibe-shared (copy, never edit): the network's legal documents.
-const legal = require('./vendor/openvibe-shared/legal');
+// The network's legal documents, from the pinned OpenVibe.Shared release (package.json).
+const legal = require('openvibe-shared/legal');
// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/chrome/sites.js).
// Which clauses apply to each domain once it opens (mirrors OpenVibe.Network/server/chrome/sites.js).
const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };
const LEGAL_PROFILE = { chat: 'ugc', codes: 'ugc', blog: 'info', wiki: 'ugc', news: 'info', reviews: 'ugc', tips: 'streaming', vip: 'account', trade: 'ugc', host: 'hosting', deals: 'info', coupons: 'info', stream: 'streaming' };
// Sites whose own server has no page routes: their legal pages are built here and served by nginx.
// Sites whose own server has no page routes: their legal pages are built here and served by nginx.
@@ -62,8 +62,8 @@ function page(site) {
@@ -62,8 +62,8 @@ function page(site) {
<meta name="robots" content="index, follow, max-image-preview:large">
<meta name="robots" content="index, follow, max-image-preview:large">
<meta name="theme-color" content="${esc(site.accent)}">
<meta name="theme-color" content="${esc(site.accent)}">
<link rel="canonical" href="${url}">
<link rel="canonical" href="${url}">
-${require('./vendor/openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}
-${require('./vendor/openvibe-shared/app-icon').CRITICAL}
+${require('openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]}
+${require('openvibe-shared/app-icon').CRITICAL}
<link rel="manifest" href="/manifest.webmanifest">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="alternate" type="text/html" hreflang="en" href="${url}">
<link rel="alternate" type="text/html" hreflang="en" href="${url}">
<meta property="og:type" content="website">
<meta property="og:type" content="website">
@@ -192,7 +192,7 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
@@ -192,7 +192,7 @@ a.card:hover{transform:translateY(-3px);border-color:rgba(var(--site-rgb),.6);bo
<a class="btn" href="${esc(NET.github)}" rel="noopener"><i class="fa-brands fa-github"></i> OpenVibers on GitHub</a>
<a class="btn" href="${esc(NET.github)}" rel="noopener"><i class="fa-brands fa-github"></i> OpenVibers on GitHub</a>
</div>
</div>
</main>
</main>
-${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}
+${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'full' })}
<script src="${NET.networkUrl}/shared/ov-mark.js" async></script>
<script src="${NET.networkUrl}/shared/ov-mark.js" async></script>
<script src="${NET.networkUrl}/shared/navbar.js"></script>
<script src="${NET.networkUrl}/shared/navbar.js"></script>
<script src="${NET.networkUrl}/shared/footer.js"></script>
<script src="${NET.networkUrl}/shared/footer.js"></script>
@@ -209,7 +209,7 @@ ${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant:
@@ -209,7 +209,7 @@ ${require('./vendor/openvibe-shared/footer').ssr({ service: 'network', variant:
function robots(site) { return `User-agent: *\nAllow: /\nDisallow: /auth/\nSitemap: https://${site.domain}/sitemap.xml\n`; }
function robots(site) { return `User-agent: *\nAllow: /\nDisallow: /auth/\nSitemap: https://${site.domain}/sitemap.xml\n`; }
function sitemap(site) { return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://${site.domain}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>\n`; }
function sitemap(site) { return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n <url><loc>https://${site.domain}/</loc><lastmod>${today}</lastmod><changefreq>weekly</changefreq><priority>1.0</priority></url>\n</urlset>\n`; }
-function manifest(site) { return JSON.stringify(require('./vendor/openvibe-shared/app-icon').manifest({ site: 'network', name: site.name, shortName: site.name.split('.').pop(), description: site.tagline, iconBase: `${NET.networkUrl}/assets` }), null, 2) + '\n'; }
+function manifest(site) { return JSON.stringify(require('openvibe-shared/app-icon').manifest({ site: 'network', name: site.name, shortName: site.name.split('.').pop(), description: site.tagline, iconBase: `${NET.networkUrl}/assets` }), null, 2) + '\n'; }
/** nginx vhost: static root, wildcard cert, www → apex, long cache for the immutable bits. */
/** nginx vhost: static root, wildcard cert, www → apex, long cache for the immutable bits. */
function vhost(site) {
function vhost(site) {
@@ -4,6 +4,8 @@
@@ -4,6 +4,8 @@
set -euo pipefail
set -euo pipefail
REPO="${REPO:-/opt/openvibe.sites}"
REPO="${REPO:-/opt/openvibe.sites}"
cd "$REPO"
cd "$REPO"
+# openvibe-shared is the pinned OpenVibe.Shared release (package.json); needs outbound HTTPS to codeload.github.com.
+npm ci --omit=dev --no-audit --no-fund
node build.js
node build.js
changed=0
changed=0
for conf in deploy/nginx/*.conf; do
for conf in deploy/nginx/*.conf; do
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+{
+ "name": "openvibe-sites",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-sites",
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/openvibe-shared": {
+ "version": "1.0.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0",
+ "integrity": "sha512-JOSH6LbIVzE9oiVWZmUnjZ+Ab8dRQ/SPk9aqBjbJGXp6kBoJA+tj7x1mp6vCusaxGHp17b2r9gmJQXk1TRL/JA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "name": "openvibe-sites",
+ "private": true,
+ "description": "Placeholder and hub pages for the planned OpenVibe domains (static build)",
+ "scripts": { "build": "node build.js" },
+ "dependencies": {
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.0.0"
+ }
+}
deleted file mode 100644
deleted file mode 100644
@@ -1,81 +0,0 @@
@@ -1,81 +0,0 @@
-'use strict';
-// ═══════════════════════════════════════════════════════════════
-// openvibe-shared/app-icon — one app icon design for every OpenVibe site.
-//
-// The icon is the OV mark as it appears in the navbar (ring, comet, white V, dot) on the network's
-// deep navy, tinted per site. No text: a home-screen icon is a few millimetres wide.
-// svg({ site, maskable }) full-bleed square SVG. maskable keeps the mark inside the 40 % radius
-// safe zone, so round, squircle and teardrop masks never clip it.
-// favicon({ site }) the same mark on a rounded tile, tuned for 16–32 px.
-// manifest({ site, name, shortName, description, startUrl }) web app manifest with shared colours.
-// SITES accent + dot colour per site (the dot is the one thing that differs,
-// matching ov-mark.js variants: Live is "on air" red).
-// PNG sets are rendered from these by scripts/build-app-icons.js.
-// ═══════════════════════════════════════════════════════════════
-const BG = '#0a0f1c', BG_EDGE = '#060912';
-const SITES = {
- network: { accent: ['#60a5fa', '#2563eb'], dot: '#ffffff' },
- live: { accent: ['#60a5fa', '#2563eb'], dot: '#ef4444' },
- tools: { accent: ['#38bdf8', '#2563eb'], dot: '#ffffff' },
- community: { accent: ['#34d399', '#2563eb'], dot: '#ffffff' },
- games: { accent: ['#a78bfa', '#2563eb'], dot: '#facc15' },
- media: { accent: ['#fbbf24', '#2563eb'], dot: '#ffffff' },
-};
-const of = (site) => SITES[site] || SITES.network;
-
-function mark(site, scale) {
- // Drawn in a 48-unit box centred on 0,0 (same geometry as ov-mark.js), then scaled.
- const c = of(site);
- return `<g transform="scale(${scale})">
-<circle r="21" fill="url(#glow)"/>
-<circle r="18" fill="none" stroke="url(#ring)" stroke-width="4" opacity=".38"/>
-<path d="M0,-18 A18,18 0 0,1 17.4,4.6" fill="none" stroke="url(#ring)" stroke-width="4.4" stroke-linecap="round"/>
-<path d="M-9.5,-7 L0,10 L9.5,-7" fill="none" stroke="#fff" stroke-width="4.8" stroke-linecap="round" stroke-linejoin="round"/>
-<circle cx="17.4" cy="4.6" r="3.2" fill="${c.dot}"/></g>`;
-}
-
-function defs(site) {
- const [a, b] = of(site).accent;
- return `<defs><linearGradient id="ring" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>
-<radialGradient id="glow"><stop offset="0" stop-color="${a}" stop-opacity=".34"/><stop offset="1" stop-color="${a}" stop-opacity="0"/></radialGradient>
-<radialGradient id="bg" cx=".5" cy=".38" r=".8"><stop offset="0" stop-color="#16223a"/><stop offset=".6" stop-color="${BG}"/><stop offset="1" stop-color="${BG_EDGE}"/></radialGradient></defs>`;
-}
-
-/** Full-bleed square. `maskable`: mark radius ≤ 40 % of the half-width; otherwise it fills more of the tile. */
-function svg({ site = 'network', maskable = true, size = 512 } = {}) {
- const scale = maskable ? 3.7 : 4.6; // 21u glow radius × 3.7 ≈ 78u of the 256u half-width… well inside 40 %
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-256 -256 512 512" width="${size}" height="${size}">${defs(site)}
-<rect x="-256" y="-256" width="512" height="512" fill="url(#bg)"${maskable ? '' : ' rx="112"'}/>${mark(site, scale * (maskable ? 1.55 : 1.6))}</svg>`;
-}
-
-/** Small sizes: thicker strokes read better, and the tile is rounded because nothing masks a favicon. */
-function favicon({ site = 'network' } = {}) {
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-32 -32 64 64">${defs(site)}<rect x="-32" y="-32" width="64" height="64" rx="15" fill="url(#bg)"/>${mark(site, 1.22)}</svg>`;
-}
-
-function manifest({ site = 'network', name, shortName, description, startUrl = '/', iconBase = '/assets' }) {
- const sizes = [72, 192, 512];
- return {
- name, short_name: shortName || name, description, start_url: startUrl, scope: '/', display: 'standalone',
- background_color: BG, theme_color: '#3b82f6',
- icons: [...sizes.map(s => ({ src: `${iconBase}/logo-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'any' })),
- ...[192, 512].map(s => ({ src: `${iconBase}/logo-maskable-${s}.png`, sizes: `${s}x${s}`, type: 'image/png', purpose: 'maskable' })),
- { src: `${iconBase}/logo.svg`, sizes: 'any', type: 'image/svg+xml', purpose: 'any' }],
- };
-}
-
-// First paint must never depend on another server. These two inline tags give every page the network's
-// dark canvas and the visitor's cached theme immediately; theme-loader.js (deferred, from the Network)
-// reconciles with the account afterwards. If the Network is slow or restarting, the page is still
-// styled and readable instead of blank white.
-const CRITICAL = `<style id="ov-critical">html{background:${BG};color:#e6edf7;color-scheme:dark}</style>`
- + `<script>(function(){try{var r=localStorage.getItem('ov_theme');if(!r)return;var t=JSON.parse(r),v=t&&t.variables;if(!v)return;var e=document.documentElement;for(var k in v)if(k.charAt(0)==='-')e.style.setProperty(k,v[k]);if(v['--bg-primary'])e.style.background=v['--bg-primary'];if(v['--text-primary'])e.style.color=v['--text-primary'];if(v['--color-scheme']==='light'||v['--color-scheme']==='dark')e.style.colorScheme=v['--color-scheme'];if(t.id)e.setAttribute('data-theme',t.id)}catch(_){}})();</script>`;
-
-/** <head> tags every site shares: SVG favicon (inline, no request), theme colour, and the PNG touch icon when the site ships one. */
-function headTags({ site = 'network', iconBase = null } = {}) {
- const uri = 'data:image/svg+xml,' + encodeURIComponent(favicon({ site })).replace(/'/g, '%27');
- return [`<link rel="icon" type="image/svg+xml" data-ov-icon="${String(site).replace(/[^a-z]/g, '')}" href="${uri}">`, '<meta name="theme-color" content="#0d131d">', '<meta name="color-scheme" content="dark">', CRITICAL,
- iconBase ? `<link rel="apple-touch-icon" href="${iconBase}/logo-192.png">` : '', iconBase ? `<link rel="manifest" href="/manifest.webmanifest">` : ''].filter(Boolean).join('\n');
-}
-
-module.exports = { svg, favicon, manifest, headTags, CRITICAL, SITES, BG };
deleted file mode 100644
deleted file mode 100644
@@ -1,17 +0,0 @@
@@ -1,17 +0,0 @@
-'use strict';
-// openvibe-shared/chrome-ssr — the parts of the shared chrome that can be baked into HTML on the server.
-// footer(cfg) the full shared footer (HTML + CSS); the browser's footer.js renders into the same element
-// noscriptNav(site) a plain navigation bar inside <noscript>: the JavaScript navbar cannot exist without
-// scripts, so visitors who block them (and text browsers) still get the brand and the network
-const footerModule = require('./footer');
-
-const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];
-const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
-
-function noscriptNav({ name, home = '/', links = [] } = {}) {
- const own = links.map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
- const net = SITES.filter(([n]) => !String(name || '').endsWith('.' + n)).map(([n, u]) => `<a href="${u}">${n}</a>`).join('');
- return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;
-}
-
-module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav };
deleted file mode 100644
deleted file mode 100644
@@ -1,395 +0,0 @@
@@ -1,395 +0,0 @@
-/**
- * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
- *
- * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
- * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
- * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
- *
- * Why this exists: every property was hand-rolling its own footer, so the network links, the
- * legal links and the styling drifted apart, and a site that gained a new sibling had to be
- * edited by hand. Here the site passes only the links that are *its own*; the Network column,
- * the Legal column, the signed-in row and all the styling come from this module, so adding a
- * property or changing a policy link updates every site at once.
- *
- * Variants:
- * full the multi-column footer, for home and browse pages
- * compact one quiet line, for pages where the footer is not the point — a streamer's
- * channel, a player, a tool someone is mid-task in
- *
- * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
- * hub, so the network behaves like one product instead of a ring of separate sites. It reads
- * the shared ov_token the same way navbar.js does and never blocks rendering on it.
- */
-(function (root, factory) {
- const api = factory();
- if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
- if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
-})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
- 'use strict';
-
- const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
- // ── 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 (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out
- 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]));
-
- // One place that knows what the network contains. Adding a property here gives every site
- // the link, with no edit anywhere else.
- const NETWORK = [
- { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
- { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
- { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
- { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
- { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
- { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
- { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
- { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
- { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
- { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
- { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
- { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
- { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
- { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
- ];
-
- const LEGAL = [
- { 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 = [
- { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' },
- { name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' },
- ];
-
- const DEFAULTS = {
- service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
- brandName: null,
- tagline: 'Open source and community run. Built in the open by the people using it.',
- variant: 'full',
- mount: '#ov-footer',
- links: [], // [{ heading, items: [{ name, url, onclick? }] }]
- legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
- apiBase: NETWORK_URL,
- user: null,
- showNetwork: true,
- showAccount: true,
- sitemap: '/sitemap.xml',
- };
-
- let _cfg = { ...DEFAULTS };
- let _mounted = null;
-
- function detectService() {
- if (typeof location === 'undefined') return 'network';
- const h = location.hostname;
- if (h.endsWith('openvibe.live')) return 'live';
- if (h.endsWith('openvibe.network')) return 'network';
- if (h.endsWith('openvibe.games')) return 'games';
- const sub = h.replace(/\.openvibe\.tools$/, '');
- return sub === h ? 'tools' : (sub || 'tools');
- }
-
- const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };
- /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
- function brandFor(service) {
- // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two
- // never disagree on the same page.
- try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }
- const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
- const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
- let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
- if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
- if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
- const found = NETWORK.find(n => n.id === service);
- if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
- if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
- return 'OpenVibe';
- }
-
- 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;
- return item.fallback;
- }
-
- // ── Icons ──────────────────────────────────────────────────
- // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not
- // load that font (the server-rendered Tools and legal pages) every icon was an empty box.
- const ICONS = {
- github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>',
- discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>',
- sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',
- arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
- channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
- dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',
- account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
- themes: '<path d="M12 4.500a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
- tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>',
- };
- const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`;
-
- // ── Markup ─────────────────────────────────────────────────
- function linkTag(item) {
- // 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>`;
- }
-
- function buildHTML(cfg) {
- const c = { ...DEFAULTS, ...(cfg || {}) };
- const service = c.service || detectService();
- const brand = c.brandName || brandFor(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') {
- return `<div class="ovf-inner ovf-inner--compact">
- <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
- <nav class="ovf-compact-links" aria-label="Site links">
- ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
- <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
- </nav>
- <span class="ovf-compact-meta">Open source · ${year}</span>
- </div>`;
- }
-
- const cols = (c.links || []).map(group => `
- <nav class="ovf-col" aria-label="${esc(group.heading)}">
- <h3>${esc(group.heading)}</h3>
- ${group.items.map(linkTag).join('')}
- </nav>`).join('');
-
- return `<div class="ovf-inner">
- <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(tagline)}</p>
- <div class="ovf-social">
- ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')}
- <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</a>
- </div>
- </div>
- ${cols}
- ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
- <h3>Network</h3>
- ${network.slice(0, 6).map(linkTag).join('')}
- <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else ${icon('arrow')}</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 ${icon('arrow')}</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('')}
- </nav>
- </div>
- <div class="ovf-account" id="ovf-account" hidden></div>
- <div class="ovf-bar">
- <span class="ovf-copy">Open source & community driven · built in the open</span>
- <span class="ovf-legal-inline">
- ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
- </span>
- </div>
- </div>`;
- }
-
- // ── Signed-in row: the same account, wherever you are ──────
- function accountHTML(user, service) {
- const name = esc(user.display_name || user.username || 'you');
- const avatar = user.avatar_url
- ? `<img src="${esc(user.avatar_url)}" alt="">`
- : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
- const quick = [
- service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' },
- { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' },
- { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' },
- { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' },
- ];
- return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
- <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`;
- }
-
- function storedToken() {
- try {
- const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
- return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
- } catch { return null; }
- }
-
- async function fillAccount(cfg) {
- const host = document.getElementById('ovf-account');
- if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
- let user = cfg.user;
- if (!user) {
- const token = storedToken();
- if (!token) return;
- try {
- const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
- if (!res.ok) return;
- const data = await res.json();
- user = data.user || data;
- } catch { return; }
- }
- if (!user || !user.username) return;
- host.innerHTML = accountHTML(user, cfg.service || detectService());
- host.hidden = false;
- }
-
- // ── Styles (injected once) ─────────────────────────────────
- const CSS = `
-.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;
- padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
-.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
-.ovf-inner{max-width:1240px;margin:0 auto}
-.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
-.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
-.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
-.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
-.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
-.ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)}
-.ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85}
-.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
-.ovf-brand--sm{font-size:.9rem}
-.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
-.ovf-social{display:flex;gap:8px;margin-top:4px}
-.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
- background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}
-.ovf-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)}
-.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
- margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
- background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28));
- border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08)));
- animation:ovfIn .4s ease-out both}
-@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
-.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
-.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
-.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
- background:var(--accent,#60a5fa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
-.ovf-avatar img{width:100%;height:100%;object-fit:cover}
-.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
-.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
- color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
- text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
-.ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)}
-.ovf-account-links i{font-size:.72rem;opacity:.8}
-.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
- margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
- padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
-.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
-.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
-.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
-.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
-.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
-@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
-@media (max-width:560px){
- .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}
- .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
- .ovf-cols{gap:22px 16px}
- .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
- .ovf-legal-inline{justify-content:center}
- .ovf-account{flex-direction:column;align-items:flex-start}
-}
-@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;
-
- function injectCSS() {
- if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
- const st = document.createElement('style');
- st.id = 'ovf-css';
- st.textContent = CSS;
- document.head.appendChild(st);
- }
-
- // ── Mount ──────────────────────────────────────────────────
- function render() {
- if (!_mounted) return;
- _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
- _mounted.setAttribute('data-variant', _cfg.variant);
- _mounted.innerHTML = buildHTML(_cfg);
- try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
- fillAccount(_cfg);
- }
-
- function init(cfg = {}) {
- _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
- _cfg.service = _cfg.service || detectService();
- injectCSS();
- const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
- if (!el) return null;
- _mounted = el;
- render();
- return el;
- }
-
- function setVariant(variant) {
- if (variant !== 'full' && variant !== 'compact') return;
- if (_cfg.variant === variant) return;
- _cfg.variant = variant;
- render();
- }
-
- /**
- * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for
- * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.
- * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'
- */
- function ssr(cfg = {}) {
- const c = { ...DEFAULTS, ...cfg };
- const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');
- return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
- }
-
- return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
-});
deleted file mode 100644
deleted file mode 100644
@@ -1,205 +0,0 @@
@@ -1,205 +0,0 @@
-'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');
-
-// Clause helpers. Every statement of fact below is backed by code in one of the repositories:
-// retention windows (OpenVibe.Tools apps/*/server/config.js), cookies (server/auth/*), the anonymous
-// page-view count (Network server/chrome), cross-site history (Network server/history), display and
-// theme preferences (theme-loader.js), AI site copy (Live server/internal/routes.js).
-const P = (...t) => t.map(x => `<p>${x}</p>`).join('');
-const UL = (items) => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
-
-function terms(site) {
- const n = esc(site.name), host = esc(site.host);
- 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. Most of it works without an account. We can remove content or accounts that break these terms. The service is provided as it is, without warranties.`,
- `These terms cover ${host}, its subdomains, and any other domain that serves the same service. Other OpenVibe sites have their own terms, written for what those sites do.`)]);
- 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 banned from the network, or if the law where you live forbids it.`)]);
- if (has(site, 'info')) S.push(['This site is not open yet', P(`${n} is a reserved address that currently shows a preview page. There is nothing to sign up for or post here yet. When it opens, these terms will be replaced with ones written for what it does, and the date above will change.`)]);
- if (!has(site, 'info')) S.push(['Your account', P(`An OpenVibe account is managed at <a href="https://openvibe.network/">openvibe.network</a> and works on every OpenVibe site. One sign-in can quietly sign you in on the other sites you visit; you can sign out everywhere from your account page.`,
- `You are responsible for what happens under your account. Keep your password to yourself, use a working email address so you can recover access, and tell us if you think someone else has got in. One person per account; do not sell, lend or share it. We may reclaim usernames that impersonate someone or sit unused.`)]);
- if (has(site, 'account')) S.push(['What the account does', P(`The account holds your sign-in, profile, theme, display and notification preferences, linked services, cross-site history and any balances shown in your wallet. Linking a third-party service (for example Discord) lets us read the basic profile that service shares and nothing you did not approve. You can unlink a service at any time.`,
- `Themes you publish to the theme gallery are shared with other users under the licence shown when you publish; do not publish a theme that copies someone else's work.`,
- `Balances, points, payouts and purchases are governed by the rules shown at the place where you earn, buy or cash them out. Unless those rules say otherwise, balances have no cash value and cannot be transferred.`)]);
- if (has(site, 'tools')) {
- S.push(['Files, text and links you give the tools', P(`A tool processes what you upload, paste or point it at and hands 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. ${n} is not storage; keep your own copies.`,
- `Many text and developer tools run entirely in your browser and send nothing to us. Tools that need a server say so by their nature: converters, downloaders, PDF tools and network lookups.`,
- `You keep all rights in what you process and in the results. We claim no ownership of either. Logos, wordmarks and other generated designs are yours to use, but we do not check them against trademarks; clearing a name or a design is your job.`)]);
- S.push(['Rules for specific tools', UL([
- `<b>Downloaders</b> are for your own uploads and for content you are allowed to keep: public-domain and openly licensed work, or where the law gives you the right. You are responsible for respecting the terms of the site the content comes from. We do not host or index the downloaded content.`,
- `<b>Network tools</b> (DNS, WHOIS, ping, traceroute, port and SSL checks, HTTP fetchers) send real traffic to the target you enter. Only point them at systems you own or are authorised to test. Do not use them to scan, probe or load-test other people's infrastructure.`,
- `<b>Maps and food finder</b> data comes from public and community sources and can be out of date. Check opening hours, access rules and land status before you travel. It is not an emergency service; in an emergency call your local emergency number.`,
- `<b>Converters and PDF tools</b>: do not upload files you have no right to process, and do not use the unlock or protect tools to defeat protection on documents that are not yours.`,
- `<b>Automation</b>: the tool catalog is public JSON and crawlers are welcome. Automated use of the tools themselves is rate limited; heavy or commercial automation needs our agreement first.`])]);
- }
- if (has(site, 'ugc')) S.push(['Pastes, posts and comments', P(`You choose whether an item is public or unlisted. Public items appear in listings and feeds, can be indexed by search engines, and may be copied by other people while they are public. Unlisted items are reachable by anyone who has the link; they are not private. Mark adult content as such.`,
- `Never post passwords, API keys, private keys, payment details or other people's personal information. We remove leaked credentials, malware, phishing kits and doxxing when we find them, and may do so without notice to limit harm.`)]);
- if (has(site, 'hosting')) S.push(['Stored media', P(`${n} stores recorded streams, clips, thumbnails, images and files for the OpenVibe sites that use them. Whether an item is public follows the setting on the site it came from. Public media can be embedded and linked from elsewhere. Media may be kept on third-party object storage and served through a content delivery network.`,
- `Deleting an item on the site it came from removes it here; copies in caches expire on their own. We may remove media that breaks these terms, and we may expire old or unused media according to the storage limits shown on the originating site.`)]);
- if (has(site, 'streaming')) S.push(['Streams, clips and chat', P(`You are responsible for everything in your broadcast, including music, games, other people who appear in it, and what your viewers can control. Get consent before streaming people in private settings. Label streams honestly and put adult or graphic content behind the mature flag.`,
- `Streams may be recorded, clipped by viewers, transcribed and summarised by automated systems so they can be searched and highlighted. Restreaming to other platforms is done on your behalf and is subject to those platforms' rules.`)]);
- if (has(site, 'games')) S.push(['Games', P(`Games here are in active development. Worlds, characters, items and progress may be changed, rebalanced, rolled back or reset, and servers may be unavailable. Items and in-game currency have no cash value and may not be sold or traded for money.`,
- `Play fair: no cheats, bots, packet tampering, exploiting bugs for advantage, or harassing other players in game chat. What you draw on shared canvases is public, may be captured in public snapshots, and follows the same content rules as the rest of the network.`)]);
- if (userContent(site)) S.push(['Your content and the licence you give us', P(`You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'create, upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, store, reproduce, transcode, display and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear, and to show it in previews and search results. 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.`,
- `You promise that you have the rights to everything you post and that it does not break the law or anyone's rights.`)]);
- S.push(['Rules for everyone', P(`OpenVibe leans toward open expression. These limits apply on every site:`) + UL([
- 'Nothing illegal where you are or in the United States, where our servers are.',
- 'No sexual content involving minors and no sexualisation of minors, ever. We report it to the authorities.',
- 'No credible threats, incitement to violence, or publishing private information to harm someone.',
- 'No content you do not have the rights to.',
- 'No malware, phishing, spam, fraud, or impersonation of a person or organisation to deceive.',
- 'No attempts to disrupt the service, get around bans, rate limits or access controls, or reach other people\'s accounts or data.',
- 'No scraping that ignores robots.txt or our rate limits. Respectful crawlers, archives and AI agents are welcome; a machine-readable map is at /llms.txt where available.'])]);
- 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. Payment processors handle the payment under their own terms. Virtual currencies have no cash value outside the payout rules shown on the site. Creators are responsible for their own taxes, and we may withhold payouts connected to fraud or chargebacks.`)]);
- S.push(['Automated and AI-written text', P(`Some descriptive text on OpenVibe, such as footer blurbs, summaries and suggestions, is written by automated systems and checked by rules rather than by a person. It can be wrong. Do not rely on it for decisions that matter.`)]);
- S.push(['Reporting and moderation', P(`Report content with the report controls where they exist, or write to <a href="mailto:${CONTACT}">${CONTACT}</a>. Copyright complaints follow the <a href="/dmca">DMCA process</a>. We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk, and we may act without notice when the harm is urgent. If you think we got it wrong, reply to the notice or write to the same address and a person will look again.`)]);
- S.push(['Leaving', P(`You can stop using ${n} at any time${has(site, 'info') ? '' : ' and delete your account from your account settings. Deleting the account removes your access on every OpenVibe site'}. Sections that by their nature should survive (licences for content already shared, liability, governing law) continue after you leave.`)]);
- S.push(['Open source, links and feedback', P(`OpenVibe's code is public. Each repository's licence governs your use of the code; these terms govern your use of the hosted service at ${host}. Links to other sites are provided for convenience and we are not responsible for them. If you send us ideas or fixes, we may use them without owing you anything, and contributions to the code are made under the repository's licence.`)]);
- S.push(['No warranty, limited liability', P(`The service is provided "as is" and "as available", without warranties of any kind, including fitness for a particular purpose, accuracy of results, or uninterrupted availability. To the extent the law allows, OpenVibe and the people who run it are not liable for indirect, incidental or consequential losses, lost data or lost profits arising from your use of ${n}, and our total liability is limited to the greater of what you paid us in the past twelve months or fifty US dollars. Nothing here limits liability that cannot be limited by law, and consumer rights you have under the law of your country remain.`,
- `If your use of ${n} breaks these terms or the law and someone brings a claim against us because of it, you agree to cover our reasonable costs.`)]);
- 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, unless the consumer law of your country gives you the right to use your local courts. If part of these terms cannot be enforced, the rest still applies. Not enforcing a term once does not waive it. These terms are the whole agreement between us about ${n}.`)]);
- S.push(['Changes and contact', P(`We may update these terms. Material changes are announced on the site before they take effect, and the date at the top changes. Continuing to use ${n} after a change means you accept it. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.`)]);
- 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 or rent personal information, we do not share it for advertising, and we do not run advertising trackers. Most of ${n} works without an account.`)]);
- S.push(['What we collect', UL([
- '<b>Request logs.</b> Like every web server, ours record your IP address, browser type, the address requested and the time. We use them for security, rate limiting, debugging and aggregate traffic statistics.',
- '<b>Usage statistics.</b> First-party page views with a random session identifier, kept on our own servers, so we can see which parts of the network are used.',
- '<b>An anonymous page count.</b> The shared navigation bar reports one count per page load containing only the site\'s hostname and the day. No user, IP address or page is stored with it. It decides the order of sites in our menus. It is skipped when your browser sends Global Privacy Control or Do Not Track.',
- has(site, 'info') ? '' : `<b>Account information, if you sign in.</b> Your OpenVibe account is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${has(site, 'account') ? 'See the next section.' : `${n} receives your user id, username, avatar and role so it can show you as signed in. It does not receive your password or your email address.`}`,
- has(site, 'account') ? '<b>Your account.</b> Username, email address, a salted hash of your password, profile and avatar, theme, display and notification preferences, push-notification subscriptions for devices where you switched them on, linked services and the basic profile they share, sign-in sessions, wallet balances and transactions, and your cross-site history while it is switched on.' : '',
- has(site, 'tools') ? '<b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploads and results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Files can contain hidden metadata such as location or author; the tools only read what they need, but consider removing it first.' : '',
- has(site, 'tools') ? '<b>Targets of network tools.</b> A lookup necessarily sends the domain or address you enter to third parties such as DNS servers, WHOIS and RDAP registries and the target itself. Downloaders fetch the link you give from its source on your behalf. Maps load tiles and search results from third-party map providers, which see your IP address and the area you view.' : '',
- has(site, 'streaming') ? '<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. Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.' : '',
- has(site, 'streaming') ? '<b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.' : '',
- has(site, 'ugc') ? '<b>What you post.</b> Pastes, posts and comments, their visibility setting, and the account that made them. Public items are visible to everyone and may be indexed and archived by third parties. Unlisted items are visible to anyone with the link.' : '',
- has(site, 'hosting') ? '<b>Media.</b> Videos, clips, images, thumbnails and files, with technical metadata (size, duration, format), the site and account they came from, and view counts. Files may contain embedded metadata that becomes public with the file.' : '',
- has(site, 'games') ? '<b>Game data.</b> Characters, items, progress, world edits, canvas pixels and in-game chat, linked to your account or to a guest identifier stored in your browser.' : ''])]);
- S.push(['Why we use it', UL(['To provide the service you asked for and keep you signed in (performance of our agreement with you).', 'To keep the service secure, prevent abuse and understand usage in aggregate (our legitimate interests).', 'To send account email such as verification and password resets, and notifications you switched on (agreement and consent).', 'To comply with the law.'])]);
- S.push(['Cookies and storage in your browser', P(`We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token limited to the sign-in paths, a short-lived state cookie during sign-in, and a hint (<code>ov_sso_hint</code>) that remembers whether you were signed in. openvibe.network also keeps a sign-in cookie that lets other OpenVibe sites check, in a hidden frame or through your browser's built-in federated sign-in, whether to sign you in quietly. It is never used for anything except that check.`,
- `Your theme, display settings (text size, reduced motion), recent pages, a cached copy of the navigation and similar conveniences are kept in your browser's local storage. There are no third-party advertising or social cookies. Our sites sit behind Cloudflare, which may set its own security cookies and measure performance.`)]);
- 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. While cross-site history is switched on, the pages and tools you open while signed in are listed for you, and only you, at <a href="https://openvibe.network/history">openvibe.network/history</a>, where you can clear the list or pause it.`)]);
- S.push(['Who we share with', P(`Only the services needed to run the network: hosting (OVH, servers in the United States), network security and delivery (Cloudflare), object storage for media (Backblaze B2 or Cloudflare R2)${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. They process data on our instructions. We disclose information when the law requires it, and we tell the person affected when we are allowed to. If OpenVibe is ever reorganised or handed to new maintainers, the data moves under the same promises.`)]);
- S.push(['How long we keep things', UL(['Request logs and raw usage events: a limited rolling period, then totals only.', 'The anonymous page count: totals per site and day, nothing personal to delete.',
- has(site, 'tools') ? 'Tool uploads and results: one hour for visitors, 24 hours for signed-in users, temporary upload fragments about ten minutes.' : '',
- userContent(site) ? 'Content you post: until you delete it or your account, plus short-lived caches and backups.' : '',
- has(site, 'info') ? '' : 'Account data: until you delete your account. Backups expire on their own shortly afterwards. Records we must keep by law, such as payment records, are kept for the required period.'])]);
- S.push(['Where your data is', P(`Our servers are in the United States. If you use OpenVibe from elsewhere, your information is transferred to and processed in the United States, which may have different data protection rules from your country.`)]);
- S.push(['Security', P(`Traffic is encrypted in transit. Passwords are stored as salted hashes, sign-in tokens are signed and short-lived, and internal service routes are not reachable from the internet. No system is perfectly secure; if a breach affects your personal information we will tell you and the relevant authorities as the law requires.`)]);
- S.push(['Your choices and rights', P(`You can use most of ${n} without an account, block cookies, and switch on Global Privacy Control to skip the anonymous page count. With an account you can view, correct, export and delete your data from your account settings, or ask us to do it.`,
- `Depending on where you live, including the EEA, the UK and US states such as California, you may have rights to access, correct, delete, restrict or object to the use of your data, to data portability, and to complain to your data protection authority. We do not sell or share personal information as those laws define it, and we do not make automated decisions with legal effect. Write to <a href="mailto:${CONTACT}">${CONTACT}</a>; we answer within 30 days and will not treat you differently for asking.`)]);
- 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.`)]);
- S.push(['Changes and contact', P(`We will post changes here and update the date above; significant changes are announced on the site. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.`)]);
- return S;
-}
-
-function dmca(site) {
- const n = esc(site.name), host = esc(site.host);
- const S = [];
- const position = has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: uploads and results are deleted automatically within hours, and downloaders neither host nor index what they fetch. If the material you are concerned about lives on another platform, a notice to that platform removes it at the source.`
- : has(site, 'info') ? ` ${n} currently publishes only its own material and does not host uploads from the public.`
- : has(site, 'hosting') ? ` ${n} stores media for other OpenVibe sites. Removing an item here removes it from the sites that embed it.`
- : has(site, 'account') ? ` ${n} hosts account profiles, avatars and published themes; content on the other OpenVibe sites is handled by the notice process on those sites.` : '';
- S.push(['Our position', P(`${n} respects copyright and responds to clear notices of alleged infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${position}`)]);
- S.push(['Before you send a notice', P(`Consider whether the use is permitted, for example as fair use, commentary, criticism, or under a licence. Notices must come from the copyright owner or someone authorised to act for them. Misrepresentations in a notice carry legal liability.`)]);
- S.push(['Send a takedown notice', P(`Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:`) + `<ol><li>Your name, postal address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed, or a representative list if there are several.</li><li>The exact URL on ${host} of each item you want removed. General descriptions, search results and channel links 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 a copy of your notice, including your contact details. We act on complete notices as quickly as we can, normally within a few business days. Incomplete notices get a reply saying what is missing.${userContent(site) ? ' Copies held in caches and in third-party archives are outside our control and expire or must be requested separately.' : ''}`)]);
- if (!has(site, 'info', 'tools')) S.push(['Counter-notice', P(`If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your name, address, phone number and email; the material and where it appeared before removal; a statement under penalty of perjury that you believe in good faith it was removed by mistake or misidentification; your consent to the jurisdiction of the federal district court for your address (or for Washington State if you are outside the United States) and to accept service from the complainant; and your signature.`,
- `We forward the counter-notice to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.`)]);
- S.push(['Repeat infringers and false claims', P(`Accounts that receive repeated valid notices lose the ability to post and, in appropriate circumstances, are terminated across the network. Knowingly false notices and counter-notices carry liability under § 512(f); we refuse notices that are clearly abusive or automated without review.`)]);
- S.push(['Other complaints', P(`Trademark, privacy, impersonation, harassment and other non-copyright complaints go to the same address; say which kind of complaint it is and give the exact URL. Content that sexually exploits children should be reported to us immediately and to the <a href="https://report.cybertip.org/" rel="noopener">NCMEC CyberTipline</a> or your national hotline; we remove it and report it to the authorities.`)]);
- 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>
-<nav class="ov-legal-toc" aria-label="On this page"><ol>${d.sections.map(([h], i) => `<li><a href="#s${i + 1}">${esc(h)}</a></li>`).join('')}</ol></nav>
-${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-toc{margin:0 0 8px;padding:14px 18px;border:1px solid var(--border,rgba(255,255,255,.1));border-radius:12px;background:var(--bg-secondary,#111826)}.ov-legal-toc ol{margin:0;padding-left:20px;columns:2 220px;column-gap:28px}.ov-legal-toc li{margin:3px 0;font-size:14px;break-inside:avoid}.ov-legal-toc a{text-decoration:none}.ov-legal section{scroll-margin-top:80px}
-.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}
-${require('./app-icon').headTags({ site: site.id })}
-<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>
-${require('./chrome-ssr').noscriptNav({ name: site.name })}
-<main>${article(kind, site)}</main>${require('./footer').ssr({ service: site.service || site.id || 'network', variant: 'compact' })}
-<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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,174 +0,0 @@
@@ -1,174 +0,0 @@
-'use strict';
-/**
- * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).
- *
- * const seo = require('openvibe-shared/seo');
- * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
- * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
- *
- * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
- * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
- * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that
- * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so
- * language-model crawlers get a clean map of the site.
- */
-
-const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
-const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
-
-/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */
-function clip(text, max) {
- const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
- if (t.length <= max) return t;
- const cut = t.slice(0, max - 1);
- const at = cut.lastIndexOf(' ');
- return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';
-}
-
-function absolute(url, base) {
- if (!url) return '';
- try { return new URL(url, base || undefined).toString(); } catch { return String(url); }
-}
-
-/** JSON-LD script tag; `<` is escaped so content can never close the script element. */
-function jsonLdTag(obj) {
- return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
-}
-
-/**
- * The whole <head> SEO block as an HTML string.
- * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),
- * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,
- * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),
- * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)
- */
-function headTags(o = {}) {
- let title = String(o.title || o.siteName || 'OpenVibe');
- if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;
- title = clip(title, 60);
- const description = clip(o.description || '', 160);
- const canonical = absolute(o.canonical);
- const image = o.image ? absolute(o.image, canonical) : '';
- const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');
- const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';
- const out = [];
- if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);
- if (description) out.push(`<meta name="description" content="${esc(description)}">`);
- if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);
- out.push(`<meta name="robots" content="${esc(robots)}">`);
- if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
- if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
- if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
- for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
- if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
- if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
- out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);
- if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);
- out.push(`<meta property="og:title" content="${esc(title)}">`);
- if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);
- if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);
- out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);
- if (image) {
- out.push(`<meta property="og:image" content="${esc(image)}">`);
- if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);
- }
- out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);
- if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);
- out.push(`<meta name="twitter:title" content="${esc(title)}">`);
- if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
- if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
- const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];
- for (const item of ld) if (item) out.push(jsonLdTag(item));
- return out.join('\n');
-}
-
-const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };
-
-const jsonLd = {
- organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
- /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */
- website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {
- const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };
- if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };
- return o;
- },
- webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {
- return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };
- },
- /** A web tool. No price/offer fields: the platform makes no cost claims. */
- softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {
- return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
- },
- breadcrumbs(items = []) {
- return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };
- },
- itemList(name, items = [], { url } = {}) {
- return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };
- },
- faq(pairs = []) {
- return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };
- },
- howTo({ name, description, steps = [] } = {}) {
- return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };
- },
- article({ headline, url, description, author, datePublished, dateModified, image } = {}) {
- return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
- },
- video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
- return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
- },
-};
-
-/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
-function sitemapXml(urls = []) {
- const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
- const parts = [`<loc>${xml(u.loc)}</loc>`];
- if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
- if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);
- if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
- for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);
- for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);
- return ` <url>${parts.join('')}</url>`;
- });
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;
-}
-
-function sitemapIndexXml(maps = []) {
- const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);
- return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
-}
-
-// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
-const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
-
-function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
- const lines = ['User-agent: *'];
- for (const a of allow) lines.push(`Allow: ${a}`);
- for (const d of disallow) lines.push(`Disallow: ${d}`);
- if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
- if (allowAI) {
- lines.push('');
- for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);
- lines.push('Allow: /');
- for (const d of disallow) lines.push(`Disallow: ${d}`);
- }
- for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }
- lines.push('');
- for (const s of sitemaps) lines.push(`Sitemap: ${s}`);
- return lines.join('\n') + '\n';
-}
-
-/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
-function llmsTxt({ name, summary, details, sections = [] } = {}) {
- const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
- if (details) out.push(String(details).trim(), '');
- for (const sec of sections) {
- if (!sec || !sec.links || !sec.links.length) continue;
- out.push(`## ${sec.title}`, '');
- for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
- out.push('');
- }
- return out.join('\n');
-}
-
-module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };