3199347ca7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
181 lines
16 KiB
JavaScript
181 lines
16 KiB
JavaScript
// 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=<secret> a contentless "Coming soon" page for everyone who has not opened ?k=<secret>.
|
||
//
|
||
// 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} <b>PolHunter</b> · ' + (p.username ? '@' + p.username : '#' + p.memberId) + ' found it on ' + p.site + ' and got <b>' + fmt(p.pol) + ' POL</b> · <a href="' + explorer() + '/tx/' + p.tx + '">verify</a>\n<a href="' + SITE + '">Hunt yours</a>');
|
||
if (kind === 'low') return telegram('⚠️ <b>PolHunter faucet is low</b>: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.');
|
||
if (kind === 'failed') return telegram('❌ <b>PolHunter</b> · drip to #' + p.memberId + ' failed: ' + p.error);
|
||
}
|
||
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' };
|
||
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) {
|
||
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 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 = `<!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;
|
||
}
|
||
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': '*' });
|
||
|
||
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('<p style="font:16px system-ui;padding:40px">' + v.error + '</p>'); }
|
||
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));
|
||
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) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn } });
|
||
}
|
||
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)) return json(res, 400, { error: 'You already completed this one.' });
|
||
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=
|
||
const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + (m.url.includes('?') ? '&' : '?') + '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)) return json(res, 400, { error: 'You already completed this one.' });
|
||
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/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 + (m.url.includes('?') ? '&' : '?') + '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'}`));
|