// PolHunter: gamified visits across the network, paid in POL. // // Three gates, all default-deny, all from the environment. A missing variable means silence: // OUTBOUND=on required before anything leaves this server: Telegram posts included. // There is no mailer in this app and there is not going to be one. // SIGNUPS=open there is no sign-up form at all; hunters arrive signed in from their // InstantAdPay dashboard (lib/sso.js). This gate controls whether that // hand-off is accepted, so the whole thing can be shut with one variable. // CURTAIN= a contentless "Coming soon" page for everyone who has not opened ?k=. // // Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET. 'use strict'; const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const store = require('./lib/store'); const sso = require('./lib/sso'); const missions = require('./lib/missions'); const rewards = require('./lib/rewards'); const faucet = require('./lib/faucet'); 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 ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim(); const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, ''); const outbound = () => process.env.OUTBOUND === 'on'; const signupsOpen = () => process.env.SIGNUPS === 'open'; store.init(DATA_DIR); const faucetOn = faucet.init(); // ---- Telegram (outward: gated) ------------------------------------------------------------ async function telegram(text) { if (!outbound()) return false; // the gate const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC; if (!tok || !chat) return false; const body = JSON.stringify(Object.assign({ chat_id: chat, text, parse_mode: 'HTML', disable_web_page_preview: true }, topic ? { message_thread_id: Number(topic) } : {})); try { const r = await fetch('https://api.telegram.org/bot' + tok + '/sendMessage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }); return r.ok; } catch (e) { return false; } } const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 }); async function notify(kind, p) { if (kind === 'paid') return telegram('\u{1F3AF} PolHunter · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got ' + fmt(p.pol) + ' POL · verify\nHunt yours'); if (kind === 'low') return telegram('⚠️ PolHunter faucet is low: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); if (kind === 'failed') return telegram('❌ PolHunter · drip to #' + p.memberId + ' failed: ' + p.error); } // Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.'; function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; } // ---- helpers ------------------------------------------------------------------------------- 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', '.webp': 'image/webp', '.mp4': 'video/mp4' }; const SEC = { 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'DENY' }; function json(res, code, body, extra) { res.writeHead(code, Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(JSON.stringify(body)); } function sendFile(res, file, extra) { // video streams with byte ranges (iOS Safari refuses to play without 206 support) if (path.extname(file) === '.mp4') return sendRange(res, file, extra); fs.readFile(file, (err, buf) => { if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } res.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {})); res.end(buf); }); } function sendRange(res, file, extra) { fs.stat(file, (err, st) => { if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); } const size = st.size; const range = res.req && res.req.headers.range; const head = Object.assign({ 'Content-Type': TYPES['.mp4'], 'Accept-Ranges': 'bytes', 'Cache-Control': 'public, max-age=3600' }, SEC, extra || {}); let start = 0, end = size - 1, code = 200; const m = range && /^bytes=(\d*)-(\d*)$/.exec(range); if (m) { start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2] || 0)); end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : (m[1] ? size - 1 : size - 1); if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); } code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size; } head['Content-Length'] = end - start + 1; res.writeHead(code, head); if (res.req && res.req.method === 'HEAD') return res.end(); fs.createReadStream(file, { start, end }).pipe(res); }); } function readBody(req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 65536) req.destroy(); }); req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { resolve({}); } }); }); } const hits = new Map(); function limited(key, max, windowMs) { const now = Date.now(); const r = hits.get(key); if (!r || now > r.reset) { hits.set(key, { n: 1, reset: now + windowMs }); return false; } r.n++; return r.n > max; } const ip = req => String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(); const CURTAIN_PAGE = `Coming soon

Coming soon

This site is still being built.

