bed75d1b0f
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
324 lines
33 KiB
JavaScript
324 lines
33 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 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 });
|
||
// keys being posted right now, so the claim path and the sweep never post the same thing twice
|
||
const postingNow = new Set();
|
||
async function notify(kind, p) {
|
||
if (kind === 'paid' && p && p.id) { const k = 'find:' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyPaid(p); } finally { postingNow.delete(k); } }
|
||
if (kind === 'badge' && p && p.me) { const k = 'badge:' + p.me.memberId + ':' + p.id; if (postingNow.has(k)) return false; postingNow.add(k); try { return await notifyBadge(p); } finally { postingNow.delete(k); } }
|
||
return notifyOther(kind, p);
|
||
}
|
||
async function notifyPaid(p) {
|
||
{ const ok = await 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 (ok && p.id) rewards.mark(p.id, { posted: Date.now() }); return ok; }
|
||
}
|
||
async function notifyOther(kind, p) {
|
||
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 === '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>'); }
|
||
return false;
|
||
}
|
||
async function notifyBadge(p) { const ok = await postBadge(p); if (ok) store.update('badges-posted', {}, all => { const k = String(p.me.memberId); all[k] = Array.from(new Set([...(all[k] || []), p.id])); return all; }); return ok; }
|
||
async function postBadge(p) {
|
||
{ 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; }
|
||
return false;
|
||
}
|
||
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
||
// Served in place, not redirected (Marty, 2026-09-21): Facebook's crawler follows redirects and canonicalizes a
|
||
// share to og:url, so a redirect to the bare home page lost the member's name on every share. og:url carries
|
||
// the ref; the cookie rides on the same response.
|
||
if (p === '/' && u.searchParams.get('r')) {
|
||
const r = String(u.searchParams.get('r')).trim().slice(0, 40);
|
||
const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r);
|
||
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8');
|
||
if (ok) html = html.replace('<meta property="og:url" content="https://polhunter.com/">', '<meta property="og:url" content="https://polhunter.com/?r=' + encodeURIComponent(r) + '">');
|
||
const h = Object.assign({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, SEC);
|
||
if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
|
||
res.writeHead(200, h); return res.end(html);
|
||
}
|
||
|
||
// ---- 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)) + '?from=polhunter' } });
|
||
}
|
||
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);
|
||
// Every completed mission reaches Telegram (Marty, 2026-09-21): a find is posted when its drip is paid, and
|
||
// this sweep posts any paid find that is not marked posted yet (the pre-live gate, Telegram down, a restart).
|
||
// Only finds made after HUNT_POST_SINCE count, so rehearsal drips from before the doors opened stay quiet.
|
||
const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0;
|
||
let sweeping = false;
|
||
async function postMissedFinds() {
|
||
if (sweeping || !outbound()) return 0; sweeping = true; let n = 0;
|
||
try { for (const p of rewards.unposted(POST_SINCE, 15)) { if (postingNow.has('find:' + p.id)) continue; if (await notify('paid', p)) n++; else break; } }
|
||
catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; }
|
||
if (n) console.log('posted', n, 'missed find(s) to Telegram');
|
||
await postMissedBadges().catch(e => console.error('badge sweep', e.message));
|
||
return n;
|
||
}
|
||
// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart)
|
||
async function postMissedBadges() {
|
||
if (!outbound()) return 0;
|
||
const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed');
|
||
const seen = new Map(); for (const r of recs) seen.set(r.memberId, { memberId: r.memberId, username: r.username || null });
|
||
const posted = store.read('badges-posted', {}); let n = 0;
|
||
for (const me of seen.values()) {
|
||
const have = new Set(posted[String(me.memberId)] || []);
|
||
for (const b of social.badgesFor(me.memberId)) { if (have.has(b.id) || postingNow.has('badge:' + me.memberId + ':' + b.id)) continue; if (n >= 10) return n; if (await notify('badge', { me, id: b.id })) n++; else return n; }
|
||
}
|
||
if (n) console.log('posted', n, 'missed badge(s) to Telegram');
|
||
return n;
|
||
}
|
||
setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000);
|
||
// 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'}`));
|