Advertiser-funded missions: the accounting that keeps their money separate
First increment of paid missions. No advertiser can buy one yet; this is the
layer that has to be right before anyone can, because the faucet is a single
wallet holding house float and every advertiser's unspent reserve at once.
THE SOLVENCY RULE, in paid.canSell: faucet balance must cover the house float
plus every outstanding reserve before a sale is accepted. Break it and you have
sold delivery you cannot pay for, and a hunter finds a code only to watch the
drip fail. Checked before the sale, never after.
Three consequences of Marty's decision that paid missions sit ON TOP of the
daily cap rather than inside it:
- a paid completion is excluded from paidToday, so it cannot eat house budget
- each live paid mission the hunter has not done raises their allowance by one
- a paid mission pays exactly what the advertiser set, with no random draw, so
the reserve taken at purchase is exact and can never come up short
Advertisers set the hunter payout themselves, floored at 0.30, and may pay more
to be completed sooner. Company margin rides on top as a percentage.
Existing missions are untouched: nothing carries the paid flag, so house
behaviour is byte for byte what it was. 18 new tests, 79 across the suite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,335 +1,335 @@
|
||||
// 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 referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
||||
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);
|
||||
// a bounty is the one payout that advertises the mission itself, so it goes to the room
|
||||
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</a>'); }
|
||||
// Marty's launch bonus, paid once to whoever gets a referral all the way through first
|
||||
if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} <b>First referral home!</b> ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s <b>' + fmt(d.pol) + ' POL</b> bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — <a href="' + SITE + '">bring yours</a>.'); }
|
||||
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);
|
||||
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
|
||||
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, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, 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') {
|
||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
||||
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' },
|
||||
referral: referrals.state(me) });
|
||||
}
|
||||
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);
|
||||
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||
let refDrips = [];
|
||||
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
|
||||
for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {});
|
||||
// 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(), referrals: referrals.totals(), referralPool: referrals.refPool(), 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', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) 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'}`));
|
||||
// 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 referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
||||
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);
|
||||
// a bounty is the one payout that advertises the mission itself, so it goes to the room
|
||||
if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} <b>PolHunter</b> · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · <b>' + fmt(d.pol) + ' POL</b> bounty on the way\n<a href="' + SITE + '">Bring yours</a>'); }
|
||||
// Marty's launch bonus, paid once to whoever gets a referral all the way through first
|
||||
if (kind === 'firstbonus') { const d = p.drip; return telegram('\u{1F947} <b>First referral home!</b> ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' is the first to bring a hunter all the way through, so Marty’s <b>' + fmt(d.pol) + ' POL</b> bonus is theirs on top of the bounty.\nThat one is claimed. The mission is not — <a href="' + SITE + '">bring yours</a>.'); }
|
||||
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);
|
||||
referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in
|
||||
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, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, 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') {
|
||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
||||
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, rewards.doneToday(me.memberId, me.wallet)),
|
||||
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' },
|
||||
referral: referrals.state(me) });
|
||||
}
|
||||
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, rewards.doneToday(me.memberId, me.wallet)); 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, rewards.doneToday(me.memberId, me.wallet)); 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);
|
||||
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||
let refDrips = [];
|
||||
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
|
||||
for (const d of refDrips) if (d.ref.kind === 'bounty' || d.ref.kind === 'first-bonus') notify(d.ref.kind === 'bounty' ? 'referral' : 'firstbonus', { drip: d }).catch(() => {});
|
||||
// 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(), referrals: referrals.totals(), referralPool: referrals.refPool(), 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', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) 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'}`));
|
||||
|
||||
Reference in New Issue
Block a user