#!/usr/bin/env node 'use strict'; /** OpenVibe.Sites: the shared browser files and the two frozen pages (Tips, VIP) until plan T5 launches them. */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const legal = require('openvibe-shared/legal'); const sharedFiles = require('openvibe-shared/files'); const notices = require('./notices.json'); const frozen = require('./frozen.json'); const ROOT = __dirname; const DIST = path.join(ROOT, 'dist'); const NET = { networkUrl: 'https://openvibe.network' }; const today = new Date().toISOString().slice(0, 10); const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const legalSite = (site) => ({ id: site.tld, service: 'network', host: site.domain, name: site.name, profile: site.legalProfile || 'info' }); // Presentation is owned by the product teams. These notice snapshots preserve the existing pages. function page(site) { return fs.readFileSync(path.join(ROOT, 'notice-pages', `${site.domain}.html`), 'utf8'); } function robots() { return 'User-agent: *\nAllow: /\nDisallow: /auth/\n'; } function sitemap(site) { return `\n\n https://${site.domain}/${today}weekly1.0\n\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'; } /** 404.html: what nginx answers (with status 404) for any path that is not a file in dist//. */ function notFound(site) { const home = `https://${site.domain}/`; return ` Not found · ${esc(site.name)} ${require('openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]} ${require('openvibe-shared/app-icon').CRITICAL}

404

This page does not exist

${esc(site.domain)} has only its front page and legal pages for now. The address you followed is not one of them.

`; } /** nginx vhost: static root, wildcard cert, www → apex, long cache for the immutable bits, real 404s. */ const SECURITY_HEADERS = [ 'add_header X-Content-Type-Options nosniff always;', 'add_header X-Frame-Options SAMEORIGIN always;', 'add_header Referrer-Policy strict-origin-when-cross-origin always;', 'add_header Strict-Transport-Security "max-age=31536000" always;', ]; function vhost(site) { const d = site.domain; const sec = SECURITY_HEADERS.join('\n '); // Subdomains of a zone (e.g. events.openvibe.network) use the zone's wildcard cert and have no www. const cert = site.zone || d; const www = site.zone ? '' : ` server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name www.${d}; ssl_certificate /etc/letsencrypt/live/${cert}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${cert}/privkey.pem; return 301 https://${d}$request_uri; } `; return `# ${d} — static front page (OpenVibe.Sites). Generated by build.js; edit notices.json, not this. server { listen 80; listen [::]:80; server_name ${d}${site.zone ? '' : ` www.${d}`}; return 301 https://${d}$request_uri; } ${www} server { listen 443 ssl; listen [::]:443 ssl; http2 on; server_name ${d}; include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo) ssl_certificate /etc/letsencrypt/live/${cert}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${cert}/privkey.pem; root /opt/openvibe.sites/dist/${d}; index index.html; access_log /var/log/nginx/${d}.access.log; error_log /var/log/nginx/${d}.error.log; ${SECURITY_HEADERS.join('\n ')} gzip on; gzip_types text/html text/plain text/css application/json application/javascript application/xml image/svg+xml application/manifest+json; # An add_header inside a location replaces the server's list (nginx does not merge them), so every # location that sets its own header repeats the security headers. location = /robots.txt { add_header Cache-Control "public, max-age=3600"; ${sec} } location = /sitemap.xml { types { application/xml xml; } add_header Cache-Control "public, max-age=3600"; ${sec} } location = /manifest.webmanifest { types { application/manifest+json webmanifest; } add_header Cache-Control "public, max-age=86400"; ${sec} } location = /status.json { add_header Cache-Control "public, max-age=600"; add_header Access-Control-Allow-Origin "*"; ${sec} } location = /release.json { add_header Cache-Control "no-cache"; add_header Access-Control-Allow-Origin "*"; ${sec} } # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get # the same hour. location ^~ /shared/ { alias /opt/openvibe.sites/dist/_shared/; add_header Cache-Control "public, max-age=3600"; add_header Access-Control-Allow-Origin "*"; ${sec} } # Only the files in dist/ exist; anything else is a real 404 with a page, never the front page. error_page 404 /404.html; location = /404.html { internal; } location / { add_header Cache-Control "public, max-age=600"; ${sec} try_files $uri $uri.html $uri/ =404; } location ~ /\\.(?!well-known) { deny all; } } `; } function releaseJson(site, html) { const release = crypto.createHash('sha256').update(String(html).replace(/\d{4}-\d{2}-\d{2}/g, 'DATE')).digest('hex').slice(0, 12); return JSON.stringify({ service: site.tld, release, released_at: `${today}T00:00:00.000Z`, booted_at: null, contracts_version: null, packages: { 'openvibe-shared': require('openvibe-shared/package.json').version }, min_client_release: null, mixed_version_window_hours: 24, kind: 'notice', }, null, 2) + '\n'; } function statusJson(site) { return JSON.stringify({ domain: site.domain, name: site.name, stage: site.kind, live: false, network: NET.networkUrl, updated: today, note: 'Static notice served by OpenVibers/OpenVibe.Sites. See the linked destination for current information.', }, null, 2) + '\n'; } function build() { const out = {}; for (const site of notices) { out[`${site.domain}/index.html`] = page(site); out[`${site.domain}/404.html`] = notFound(site); out[`${site.domain}/robots.txt`] = robots(site); out[`${site.domain}/manifest.webmanifest`] = manifest(site); out[`${site.domain}/release.json`] = releaseJson(site, out[`${site.domain}/index.html`]); out[`${site.domain}/status.json`] = statusJson(site); for (const kind of ['terms', 'privacy', 'dmca']) out[`${site.domain}/${kind}.html`] = legal.page(kind, legalSite(site)); if (!site.vhostOwner) out[`../deploy/nginx/${site.domain}.conf`] = vhost(site); } for (const name of sharedFiles.BROWSER) out[`_shared/${name}`] = fs.readFileSync(sharedFiles.path(name), 'utf8'); return out; } function verifyFrozen() { const noticeDomains = new Set(notices.map(site => site.domain)); let missing = 0; for (const domain of frozen) { if (noticeDomains.has(domain)) { console.error(`frozen domain is also a notice: ${domain}`); missing++; } for (const file of [`dist/${domain}/index.html`, `deploy/nginx/${domain}.conf`]) { if (!fs.existsSync(path.join(ROOT, file))) { console.error(`missing frozen file: ${file}`); missing++; } } } return missing; } function check() { let stale = verifyFrozen(); const norm = (s) => String(s).replace(/\d{4}-\d{2}-\d{2}/g, 'DATE'); for (const [rel, content] of Object.entries(build())) { const p = path.join(DIST, rel); if (!fs.existsSync(p) || norm(fs.readFileSync(p, 'utf8')) !== norm(content)) { console.error(`stale: ${rel}`); stale++; } } return stale; } if (require.main === module) { if (process.argv.includes('--check')) process.exit(check() ? 1 : 0); for (const [rel, content] of Object.entries(build())) { const p = path.join(DIST, rel); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); } console.log(`built ${notices.length} notices; preserved ${frozen.length} frozen product domains`); } module.exports = { build, check, verifyFrozen };