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 ` + +Protected + +
🔒

Protected

+

Enter the passphrase to view this page.

+
+ +
+
${err ? 'Incorrect passphrase.' : ''}
`; +} + +function send(res, code, type, body, extra) { + const h = { 'Content-Type': type, 'X-Robots-Tag': 'noindex, nofollow', ...(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' }); + fs.createReadStream(abs).pipe(res); + } catch (_) { send(res, 404, 'text/html; charset=utf-8', '

404

'); } +}).listen(PORT, () => console.log('gate proxy on ' + PORT));