add server-side passphrase gate (protects pages + PDFs)
This commit is contained in:
parent
46c400571c
commit
6c85055e79
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow"><title>Protected</title>
|
||||||
|
<style>:root{color-scheme:light dark}*{box-sizing:border-box}
|
||||||
|
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
|
||||||
|
font-family:-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:#0f1720;color:#e7edf3;padding:24px}
|
||||||
|
.card{width:100%;max-width:360px;background:#182430;border:1px solid #26333f;border-radius:14px;
|
||||||
|
padding:28px 26px;box-shadow:0 18px 50px rgba(0,0,0,.4);text-align:center}
|
||||||
|
.lock{font-size:30px}h1{font-size:16px;margin:14px 0 4px}p{font-size:12.5px;color:#93a3b3;margin:0 0 18px}
|
||||||
|
form{display:flex;gap:8px}input{flex:1;padding:11px 12px;border-radius:9px;border:1px solid #33424f;
|
||||||
|
background:#0f1720;color:#e7edf3;font-size:14px;outline:none}input:focus{border-color:#4b93d1}
|
||||||
|
button{padding:11px 15px;border:0;border-radius:9px;background:#2f6fb0;color:#fff;font-size:14px;font-weight:600;cursor:pointer}
|
||||||
|
button:hover{background:#3a80c6}.err{color:#f0857d;font-size:12px;margin-top:12px;min-height:15px}</style></head>
|
||||||
|
<body><div class="card"><div class="lock">🔒</div><h1>Protected</h1>
|
||||||
|
<p>Enter the passphrase to view this page.</p>
|
||||||
|
<form method="POST" action="/__gate"><input type="hidden" name="next" value="${safeNext(next).replace(/"/g, '"')}">
|
||||||
|
<input name="pass" type="password" autocomplete="current-password" autofocus placeholder="Passphrase" aria-label="Passphrase">
|
||||||
|
<button type="submit">Open</button></form>
|
||||||
|
<div class="err">${err ? 'Incorrect passphrase.' : ''}</div></div></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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', '<h1>404</h1>'); }
|
||||||
|
}).listen(PORT, () => console.log('gate proxy on ' + PORT));
|
||||||
Loading…
Reference in New Issue