`; 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; } function admin(req) { const k = req.headers['x-admin-key'] || new URL(req.url, 'http://x').searchParams.get('key'); return !!(ADMIN_KEY && k && k.length === ADMIN_KEY.length && crypto.timingSafeEqual(Buffer.from(k), Buffer.from(ADMIN_KEY))); } const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() }); // ---- the server ------------------------------------------------------------------------------ const server = http.createServer(async (req, res) => { try { 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, faucet: faucetOn, sso: sso.enabled(), chain: Number(process.env.HUNT_CHAIN_ID) || null }); // the embed talks to us from the mission sites: it must work through the curtain, and it must // answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first) if (p === '/api/embed/code') { const origin = String(req.headers.origin || ''); const r = missions.codeForEmbed(u.searchParams.get('t'), origin); const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': 'Origin' }; if (req.method === 'OPTIONS') { res.writeHead(204, Object.assign({ 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Max-Age': '600' }, cors)); return res.end(); } if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors); return json(res, r.error ? 403 : 200, r, cors); } if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' }); // the coin on the code pill, fetched by mission-site visitors who hold no curtain pass if (p === '/img/coin-sm.png' || p === '/img/coin.png') return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' }); if (curtained(req, res, u)) return; // ---- sign-in by hand-off from InstantAdPay if (p === '/auth') { if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); } const v = sso.verify(u.searchParams.get('t')); if (v.error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('

' + v.error + '

'); } const cur = sso.fromRequest(req); let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null; if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims); res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end(); } if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); } // ---- public if (p === '/api/config') return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer() }); if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); } // ---- hunter (session required) if (p.startsWith('/api/my/')) { const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' }); if (p === '/api/my/board') { const done = new Set(rewards.mine(me.memberId).map(x => x.missionId)); const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set(); return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool() }); } if (p === '/api/my/start' && req.method === 'POST') { const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' }); if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' }); const t = missions.issue(me.memberId, m.id); // a mission URL may place the token itself with {token} (a Telegram Mini App takes it in // ?startapp=, not as our own query string); otherwise it is appended as ?ph= // the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t; return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp }); } if (p === '/api/my/submit' && req.method === 'POST') { const b = await readBody(req); if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' }); const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error }); const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' }); if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' }); { const pool = rewards.pool(); if (pool.spent) return json(res, 400, { error: SPENT_MSG, spent: true, resetsAt: pool.resetsAt }); } const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' }); } return json(res, 404, { error: 'No such call.' }); } // ---- admin (key) if (p.startsWith('/api/admin/')) { if (!admin(req)) return json(res, 401, { error: 'Admin key required.' }); if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } }); if (p === '/api/admin/mission' && req.method === 'POST') { const b = await readBody(req); const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' }); let host = ''; try { host = new URL(String(b.url)).hostname.replace(/^www\./, ''); } catch (e) { return json(res, 400, { error: 'url must be a full https URL' }); } // the origin the embed calls from can differ from the link (a t.me launch link opens a Mini App on its own host) if (b.host) host = String(b.host).trim().toLowerCase().replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/.*$/, ''); missions.save({ id, site: String(b.site || host).slice(0, 60), host, name: String(b.name || '').slice(0, 80), brief: String(b.brief || '').slice(0, 400), url: String(b.url), dwell: Math.max(5, Number(b.dwell) || 45), slots: Math.max(1, Number(b.slots) || 1), budget: Math.max(0, Number(b.budget) || 0), active: b.active !== false }); return json(res, 200, { ok: true, missions: missions.list() }); } if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); } if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); } if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); } if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' }); const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell }); } return json(res, 404, { error: 'No such admin call.' }); } if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html')); if (p === '/app') { if (!sso.fromRequest(req)) { res.writeHead(302, { Location: '/?signin=1' }); return res.end(); } return sendFile(res, path.join(PUBLIC_DIR, 'app.html')); } if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' }); 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(); } return sendFile(res, file); } catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} } }); // the faucet pays every two minutes; nothing outward leaves unless OUTBOUND=on (telegram checks) if (faucetOn) setInterval(() => faucet.tick(notify).catch(e => console.error('faucet', e.message)), 2 * 60000); server.listen(PORT, () => console.log(`PolHunter on :${PORT} — outbound: ${outbound() ? 'ON' : 'OFF'} — sign-ups: ${signupsOpen() ? 'open' : 'CLOSED'} — curtain: ${CURTAIN ? 'up' : 'down'} — sso: ${sso.enabled() ? 'on' : 'off'} — faucet: ${faucetOn ? faucet.address() + ' chain ' + (process.env.HUNT_CHAIN_ID || '?') : 'off'}`));