openvibers/openvibe.sites build.js

build.js

main 10.1 KB · 228 lines · javascript

1#!/usr/bin/env node
2'use strict';
3/** OpenVibe.Sites: the shared browser files and the two frozen pages (Tips, VIP) until plan T5 launches them. */
4const fs = require('fs');
5const path = require('path');
6const crypto = require('crypto');
7const legal = require('openvibe-shared/legal');
8const sharedFiles = require('openvibe-shared/files');
9const notices = require('./notices.json');
10const frozen = require('./frozen.json');
11const ROOT = __dirname;
12const DIST = path.join(ROOT, 'dist');
13const NET = { networkUrl: 'https://openvibe.network' };
14const today = new Date().toISOString().slice(0, 10);
15const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
16const 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.
19function page(site) { return fs.readFileSync(path.join(ROOT, 'notice-pages', `${site.domain}.html`), 'utf8'); }
20function robots() { return 'User-agent: *\nAllow: /\nDisallow: /auth/\n'; }
21function 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`; }
22function 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>/. */
25function 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)}}
40body{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}
41main{max-width:560px;text-align:center}
42.code{font-size:14px;font-weight:700;letter-spacing:.2em;color:var(--site)}
43h1{font-size:clamp(28px,5vw,40px);font-weight:800;letter-spacing:-.8px;margin:10px 0 12px}
44p{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}
46a{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)}
47a: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. */
66const 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];
72function 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 ? '' : `
78server {
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.
89server {
90 listen 80;
91 listen [::]:80;
92 server_name ${d}${site.zone ? '' : ` www.${d}`};
93 return 301 https://${d}$request_uri;
94}
95${www}
96server {
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
168function 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}
176function 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}
183function 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}
198function 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}
209function 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}
218if (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}
227module.exports = { build, check, verifyFrozen };
228