PolHunter shell: default-deny gates (OUTBOUND, SIGNUPS, CURTAIN), health, placeholder page

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 10:16:11 -05:00
commit 1a6166abc3
5 changed files with 142 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
*.log
*.key
.env
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY . .
RUN mkdir -p /app/data
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
CMD ["node","server.js"]
+15
View File
@@ -0,0 +1,15 @@
{
"name": "polhunter",
"version": "0.1.0",
"private": true,
"description": "PolHunter: gamified visits across the network, paid in POL.",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {}
}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PolHunter</title>
<style>
:root{--bg:#0b0a14;--ink:#f1eefb;--muted:#a79fc4;--pol:#8247e5;--pol-hi:#a97cf5;--gold:#f3be43}
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:32px 20px;background:radial-gradient(circle at 20% 10%,rgba(130,71,229,.28),transparent 45%),radial-gradient(circle at 85% 90%,rgba(243,190,67,.16),transparent 40%),var(--bg);color:var(--ink);font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
.wrap{max-width:640px;text-align:center}
.mark{display:inline-flex;align-items:center;gap:12px;font-weight:800;font-size:22px;letter-spacing:-.3px;margin-bottom:28px}
.mark i{width:34px;height:34px;border-radius:50%;background:var(--pol);box-shadow:0 0 34px rgba(130,71,229,.7)}
h1{font-size:clamp(32px,7vw,54px);line-height:1.05;font-weight:800;letter-spacing:-1px;margin-bottom:16px}
h1 b{color:var(--gold)}
p{color:var(--muted);font-size:18px;max-width:520px;margin:0 auto 22px}
.pill{display:inline-block;padding:8px 16px;border:1px solid rgba(167,159,196,.35);border-radius:999px;color:var(--muted);font-size:13px;letter-spacing:.08em;text-transform:uppercase}
.fine{margin-top:40px;font-size:12px;color:#6e678a}
</style>
</head>
<body>
<div class="wrap">
<div class="mark"><i></i>PolHunter</div>
<h1>Visit. Find it. <b>Get paid in POL.</b></h1>
<p>Missions across the network. Each one takes a few minutes on a site, a thing to find, and a drip of POL to your wallet when you find it.</p>
<span class="pill">Building — opens soon</span>
<div class="fine">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</div>
</div>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
// PolHunter: gamified visits across the network, paid in POL.
//
// This is the shell the hunt engine will grow inside. What it does today is refuse to do anything
// dangerous by default, because the day before it was built a test area in this same network
// emailed 213 real people. Three gates, all default-deny, all read from the environment:
//
// OUTBOUND=on nothing can send mail (or anything else outward) without it. There is no
// mailer in this app yet; when one arrives it must check outbound() first.
// SIGNUPS=open the sign-in door stays shut until this is set. Hunters will sign in with
// their InstantAdPay account; that bridge does not exist yet either.
// CURTAIN=<secret> while set, every request gets a contentless "Coming soon" page unless the
// browser has visited ?k=<secret> once. Lift it by unsetting the variable.
//
// A missing variable means silence, never delivery.
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = Number(process.env.PORT || 3000);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const PUBLIC_DIR = path.join(__dirname, 'public');
const CURTAIN = String(process.env.CURTAIN || '').trim();
const outbound = () => process.env.OUTBOUND === 'on';
const signupsOpen = () => process.env.SIGNUPS === 'open';
try { fs.mkdirSync(DATA_DIR, { recursive: true }); } catch (e) {}
const CURTAIN_PAGE = `<!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>Coming soon</title><style>
*{margin:0;padding:0;box-sizing:border-box} html,body{height:100%}
body{display:flex;align-items:center;justify-content:center;padding:24px;background:#0d1117;color:#e6edf3;font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
.card{max-width:420px;text-align:center} h1{font-size:clamp(28px,7vw,44px);font-weight:700;letter-spacing:-.5px;margin-bottom:14px} p{color:#8b949e}
</style></head><body><div class="card"><h1>Coming soon</h1><p>This site is still being built.</p></div></body></html>`;
function curtained(req, res, u) {
if (!CURTAIN) return false;
if (u.searchParams.get('k') === CURTAIN) {
u.searchParams.delete('k');
res.writeHead(302, {
'Set-Cookie': 'ph.pass=' + encodeURIComponent(CURTAIN) + '; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax; Secure',
Location: u.pathname + (u.searchParams.toString() ? '?' + u.searchParams : ''),
'Cache-Control': 'no-store'
});
res.end();
return true;
}
const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || '');
if (m && decodeURIComponent(m[1]) === CURTAIN) return false;
res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' });
res.end(req.method === 'HEAD' ? '' : CURTAIN_PAGE);
return true;
}
const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'application/javascript', '.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.json': 'application/json' };
function sendFile(res, file) {
fs.readFile(file, (err, buf) => {
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
res.end(buf);
});
}
const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
if (p === '/health') return json(res, 200, { ok: true, outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN });
if (curtained(req, res, u)) return; // nothing below runs for an uninvited visitor
if (p === '/api/config') return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), outbound: outbound() });
if (p.startsWith('/api/')) return json(res, 404, { error: 'Not built yet.' });
// static: the public dir, index for /
const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, '');
const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe);
if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); }
sendFile(res, file);
});
server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'}`));