// Reusable Mosaic passphrase gate (server-side). Drop this + package.json into any
// static Mosaic project; it serves the project's existing files (HTML, PDF, assets)
// but only after a shared passphrase is entered. Gates EVERY route (unlike a
// client-side gate, PDFs and assets are protected too) and marks everything noindex.
// Stdlib only — no npm deps to install.
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const ROOT = __dirname;
const PORT = process.env.PORT || 8080;
const PASSPHRASE = process.env.GATE_PASSPHRASE || 'OpenSaysMe';
const SECRET = process.env.GATE_SECRET || PASSPHRASE; // cookie signer
const COOKIE = 'mz_gate';
const TOKEN = crypto.createHmac('sha256', SECRET).update('v1').digest('hex');
const MAXAGE = 30 * 24 * 3600;
const MIME = {
'.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
'.json': 'application/json', '.pdf': 'application/pdf',
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
'.svg': 'image/svg+xml', '.webp': 'image/webp', '.ico': 'image/x-icon',
'.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.mp4': 'video/mp4',
};
const BLOCK = new Set(['server.js', 'package.json', 'package-lock.json']);
function authed(req) {
const c = (req.headers.cookie || '').split(';').map((s) => s.trim());
for (const kv of c) { const i = kv.indexOf('='); if (i > 0 && kv.slice(0, i) === COOKIE) {
const v = kv.slice(i + 1);
try { return crypto.timingSafeEqual(Buffer.from(v), Buffer.from(TOKEN)); } catch (_) { return false; }
} }
return false;
}
function safeNext(n) { return (typeof n === 'string' && n.startsWith('/') && !n.startsWith('//')) ? n : '/'; }
function lockPage(err, next) {
return `
Protected
🔒
Protected
Enter the passphrase to view this page.
${err ? 'Incorrect passphrase.' : ''}
`;
}
// Every response must be uncacheable — otherwise the CDN could cache a gated file
// under its URL and serve it to unauthenticated visitors (bypassing the gate), or
// cache a lock page and show it to authenticated ones.
const NOCACHE = { 'Cache-Control': 'no-store, no-cache, must-revalidate, private', 'Pragma': 'no-cache', 'Vary': 'Cookie' };
function send(res, code, type, body, extra) {
const h = { 'Content-Type': type, 'X-Robots-Tag': 'noindex, nofollow', ...NOCACHE, ...(extra || {}) };
res.writeHead(code, h); res.end(body);
}
http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
let pathname = decodeURIComponent(u.pathname);
if (pathname === '/__gate' && req.method === 'POST') {
let b = '';
req.on('data', (d) => { b += d; if (b.length > 4096) req.destroy(); });
req.on('end', () => {
const p = new URLSearchParams(b);
const next = safeNext(p.get('next'));
let ok = false;
try { ok = crypto.timingSafeEqual(Buffer.from(p.get('pass') || ''), Buffer.from(PASSPHRASE)); } catch (_) {}
if (ok) {
send(res, 302, 'text/plain', 'ok', {
'Set-Cookie': `${COOKIE}=${TOKEN}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAXAGE}`,
Location: next,
});
} else {
send(res, 200, 'text/html; charset=utf-8', lockPage(true, next));
}
});
return;
}
if (!authed(req)) { send(res, 200, 'text/html; charset=utf-8', lockPage(false, pathname)); return; }
// Authed: serve the file from disk.
let rel = pathname.replace(/^\/+/, '');
let abs = path.normalize(path.join(ROOT, rel));
if (!abs.startsWith(ROOT)) { send(res, 403, 'text/plain', 'forbidden'); return; }
try {
let st = fs.statSync(abs);
if (st.isDirectory()) { abs = path.join(abs, 'index.html'); st = fs.statSync(abs); }
const base = path.basename(abs);
if (BLOCK.has(base) || abs.includes('/node_modules/') || abs.includes('/.git/')) { send(res, 404, 'text/plain', 'not found'); return; }
const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type, 'X-Robots-Tag': 'noindex, nofollow', ...NOCACHE });
fs.createReadStream(abs).pipe(res);
} catch (_) { send(res, 404, 'text/html; charset=utf-8', '404
'); }
}).listen(PORT, () => console.log('gate proxy on ' + PORT));