Files
polhunter/server.js
T

277 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 social = require('./lib/social');
const badge = require('./lib/badge');
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');
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
const CURTAIN = String(process.env.CURTAIN || '').trim();
// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND
// says; from then on the curtain lifts by itself and Telegram opens. Launch: 2026-09-21T09:00-05:00.
const LIVE_AT = process.env.HUNT_LIVE_AT ? Date.parse(process.env.HUNT_LIVE_AT) : 0;
function live() { return !LIVE_AT || Date.now() >= LIVE_AT; }
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' && live(); // nothing leaves before the live moment
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; }
}
async function telegramPhoto(file, caption, general) {
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 || !file) return false;
try {
const fd = new FormData(); fd.append('chat_id', chat); fd.append('caption', caption); fd.append('parse_mode', 'HTML'); if (topic && !general) fd.append('message_thread_id', String(topic));
fd.append('photo', new Blob([fs.readFileSync(file)], { type: 'image/jpeg' }), 'badge.jpg');
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); 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);
if (kind === 'badge') { const b = social.BADGES.find(x => x.id === p.id); if (!b) return false; const who = social.nameOf(p.me); const file = await badge.render(p.id, p.me.username || '#' + p.me.memberId); const cap = '\u{1F3C6} <b>PolHunter</b> \u00b7 <b>' + (p.me.username ? '@' + p.me.username : '#' + p.me.memberId) + '</b> unlocked <b>' + b.name + '</b>: ' + b.why + '\n' + SITE + '/b/' + who + '/' + p.id; if (!file) return telegram(cap); const ok = await telegramPhoto(file, cap); if (String(process.env.HUNT_TG_BADGE_GENERAL || 'on') === 'on') await telegramPhoto(file, cap, true); return ok; }
if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} <b>PolHunter weekly prizes</b> for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 <b>' + fmt(w.prizePol) + ' POL</b>').join('\n') + '\n<a href="' + SITE + '/leaders">Leaderboard</a>'); }
}
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
const dailyMsg = d => 'That is your ' + ['', 'one', 'two', 'three', 'four', 'five'][d.limit] + ' for today. Fresh missions at midnight Central.';
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; }
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 = `<!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 || (LIVE_AT && live())) return false; // the launch moment lifts the curtain
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 && !(LIVE_AT && live()), live: live(), liveAt: LIVE_AT ? new Date(LIVE_AT).toISOString() : null, 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': '*' });
// badge cards and their share pages are posted around the web: they pass the curtain
if (p === '/style.css') return sendFile(res, path.join(PUBLIC_DIR, 'style.css'), { 'Cache-Control': 'public, max-age=3600' }); // the share pages are styled for outsiders
if (/^\/badges\/badge-[a-z]+\.jpg$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
{ const bm = /^\/badge-img\/([a-z0-9_.-]{1,40})\/([a-z]+)\.jpg$/.exec(p) || /^\/b\/([a-z0-9_.-]{1,40})\/([a-z]+)$/.exec(p);
if (bm) {
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
if (!m || !b || !social.badgesFor(m.memberId).some(x => x.id === id)) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
if (p.startsWith('/badge-img/')) { const file = await badge.render(id, m.username || '#' + m.memberId); return sendFile(res, file || path.join(PUBLIC_DIR, 'badges', 'badge-' + id + '.jpg'), { 'Cache-Control': 'public, max-age=3600' }); }
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const url = SITE + '/b/' + who + '/' + id, img = SITE + '/badge-img/' + who + '/' + id + '.jpg', ref = SITE + '/?r=' + encodeURIComponent(m.username || m.memberId);
const title = m.who + ' unlocked ' + b.name + ' on PolHunter', desc = b.name + ': ' + b.why + '. PolHunter pays random drips of POL for finding your code on our sites, on chain, with a link to prove it.';
const share = encodeURIComponent(title + ' ' + url);
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' + esc(title) + '</title><meta name="description" content="' + esc(desc) + '"><link rel="canonical" href="' + url + '"><meta name="robots" content="noindex">'
+ '<meta property="og:type" content="website"><meta property="og:site_name" content="PolHunter"><meta property="og:title" content="' + esc(title) + '"><meta property="og:description" content="' + esc(desc) + '"><meta property="og:url" content="' + url + '"><meta property="og:image" content="' + img + '"><meta property="og:image:width" content="1080"><meta property="og:image:height" content="1080">'
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(title) + '"><meta name="twitter:description" content="' + esc(desc) + '"><meta name="twitter:image" content="' + img + '"><link rel="stylesheet" href="/style.css?v=12"></head>'
+ '<body><div class="wrap"><header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter</a><nav class="nav"><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn sm" href="' + ref + '">Hunt yours</a></nav></header>'
+ '<div class="bp"><img src="' + img + '" alt="' + esc(b.name) + ' badge for ' + esc(m.who) + '"><h1>' + esc(m.who) + ' unlocked <em>' + esc(b.name) + '</em></h1><p>' + esc(b.why.charAt(0).toUpperCase() + b.why.slice(1)) + '. PolHunter pays random drips of POL for finding your code on our sites, straight to your wallet, on chain.</p>'
+ '<a class="btn" href="' + ref + '">Hunt yours</a><div class="sharebtns" style="justify-content:center;margin-top:16px"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + share + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(title) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + share + '">WhatsApp</a></div>'
+ '<p class="small" style="margin-top:26px">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</p></div></div></body></html>';
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=300' }); return res.end(html);
}
}
// 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' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
if (curtained(req, res, u)) return;
// a member's share link: /?r=<their IAP username or id> becomes a 30-day cookie; the landing then
// sends sign-ups to instantadpay.com/join/<ref>, so IAP's own last-touch sponsor rule applies
if (p === '/' && u.searchParams.get('r')) {
const r = String(u.searchParams.get('r')).trim().slice(0, 40); u.searchParams.delete('r');
const h = { Location: '/' + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' };
if (/^[A-Za-z0-9_.-]{1,40}$/.test(r)) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
res.writeHead(302, h); return res.end();
}
// ---- 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') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES.map(b => Object.assign({ art: '/badges/badge-' + b.id + '.jpg' }, b)) });
if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' });
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
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 = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet
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(), daily: rewards.daily(me.memberId),
badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } });
}
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 }); }
{ const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.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 d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); }
const before = new Set(social.badgesFor(me.memberId).map(b => b.id));
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
// achievements unlocked by this find: told to the board, posted to Telegram (gated)
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, unlocked: unlocked.map(b => b.id), 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', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
// award a week by hand (its Monday key); already-awarded weeks are skipped
if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); }
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);
// weekly prizes: the week that just ended is awarded on the first tick after Sunday, Central
setInterval(() => { try { social.awardDue(notify); } catch (e) { console.error('prizes', 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'}`));