openvibers/openvibe.sites build.js
build.js
| 1 | |
| 2 | ; |
| 3 | /** OpenVibe.Sites: the shared browser files and the two frozen pages (Tips, VIP) until plan T5 launches them. */ |
| 4 | const fs = require('fs'); |
| 5 | const path = require('path'); |
| 6 | const crypto = require('crypto'); |
| 7 | const legal = require('openvibe-shared/legal'); |
| 8 | const sharedFiles = require('openvibe-shared/files'); |
| 9 | const notices = require('./notices.json'); |
| 10 | const frozen = require('./frozen.json'); |
| 11 | const ROOT = __dirname; |
| 12 | const DIST = path.join(ROOT, 'dist'); |
| 13 | const NET = { networkUrl: 'https://openvibe.network' }; |
| 14 | const today = new Date().toISOString().slice(0, 10); |
| 15 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 16 | const legalSite = (site) => ({ id: site.tld, service: 'network', host: site.domain, name: site.name, profile: site.legalProfile || 'info' }); |
| 17 | |
| 18 | // Presentation is owned by the product teams. These notice snapshots preserve the existing pages. |
| 19 | function page(site) { return fs.readFileSync(path.join(ROOT, 'notice-pages', `${site.domain}.html`), 'utf8'); } |
| 20 | function robots() { return 'User-agent: *\nAllow: /\nDisallow: /auth/\n'; } |
| 21 | 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`; } |
| 22 | 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'; } |
| 23 | |
| 24 | /** 404.html: what nginx answers (with status 404) for any path that is not a file in dist/<domain>/. */ |
| 25 | function notFound(site) { |
| 26 | const home = `https://${site.domain}/`; |
| 27 | return `<!DOCTYPE html> |
| 28 | <html lang="en"> |
| 29 | <head> |
| 30 | <meta charset="utf-8"> |
| 31 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 32 | <title>Not found · ${esc(site.name)}</title> |
| 33 | <meta name="robots" content="noindex"> |
| 34 | <meta name="theme-color" content="${esc(site.accent)}"> |
| 35 | ${require('openvibe-shared/app-icon').headTags({ site: 'network' }).split('\n')[0]} |
| 36 | ${require('openvibe-shared/app-icon').CRITICAL} |
| 37 | <style> |
| 38 | *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} |
| 39 | :root{--bg-primary:#0a0f1c;--bg-secondary:#101828;--border:#1f2d47;--text-primary:#e6edf7;--text-secondary:#96a7c2;--site:${esc(site.accent)}} |
| 40 | body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;min-height:100vh;display:grid;place-items:center;padding:24px 16px} |
| 41 | main{max-width:560px;text-align:center} |
| 42 | .code{font-size:14px;font-weight:700;letter-spacing:.2em;color:var(--site)} |
| 43 | h1{font-size:clamp(28px,5vw,40px);font-weight:800;letter-spacing:-.8px;margin:10px 0 12px} |
| 44 | p{color:var(--text-secondary);font-size:15px;line-height:1.65} |
| 45 | .links{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin-top:24px} |
| 46 | a{display:inline-block;padding:11px 18px;border-radius:12px;font-size:14px;font-weight:600;text-decoration:none;border:1px solid var(--border);color:var(--text-primary);background:var(--bg-secondary)} |
| 47 | a:first-child{background:var(--site);border-color:var(--site);color:#0b0d10} |
| 48 | </style> |
| 49 | </head> |
| 50 | <body> |
| 51 | <main> |
| 52 | <p class="code">404</p> |
| 53 | <h1>This page does not exist</h1> |
| 54 | <p>${esc(site.domain)} has only its front page and legal pages for now. The address you followed is not one of them.</p> |
| 55 | <div class="links"> |
| 56 | <a href="${home}">${esc(site.name)}</a> |
| 57 | <a href="${NET.networkUrl}/">OpenVibe.Network</a> |
| 58 | </div> |
| 59 | </main> |
| 60 | </body> |
| 61 | </html> |
| 62 | `; |
| 63 | } |
| 64 | |
| 65 | /** nginx vhost: static root, wildcard cert, www → apex, long cache for the immutable bits, real 404s. */ |
| 66 | const SECURITY_HEADERS = [ |
| 67 | 'add_header X-Content-Type-Options nosniff always;', |
| 68 | 'add_header X-Frame-Options SAMEORIGIN always;', |
| 69 | 'add_header Referrer-Policy strict-origin-when-cross-origin always;', |
| 70 | 'add_header Strict-Transport-Security "max-age=31536000" always;', |
| 71 | ]; |
| 72 | function vhost(site) { |
| 73 | const d = site.domain; |
| 74 | const sec = SECURITY_HEADERS.join('\n '); |
| 75 | // Subdomains of a zone (e.g. events.openvibe.network) use the zone's wildcard cert and have no www. |
| 76 | const cert = site.zone || d; |
| 77 | const www = site.zone ? '' : ` |
| 78 | server { |
| 79 | listen 443 ssl; |
| 80 | listen [::]:443 ssl; |
| 81 | http2 on; |
| 82 | server_name www.${d}; |
| 83 | ssl_certificate /etc/letsencrypt/live/${cert}/fullchain.pem; |
| 84 | ssl_certificate_key /etc/letsencrypt/live/${cert}/privkey.pem; |
| 85 | return 301 https://${d}$request_uri; |
| 86 | } |
| 87 | `; |
| 88 | return `# ${d} — static front page (OpenVibe.Sites). Generated by build.js; edit notices.json, not this. |
| 89 | server { |
| 90 | listen 80; |
| 91 | listen [::]:80; |
| 92 | server_name ${d}${site.zone ? '' : ` www.${d}`}; |
| 93 | return 301 https://${d}$request_uri; |
| 94 | } |
| 95 | ${www} |
| 96 | server { |
| 97 | listen 443 ssl; |
| 98 | listen [::]:443 ssl; |
| 99 | http2 on; |
| 100 | server_name ${d}; |
| 101 | include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo) |
| 102 | |
| 103 | ssl_certificate /etc/letsencrypt/live/${cert}/fullchain.pem; |
| 104 | ssl_certificate_key /etc/letsencrypt/live/${cert}/privkey.pem; |
| 105 | |
| 106 | root /opt/openvibe.sites/dist/${d}; |
| 107 | index index.html; |
| 108 | |
| 109 | access_log /var/log/nginx/${d}.access.log; |
| 110 | error_log /var/log/nginx/${d}.error.log; |
| 111 | |
| 112 | ${SECURITY_HEADERS.join('\n ')} |
| 113 | |
| 114 | gzip on; |
| 115 | gzip_types text/html text/plain text/css application/json application/javascript application/xml image/svg+xml application/manifest+json; |
| 116 | |
| 117 | # An add_header inside a location replaces the server's list (nginx does not merge them), so every |
| 118 | # location that sets its own header repeats the security headers. |
| 119 | location = /robots.txt { |
| 120 | add_header Cache-Control "public, max-age=3600"; |
| 121 | ${sec} |
| 122 | } |
| 123 | location = /sitemap.xml { |
| 124 | types { application/xml xml; } |
| 125 | add_header Cache-Control "public, max-age=3600"; |
| 126 | ${sec} |
| 127 | } |
| 128 | location = /manifest.webmanifest { |
| 129 | types { application/manifest+json webmanifest; } |
| 130 | add_header Cache-Control "public, max-age=86400"; |
| 131 | ${sec} |
| 132 | } |
| 133 | location = /status.json { |
| 134 | add_header Cache-Control "public, max-age=600"; |
| 135 | add_header Access-Control-Allow-Origin "*"; |
| 136 | ${sec} |
| 137 | } |
| 138 | location = /release.json { |
| 139 | add_header Cache-Control "no-cache"; |
| 140 | add_header Access-Control-Allow-Origin "*"; |
| 141 | ${sec} |
| 142 | } |
| 143 | # This repository's pinned shared browser files (navbar, footer, theme loader…), one copy for every Sites |
| 144 | # vhost. Pages ask for them content-addressed (?v=); the few loaded without it (navbar's companions) get |
| 145 | # the same hour. |
| 146 | location ^~ /shared/ { |
| 147 | alias /opt/openvibe.sites/dist/_shared/; |
| 148 | add_header Cache-Control "public, max-age=3600"; |
| 149 | add_header Access-Control-Allow-Origin "*"; |
| 150 | ${sec} |
| 151 | } |
| 152 | |
| 153 | # Only the files in dist/ exist; anything else is a real 404 with a page, never the front page. |
| 154 | error_page 404 /404.html; |
| 155 | location = /404.html { internal; } |
| 156 | |
| 157 | location / { |
| 158 | add_header Cache-Control "public, max-age=600"; |
| 159 | ${sec} |
| 160 | try_files $uri $uri.html $uri/ =404; |
| 161 | } |
| 162 | |
| 163 | location ~ /\\.(?!well-known) { deny all; } |
| 164 | } |
| 165 | `; |
| 166 | } |
| 167 | |
| 168 | function releaseJson(site, html) { |
| 169 | const release = crypto.createHash('sha256').update(String(html).replace(/\d{4}-\d{2}-\d{2}/g, 'DATE')).digest('hex').slice(0, 12); |
| 170 | return JSON.stringify({ |
| 171 | service: site.tld, release, released_at: `${today}T00:00:00.000Z`, booted_at: null, |
| 172 | contracts_version: null, packages: { 'openvibe-shared': require('openvibe-shared/package.json').version }, |
| 173 | min_client_release: null, mixed_version_window_hours: 24, kind: 'notice', |
| 174 | }, null, 2) + '\n'; |
| 175 | } |
| 176 | function statusJson(site) { |
| 177 | return JSON.stringify({ |
| 178 | domain: site.domain, name: site.name, stage: site.kind, live: false, |
| 179 | network: NET.networkUrl, updated: today, |
| 180 | note: 'Static notice served by OpenVibers/OpenVibe.Sites. See the linked destination for current information.', |
| 181 | }, null, 2) + '\n'; |
| 182 | } |
| 183 | function build() { |
| 184 | const out = {}; |
| 185 | for (const site of notices) { |
| 186 | out[`${site.domain}/index.html`] = page(site); |
| 187 | out[`${site.domain}/404.html`] = notFound(site); |
| 188 | out[`${site.domain}/robots.txt`] = robots(site); |
| 189 | out[`${site.domain}/manifest.webmanifest`] = manifest(site); |
| 190 | out[`${site.domain}/release.json`] = releaseJson(site, out[`${site.domain}/index.html`]); |
| 191 | out[`${site.domain}/status.json`] = statusJson(site); |
| 192 | for (const kind of ['terms', 'privacy', 'dmca']) out[`${site.domain}/${kind}.html`] = legal.page(kind, legalSite(site)); |
| 193 | if (!site.vhostOwner) out[`../deploy/nginx/${site.domain}.conf`] = vhost(site); |
| 194 | } |
| 195 | for (const name of sharedFiles.BROWSER) out[`_shared/${name}`] = fs.readFileSync(sharedFiles.path(name), 'utf8'); |
| 196 | return out; |
| 197 | } |
| 198 | function verifyFrozen() { |
| 199 | const noticeDomains = new Set(notices.map(site => site.domain)); |
| 200 | let missing = 0; |
| 201 | for (const domain of frozen) { |
| 202 | if (noticeDomains.has(domain)) { console.error(`frozen domain is also a notice: ${domain}`); missing++; } |
| 203 | for (const file of [`dist/${domain}/index.html`, `deploy/nginx/${domain}.conf`]) { |
| 204 | if (!fs.existsSync(path.join(ROOT, file))) { console.error(`missing frozen file: ${file}`); missing++; } |
| 205 | } |
| 206 | } |
| 207 | return missing; |
| 208 | } |
| 209 | function check() { |
| 210 | let stale = verifyFrozen(); |
| 211 | const norm = (s) => String(s).replace(/\d{4}-\d{2}-\d{2}/g, 'DATE'); |
| 212 | for (const [rel, content] of Object.entries(build())) { |
| 213 | const p = path.join(DIST, rel); |
| 214 | if (!fs.existsSync(p) || norm(fs.readFileSync(p, 'utf8')) !== norm(content)) { console.error(`stale: ${rel}`); stale++; } |
| 215 | } |
| 216 | return stale; |
| 217 | } |
| 218 | if (require.main === module) { |
| 219 | if (process.argv.includes('--check')) process.exit(check() ? 1 : 0); |
| 220 | for (const [rel, content] of Object.entries(build())) { |
| 221 | const p = path.join(DIST, rel); |
| 222 | fs.mkdirSync(path.dirname(p), { recursive: true }); |
| 223 | fs.writeFileSync(p, content); |
| 224 | } |
| 225 | console.log(`built ${notices.length} notices; preserved ${frozen.length} frozen product domains`); |
| 226 | } |
| 227 | module.exports = { build, check, verifyFrozen }; |
| 228 |