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:
+96
@@ -0,0 +1,96 @@
|
|||||||
|
// Advertiser-funded missions (Marty, 2026-09-24).
|
||||||
|
//
|
||||||
|
// A member buys a mission: they pay POL into the faucet wallet, and hunters who complete it are
|
||||||
|
// dripped out of what they paid. Two rules follow from that, and everything here exists to hold
|
||||||
|
// them:
|
||||||
|
//
|
||||||
|
// 1. A paid completion is NOT house spend. It does not count against dailyCapPol, and it does
|
||||||
|
// not use up one of the hunter's missionsPerDay. Each live paid mission raises that hunter's
|
||||||
|
// allowance by one, because the company is not funding it. ("Each additional mission paid by
|
||||||
|
// an advertiser should allow +1 above the cap.")
|
||||||
|
//
|
||||||
|
// 2. THE SOLVENCY RULE. The faucet is one wallet holding house float AND every advertiser's
|
||||||
|
// unspent reserve. If reserves ever exceed the balance we have sold delivery we cannot pay
|
||||||
|
// for, and a hunter completes a mission only to watch the drip fail. So:
|
||||||
|
//
|
||||||
|
// faucet balance >= house float + sum(outstanding reserves)
|
||||||
|
//
|
||||||
|
// checked before a sale is accepted, never after.
|
||||||
|
//
|
||||||
|
// Money in is split at purchase: the hunter payout is reserved, the rest is company margin the
|
||||||
|
// P&L can recognise. The advertiser sets the hunter payout themselves (base 0.30, they may pay
|
||||||
|
// more to get completed sooner), so the reserve is exact rather than estimated. No draw, no
|
||||||
|
// variance, no guessing.
|
||||||
|
'use strict';
|
||||||
|
const store = require('./store');
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
paidEnabled: 1,
|
||||||
|
paidBaseHunterPol: 0.30, // the floor an advertiser may set as the hunter's payout
|
||||||
|
paidMarginPct: 100, // company margin on top of the hunter payout, as a percentage
|
||||||
|
paidMinBlock: 100, // completions per block
|
||||||
|
paidMaxConcurrent: 0, // 0 = no limit; supply is elastic now that paid missions add capacity
|
||||||
|
paidHouseFloatPol: 25, // POL kept back for house missions, never counted as sellable
|
||||||
|
};
|
||||||
|
function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||||
|
const r4 = n => Math.round(Number(n) * 10000) / 10000;
|
||||||
|
|
||||||
|
// ---- what a block costs -----------------------------------------------------------------
|
||||||
|
// hunterPol is what each completing hunter receives; the advertiser pays that plus the margin.
|
||||||
|
function quote(hunterPol, completions) {
|
||||||
|
const c = cfg();
|
||||||
|
const hp = r4(Math.max(Number(c.paidBaseHunterPol), Number(hunterPol) || 0));
|
||||||
|
const n = Math.max(Number(c.paidMinBlock), Math.round(Number(completions) || 0));
|
||||||
|
const reserve = r4(hp * n); // exact: no draw, so no buffer needed
|
||||||
|
const margin = r4(reserve * (Number(c.paidMarginPct) / 100));
|
||||||
|
return { hunterPol: hp, completions: n, reserve, margin, total: r4(reserve + margin) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- reserves ---------------------------------------------------------------------------
|
||||||
|
const paidMissions = () => store.read('missions', []).filter(m => m.paid);
|
||||||
|
|
||||||
|
// completions already granted against a mission (failed ones do not count, they never paid)
|
||||||
|
function usedOf(missionId) {
|
||||||
|
return store.read('payouts', []).filter(p => p.missionId === missionId && p.status !== 'failed' && !p.prize && !p.ref).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// what every live paid mission still owes its hunters
|
||||||
|
function outstanding() {
|
||||||
|
let pol = 0, left = 0;
|
||||||
|
for (const m of paidMissions()) {
|
||||||
|
const remaining = Math.max(0, Number(m.budget || 0) - usedOf(m.id));
|
||||||
|
left += remaining;
|
||||||
|
pol += remaining * Number(m.hunterPol || 0);
|
||||||
|
}
|
||||||
|
return { reservePol: r4(pol), completionsLeft: left, missions: paidMissions().length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE guard. Given the faucet's real balance, can we take this sale?
|
||||||
|
function canSell(balancePol, addReservePol) {
|
||||||
|
const c = cfg();
|
||||||
|
const o = outstanding();
|
||||||
|
const float = Number(c.paidHouseFloatPol) || 0;
|
||||||
|
const committed = r4(o.reservePol + float + Number(addReservePol || 0));
|
||||||
|
const bal = r4(Number(balancePol) || 0);
|
||||||
|
return {
|
||||||
|
ok: bal >= committed,
|
||||||
|
balance: bal, committed, houseFloat: float,
|
||||||
|
alreadyReserved: o.reservePol, adding: r4(Number(addReservePol) || 0),
|
||||||
|
shortBy: bal >= committed ? 0 : r4(committed - bal),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the hunter's allowance ---------------------------------------------------------------
|
||||||
|
// Base allowance is the company-funded missionsPerDay. Every live paid mission the hunter has
|
||||||
|
// not yet done adds one, because that completion costs the company nothing.
|
||||||
|
function extraAllowance(doneIds) {
|
||||||
|
const done = doneIds instanceof Set ? doneIds : new Set(doneIds || []);
|
||||||
|
return paidMissions().filter(m => m.active && !done.has(m.id) && Math.max(0, Number(m.budget || 0) - usedOf(m.id)) > 0).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPaid(missionId) {
|
||||||
|
const m = store.read('missions', []).find(x => x.id === missionId);
|
||||||
|
return !!(m && m.paid);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { cfg, quote, outstanding, canSell, extraAllowance, isPaid, usedOf, paidMissions };
|
||||||
+125
-112
@@ -1,112 +1,125 @@
|
|||||||
// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
|
// Rewards: the weighted draw, the daily cap, the queue, and the ledger.
|
||||||
//
|
//
|
||||||
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
|
// settings (admin, on the volume): { minPol, maxPol, dailyCapPol, lowBalancePol }
|
||||||
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
|
// defaults 0.05 / 1 / 20 / 40 (Marty, 2026-09-19). The cap is a setting, not a constant,
|
||||||
// because POL's dollar price moves.
|
// because POL's dollar price moves.
|
||||||
//
|
//
|
||||||
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
|
// The draw is weighted low: uniform on a log scale between min and max, so most drips sit near
|
||||||
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
|
// the floor and a 1 POL hit is rare enough to be talked about. Same range every day, whatever
|
||||||
// the cap.
|
// the cap.
|
||||||
//
|
//
|
||||||
// A completion is recorded the moment the proof checks out. If today's paid total plus this
|
// A completion is recorded the moment the proof checks out. If today's paid total plus this
|
||||||
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
|
// drip would cross the cap, the drip is queued (status 'queued') and paid on a later day in
|
||||||
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
|
// order; the hunter did the work and is never told no. The faucet pays 'due' entries.
|
||||||
'use strict';
|
'use strict';
|
||||||
const store = require('./store');
|
const store = require('./store');
|
||||||
const { ctDay } = require('./missions');
|
const { ctDay } = require('./missions');
|
||||||
|
|
||||||
// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
|
// drawSkew k: log-uniform on u^(1/k), so k=1 is the plain log-uniform and k=3 leans hard to the floor
|
||||||
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
|
// (mean ~0.13 POL on 0.05..1, one drip in eighty above 0.5). missionsPerDay: finds per hunter per
|
||||||
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
|
// Central day (Marty, 2026-09-20: cap 40, k=3, 3 a day, so a launch morning does not empty the pool)
|
||||||
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
|
const DEFAULTS = { minPol: 0.05, maxPol: 1, dailyCapPol: 20, lowBalancePol: 40, drawSkew: 3, missionsPerDay: 3 };
|
||||||
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
function settings() { return Object.assign({}, DEFAULTS, store.read('settings', {})); }
|
||||||
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
|
function setSettings(patch) { return store.update('settings', {}, s => Object.assign(s, patch)); }
|
||||||
|
|
||||||
function draw(min, max, skew) {
|
function draw(min, max, skew) {
|
||||||
const lo = Math.log(min), hi = Math.log(max);
|
const lo = Math.log(min), hi = Math.log(max);
|
||||||
const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
|
const k = Math.max(1, Number(skew != null ? skew : settings().drawSkew) || 1);
|
||||||
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
|
const u = Math.pow(Math.random(), 1 / k); // k>1 pushes u toward 1, i.e. the value toward the floor
|
||||||
const v = Math.exp(hi - u * (hi - lo));
|
const v = Math.exp(hi - u * (hi - lo));
|
||||||
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
|
return Math.round(Math.max(min, Math.min(max, v)) * 10000) / 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool
|
// prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool
|
||||||
// a hunter's finds today (Central), prizes excluded
|
// a hunter's finds today (Central), prizes excluded
|
||||||
// day defaults to today: called without it the comparison was against undefined and silently
|
// day defaults to today: called without it the comparison was against undefined and silently
|
||||||
// returned 0 for everyone, which would read as "no finds yet" to any new caller
|
// returned 0 for everyone, which would read as "no finds yet" to any new caller
|
||||||
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; }
|
function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; }
|
||||||
function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
function daily(memberId, doneIds) { const s = settings(); const base = Math.max(1, Number(s.missionsPerDay) || 3);
|
||||||
function paidToday(day) {
|
// every live paid mission this hunter has not done yet adds one, because the company is not
|
||||||
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0);
|
// funding that completion
|
||||||
}
|
let extra = 0; try { extra = require('./paid').extraAllowance(doneIds); } catch (e) {}
|
||||||
|
const limit = base + extra; const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; }
|
||||||
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
|
// What the COMPANY has spent today. Advertiser-funded completions are excluded on purpose: the
|
||||||
// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet:
|
// hunter is paid out of what the advertiser already put in the faucet, so counting it here would
|
||||||
// two accounts sharing a wallet get one drip per mission per day between them.
|
// let a paid mission use up the house budget (Marty, 2026-09-24).
|
||||||
function completed(memberId, missionId, wallet) {
|
function paidToday(day) {
|
||||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && !p.paid).reduce((n, p) => n + p.pol, 0);
|
||||||
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
|
}
|
||||||
}
|
|
||||||
// the mission ids this member (or wallet) has done today
|
// has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and
|
||||||
function doneToday(memberId, wallet) {
|
// the pool (Marty, 2026-09-20: yesterday's finds showed as done today). Checked by member id, and also by wallet:
|
||||||
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
// two accounts sharing a wallet get one drip per mission per day between them.
|
||||||
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
|
function completed(memberId, missionId, wallet) {
|
||||||
}
|
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||||
|
return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
|
||||||
// record a completion; decide paid-today vs queued
|
}
|
||||||
function grant(member, mission) {
|
// the mission ids this member (or wallet) has done today
|
||||||
const s = settings();
|
function doneToday(memberId, wallet) {
|
||||||
const pol = draw(Number(s.minPol), Number(s.maxPol));
|
const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay();
|
||||||
const day = ctDay();
|
return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId));
|
||||||
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
|
}
|
||||||
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
|
|
||||||
const overCap = paidToday(day) + pol > Number(s.dailyCapPol);
|
// record a completion; decide paid-today vs queued
|
||||||
const rec = {
|
function grant(member, mission) {
|
||||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
const s = settings();
|
||||||
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
|
// An advertiser-funded mission pays exactly what the advertiser set, every time. No draw, so
|
||||||
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
|
// the reserve taken at purchase is exact and can never come up short.
|
||||||
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
|
const isPaid = !!mission.paid;
|
||||||
};
|
const pol = isPaid ? Math.round(Number(mission.hunterPol || 0) * 10000) / 10000
|
||||||
store.update('payouts', [], all => { all.push(rec); return all; });
|
: draw(Number(s.minPol), Number(s.maxPol));
|
||||||
return { ok: true, rec, queued: overCap };
|
const day = ctDay();
|
||||||
}
|
const budgetLeft = mission.budget ? mission.budget - store.read('payouts', []).filter(p => p.missionId === mission.id && p.status !== 'failed').length : Infinity;
|
||||||
|
if (budgetLeft <= 0) return { error: 'This mission has paid out all it was funded for.' };
|
||||||
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
|
// the house cap governs house spend only; a paid completion is already funded
|
||||||
function payable() {
|
const overCap = !isPaid && (paidToday(day) + pol > Number(s.dailyCapPol));
|
||||||
const s = settings(); const day = ctDay();
|
const rec = {
|
||||||
let room = Number(s.dailyCapPol) - paidToday(day);
|
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8),
|
||||||
const all = store.read('payouts', []);
|
memberId: Number(member.memberId), email: member.email, wallet: member.wallet, username: member.username || null,
|
||||||
const out = [];
|
missionId: mission.id, site: mission.site, pol, day, at: Date.now(),
|
||||||
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
|
paid: isPaid || undefined, // marks advertiser-funded spend so the house accounting skips it
|
||||||
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
|
status: overCap ? 'queued' : 'due', tx: null, paidAt: null, error: null,
|
||||||
return out;
|
};
|
||||||
}
|
store.update('payouts', [], all => { all.push(rec); return all; });
|
||||||
// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first
|
return { ok: true, rec, queued: overCap };
|
||||||
function unposted(since, limit) {
|
}
|
||||||
return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0))
|
|
||||||
.sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15));
|
// what the faucet should pay now: due entries, then queued ones as long as today's cap allows
|
||||||
}
|
function payable() {
|
||||||
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
|
const s = settings(); const day = ctDay();
|
||||||
|
let room = Number(s.dailyCapPol) - paidToday(day);
|
||||||
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
|
const all = store.read('payouts', []);
|
||||||
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
|
const out = [];
|
||||||
function nextResetAt(now) {
|
for (const p of all.filter(p => p.status === 'due')) { out.push(p); }
|
||||||
const t0 = now || Date.now(); const today = ctDay(t0);
|
for (const p of all.filter(p => p.status === 'queued').sort((a, b) => a.at - b.at)) { if (p.pol <= room) { room -= p.pol; out.push(p); } else break; }
|
||||||
let lo = t0, hi = t0 + 26 * 3600000;
|
return out;
|
||||||
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
|
}
|
||||||
return hi;
|
// paid drips whose find has not been posted yet (found at or after `since`), oldest paid first
|
||||||
}
|
function unposted(since, limit) {
|
||||||
function pool() {
|
return store.read('payouts', []).filter(p => p.status === 'paid' && p.tx && !p.posted && (p.at || 0) >= (since || 0))
|
||||||
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
|
.sort((a, b) => (a.paidAt || a.at) - (b.paidAt || b.at)).slice(0, Math.max(1, limit || 15));
|
||||||
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
|
}
|
||||||
}
|
function mark(id, patch) { return store.update('payouts', [], all => { const p = all.find(x => x.id === id); if (p) Object.assign(p, patch); return all; }); }
|
||||||
|
|
||||||
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
|
// the day's pool: committed POL (paid, sent, due, queued; never failed) against the cap, and when it
|
||||||
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
|
// resets: the next midnight in Central time, found by bisection on the day key (never a UTC midnight)
|
||||||
function totals() {
|
function nextResetAt(now) {
|
||||||
const all = store.read('payouts', []);
|
const t0 = now || Date.now(); const today = ctDay(t0);
|
||||||
const paid = all.filter(p => p.status === 'paid');
|
let lo = t0, hi = t0 + 26 * 3600000;
|
||||||
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
|
while (hi - lo > 1000) { const mid = Math.floor((lo + hi) / 2); if (ctDay(mid) === today) lo = mid; else hi = mid; }
|
||||||
}
|
return hi;
|
||||||
|
}
|
||||||
module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted };
|
function pool() {
|
||||||
|
const s = settings(); const day = ctDay(); const today = paidToday(day); const cap = Number(s.dailyCapPol);
|
||||||
|
return { capPol: cap, todayPol: Math.round(today * 10000) / 10000, spent: today >= cap - 1e-9, resetsAt: nextResetAt() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ledger(n) { return store.read('payouts', []).filter(p => p.status === 'paid').sort((a, b) => b.paidAt - a.paidAt).slice(0, n || 50); }
|
||||||
|
function mine(memberId) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId)).sort((a, b) => b.at - a.at); }
|
||||||
|
function totals() {
|
||||||
|
const all = store.read('payouts', []);
|
||||||
|
const paid = all.filter(p => p.status === 'paid');
|
||||||
|
return { paid: paid.length, pol: Math.round(paid.reduce((n, p) => n + p.pol, 0) * 10000) / 10000, queued: all.filter(p => p.status === 'queued').length, today: paidToday(ctDay()), hunters: new Set(paid.map(p => p.memberId)).size };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { settings, setSettings, draw, grant, completed, doneToday, payable, mark, ledger, mine, totals, paidToday, pool, nextResetAt, daily, findsToday, unposted };
|
||||||
|
|||||||
@@ -1,335 +1,335 @@
|
|||||||
// PolHunter: gamified visits across the network, paid in POL.
|
// PolHunter: gamified visits across the network, paid in POL.
|
||||||
//
|
//
|
||||||
// Three gates, all default-deny, all from the environment. A missing variable means silence:
|
// 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.
|
// 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.
|
// 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
|
// 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
|
// InstantAdPay dashboard (lib/sso.js). This gate controls whether that
|
||||||
// hand-off is accepted, so the whole thing can be shut with one variable.
|
// 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>.
|
// 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.
|
// Faucet: HUNT_WALLET_KEY + HUNT_RPC (+ HUNT_CHAIN_ID). Admin: ADMIN_KEY. Hand-off: HUNT_SSO_SECRET.
|
||||||
'use strict';
|
'use strict';
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const store = require('./lib/store');
|
const store = require('./lib/store');
|
||||||
const sso = require('./lib/sso');
|
const sso = require('./lib/sso');
|
||||||
const missions = require('./lib/missions');
|
const missions = require('./lib/missions');
|
||||||
const rewards = require('./lib/rewards');
|
const rewards = require('./lib/rewards');
|
||||||
const social = require('./lib/social');
|
const social = require('./lib/social');
|
||||||
const badge = require('./lib/badge');
|
const badge = require('./lib/badge');
|
||||||
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts
|
||||||
const faucet = require('./lib/faucet');
|
const faucet = require('./lib/faucet');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000);
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const PUBLIC_DIR = path.join(__dirname, 'public');
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
||||||
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
|
badge.init({ publicDir: PUBLIC_DIR, dataDir: process.env.DATA_DIR || path.join(__dirname, 'data') });
|
||||||
const CURTAIN = String(process.env.CURTAIN || '').trim();
|
const CURTAIN = String(process.env.CURTAIN || '').trim();
|
||||||
// HUNT_LIVE_AT (ISO 8601): until then the curtain stays up and nothing goes out, whatever OUTBOUND
|
// 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.
|
// 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;
|
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; }
|
function live() { return !LIVE_AT || Date.now() >= LIVE_AT; }
|
||||||
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
|
const ADMIN_KEY = String(process.env.ADMIN_KEY || '').trim();
|
||||||
const SITE = String(process.env.SITE_URL || 'https://polhunter.com').replace(/\/+$/, '');
|
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 outbound = () => process.env.OUTBOUND === 'on' && live(); // nothing leaves before the live moment
|
||||||
const signupsOpen = () => process.env.SIGNUPS === 'open';
|
const signupsOpen = () => process.env.SIGNUPS === 'open';
|
||||||
|
|
||||||
store.init(DATA_DIR);
|
store.init(DATA_DIR);
|
||||||
const faucetOn = faucet.init();
|
const faucetOn = faucet.init();
|
||||||
|
|
||||||
// ---- Telegram (outward: gated) ------------------------------------------------------------
|
// ---- Telegram (outward: gated) ------------------------------------------------------------
|
||||||
async function telegram(text) {
|
async function telegram(text) {
|
||||||
if (!outbound()) return false; // the gate
|
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;
|
const tok = process.env.HUNT_TG_TOKEN, chat = process.env.HUNT_TG_CHAT, topic = process.env.HUNT_TG_TOPIC;
|
||||||
if (!tok || !chat) return false;
|
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) } : {}));
|
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; }
|
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) {
|
async function telegramPhoto(file, caption, general) {
|
||||||
if (!outbound()) return false; // the gate
|
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;
|
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;
|
if (!tok || !chat || !file) return false;
|
||||||
try {
|
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));
|
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');
|
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;
|
const r = await fetch('https://api.telegram.org/bot' + tok + '/sendPhoto', { method: 'POST', body: fd }); return r.ok;
|
||||||
} catch (e) { return false; }
|
} catch (e) { return false; }
|
||||||
}
|
}
|
||||||
const fmt = n => Number(n).toLocaleString('en-US', { maximumFractionDigits: 4 });
|
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
|
// keys being posted right now, so the claim path and the sweep never post the same thing twice
|
||||||
const postingNow = new Set();
|
const postingNow = new Set();
|
||||||
async function notify(kind, p) {
|
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 === '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); } }
|
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);
|
return notifyOther(kind, p);
|
||||||
}
|
}
|
||||||
async function notifyPaid(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; }
|
{ 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) {
|
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 === '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 === '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
|
// 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>'); }
|
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
|
// 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 === '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>'); }
|
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;
|
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 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) {
|
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; }
|
{ 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;
|
return false;
|
||||||
}
|
}
|
||||||
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
|
// 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 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.';
|
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 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'; }
|
function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; }
|
||||||
|
|
||||||
// ---- helpers -------------------------------------------------------------------------------
|
// ---- 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 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' };
|
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 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) {
|
function sendFile(res, file, extra) {
|
||||||
// video streams with byte ranges (iOS Safari refuses to play without 206 support)
|
// video streams with byte ranges (iOS Safari refuses to play without 206 support)
|
||||||
if (path.extname(file) === '.mp4') return sendRange(res, file, extra);
|
if (path.extname(file) === '.mp4') return sendRange(res, file, extra);
|
||||||
fs.readFile(file, (err, buf) => {
|
fs.readFile(file, (err, buf) => {
|
||||||
if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
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.writeHead(200, Object.assign({ 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }, SEC, extra || {}));
|
||||||
res.end(buf);
|
res.end(buf);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function sendRange(res, file, extra) {
|
function sendRange(res, file, extra) {
|
||||||
fs.stat(file, (err, st) => {
|
fs.stat(file, (err, st) => {
|
||||||
if (err || !st.isFile()) { res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
|
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 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 || {});
|
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;
|
let start = 0, end = size - 1, code = 200;
|
||||||
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
const m = range && /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||||
if (m) {
|
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);
|
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(); }
|
if (start > end || start >= size) { res.writeHead(416, { 'Content-Range': 'bytes */' + size }); return res.end(); }
|
||||||
code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size;
|
code = 206; head['Content-Range'] = 'bytes ' + start + '-' + end + '/' + size;
|
||||||
}
|
}
|
||||||
head['Content-Length'] = end - start + 1;
|
head['Content-Length'] = end - start + 1;
|
||||||
res.writeHead(code, head);
|
res.writeHead(code, head);
|
||||||
if (res.req && res.req.method === 'HEAD') return res.end();
|
if (res.req && res.req.method === 'HEAD') return res.end();
|
||||||
fs.createReadStream(file, { start, end }).pipe(res);
|
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({}); } }); }); }
|
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();
|
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; }
|
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 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>`;
|
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) {
|
function curtained(req, res, u) {
|
||||||
if (!CURTAIN || (LIVE_AT && live())) return false; // the launch moment lifts the curtain
|
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; }
|
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 || '');
|
const m = /(?:^|;\s*)ph\.pass=([^;]*)/.exec(req.headers.cookie || '');
|
||||||
if (m && decodeURIComponent(m[1]) === CURTAIN) return false;
|
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;
|
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))); }
|
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() });
|
const pubMission = m => ({ id: m.id, site: m.site, name: m.name, brief: m.brief, dwell: m.dwell || 30, reward: rewards.settings() });
|
||||||
|
|
||||||
// ---- the server ------------------------------------------------------------------------------
|
// ---- the server ------------------------------------------------------------------------------
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const u = new URL(req.url, 'http://x'); const p = u.pathname;
|
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 });
|
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
|
// 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)
|
// answer only to the mission's own origin (CORS is the second lock, missions.codeForEmbed the first)
|
||||||
if (p === '/api/embed/code') {
|
if (p === '/api/embed/code') {
|
||||||
const origin = String(req.headers.origin || '');
|
const origin = String(req.headers.origin || '');
|
||||||
const r = missions.codeForEmbed(u.searchParams.get('t'), origin);
|
const r = missions.codeForEmbed(u.searchParams.get('t'), origin);
|
||||||
const cors = r.error === 'origin' ? {} : { 'Access-Control-Allow-Origin': origin, 'Vary': '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 (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);
|
if (limited('embed:' + ip(req), 120, 60000)) return json(res, 429, { error: 'slow down' }, cors);
|
||||||
return json(res, r.error ? 403 : 200, r, cors);
|
return json(res, r.error ? 403 : 200, r, cors);
|
||||||
}
|
}
|
||||||
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
|
if (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
|
// 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 (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' });
|
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);
|
{ 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) {
|
if (bm) {
|
||||||
const who = bm[1], id = bm[2]; const m = social.memberByName(who); const b = social.BADGES.find(x => x.id === id);
|
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 (!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' }); }
|
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 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 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 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 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">'
|
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 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>'
|
+ '<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>'
|
+ '<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>'
|
+ '<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>'
|
+ '<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>';
|
+ '<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);
|
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
|
// 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 (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;
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// the ref; the cookie rides on the same response.
|
||||||
if (p === '/' && u.searchParams.get('r')) {
|
if (p === '/' && u.searchParams.get('r')) {
|
||||||
const r = String(u.searchParams.get('r')).trim().slice(0, 40);
|
const r = String(u.searchParams.get('r')).trim().slice(0, 40);
|
||||||
const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r);
|
const ok = /^[A-Za-z0-9_.-]{1,40}$/.test(r);
|
||||||
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8');
|
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) + '">');
|
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);
|
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';
|
if (ok) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
|
||||||
res.writeHead(200, h); return res.end(html);
|
res.writeHead(200, h); return res.end(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- sign-in by hand-off from InstantAdPay
|
// ---- sign-in by hand-off from InstantAdPay
|
||||||
if (p === '/auth') {
|
if (p === '/auth') {
|
||||||
if (!signupsOpen()) { res.writeHead(503, { 'Content-Type': 'text/plain' }); return res.end('PolHunter is not accepting hunters yet.'); }
|
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'));
|
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>'); }
|
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);
|
const cur = sso.fromRequest(req);
|
||||||
let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null;
|
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);
|
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
|
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();
|
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(); }
|
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
|
// ---- 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/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/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/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 === '/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 === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
|
||||||
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.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 })) }); }
|
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)
|
// ---- hunter (session required)
|
||||||
if (p.startsWith('/api/my/')) {
|
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.' });
|
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') {
|
if (p === '/api/my/board') {
|
||||||
referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too
|
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
|
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),
|
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') },
|
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' },
|
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' },
|
||||||
referral: referrals.state(me) });
|
referral: referrals.state(me) });
|
||||||
}
|
}
|
||||||
if (p === '/api/my/start' && req.method === 'POST') {
|
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.' });
|
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 (!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.' });
|
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 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 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.' });
|
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);
|
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
|
// 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=
|
// ?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
|
// 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;
|
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 });
|
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') {
|
if (p === '/api/my/submit' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' });
|
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 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.' });
|
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.' });
|
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 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 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 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);
|
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
|
// whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new
|
||||||
let refDrips = [];
|
let refDrips = [];
|
||||||
try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); }
|
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(() => {});
|
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)
|
// 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));
|
const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id));
|
||||||
for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {});
|
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, 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.' });
|
return json(res, 404, { error: 'No such call.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- admin (key)
|
// ---- admin (key)
|
||||||
if (p.startsWith('/api/admin/')) {
|
if (p.startsWith('/api/admin/')) {
|
||||||
if (!admin(req)) return json(res, 401, { error: 'Admin key required.' });
|
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/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') {
|
if (p === '/api/admin/mission' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
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' });
|
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' }); }
|
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)
|
// 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(/\/.*$/, '');
|
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 });
|
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() });
|
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/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) }); }
|
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
|
// 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/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/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/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
|
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 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 });
|
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.' });
|
return json(res, 404, { error: 'No such admin call.' });
|
||||||
}
|
}
|
||||||
if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html'));
|
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 === '/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.' });
|
if (p.startsWith('/api/')) return json(res, 404, { error: 'No such call.' });
|
||||||
|
|
||||||
const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, '');
|
const safe = path.normalize(p).replace(/^(\.\.[/\\])+/, '');
|
||||||
const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe);
|
const file = path.join(PUBLIC_DIR, safe === '/' || safe === '\\' ? 'index.html' : safe);
|
||||||
if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); }
|
if (!file.startsWith(PUBLIC_DIR)) { res.writeHead(400); return res.end(); }
|
||||||
return sendFile(res, file);
|
return sendFile(res, file);
|
||||||
} catch (e) { console.error(req.method, req.url, e.message); try { json(res, 500, { error: 'Internal server error' }); } catch (x) {} }
|
} 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)
|
// 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);
|
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
|
// 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).
|
// 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.
|
// 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;
|
const POST_SINCE = process.env.HUNT_POST_SINCE ? Date.parse(process.env.HUNT_POST_SINCE) : 0;
|
||||||
let sweeping = false;
|
let sweeping = false;
|
||||||
async function postMissedFinds() {
|
async function postMissedFinds() {
|
||||||
if (sweeping || !outbound()) return 0; sweeping = true; let n = 0;
|
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; } }
|
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; }
|
catch (e) { console.error('find sweep', e.message); } finally { sweeping = false; }
|
||||||
if (n) console.log('posted', n, 'missed find(s) to Telegram');
|
if (n) console.log('posted', n, 'missed find(s) to Telegram');
|
||||||
await postMissedBadges().catch(e => console.error('badge sweep', e.message));
|
await postMissedBadges().catch(e => console.error('badge sweep', e.message));
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart)
|
// badges of every hunter active since the cutoff that were never posted (gated, Telegram down, restart)
|
||||||
async function postMissedBadges() {
|
async function postMissedBadges() {
|
||||||
if (!outbound()) return 0;
|
if (!outbound()) return 0;
|
||||||
const since = POST_SINCE; const recs = store.read('payouts', []).filter(x => (x.at || 0) >= since && x.status !== 'failed');
|
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 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;
|
const posted = store.read('badges-posted', {}); let n = 0;
|
||||||
for (const me of seen.values()) {
|
for (const me of seen.values()) {
|
||||||
const have = new Set(posted[String(me.memberId)] || []);
|
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; }
|
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');
|
if (n) console.log('posted', n, 'missed badge(s) to Telegram');
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
setInterval(() => postMissedFinds().catch(() => {}), 2 * 60000); setTimeout(() => postMissedFinds().catch(() => {}), 20000);
|
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
|
// 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);
|
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'}`));
|
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