diff --git a/package.json b/package.json new file mode 100644 index 0000000..0d7c285 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "mosaic-gate-proxy", + "version": "1.0.0", + "private": true, + "description": "Passphrase gate that serves a project's existing static files behind a shared passphrase.", + "scripts": { + "start": "node server.js" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..c3b63cb --- /dev/null +++ b/server.js @@ -0,0 +1,106 @@ +// 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 `
+ +Enter the passphrase to view this page.
+ +