Files
polhunter/lib/missions.js
T

82 lines
4.9 KiB
JavaScript

// Missions, tokens and proof codes.
//
// A mission: { id, site, host, name, brief, url, dwell, reward: {min, max}, budget, active, created }
// host the origin the embed must call from (e.g. instantadpay.com); the proof only ever
// answers a request whose Origin header is that host
// brief what the hunter is told ("Open the ledger and find your code next to the newest payout")
// url where the mission link sends them; the token is appended as ?ph=<token>
// dwell seconds on the site before the code will render
// budget completions this mission will still pay (0 = unlimited)
//
// A token is issued when a hunter starts a mission: { t, memberId, missionId, iat, exp }. The
// code they must type is derived from the token with the server secret, so it is unique to that
// member and that sitting, worthless to anyone else, and different tomorrow. The hiding place
// (which page / which slot the embed renders in) is also derived, from the day and the member.
'use strict';
const crypto = require('crypto');
const store = require('./store');
const SECRET = String(process.env.HUNT_CODE_SECRET || process.env.HUNT_SSO_SECRET || '').trim();
const TOKEN_TTL = 20 * 60000;
const h = s => crypto.createHmac('sha256', SECRET).update(s).digest();
const ctDay = t => new Date(t === undefined ? Date.now() : t).toLocaleDateString('en-CA', { timeZone: 'America/Chicago' });
function list() { return store.read('missions', []); }
function get(id) { return list().find(m => m.id === id) || null; }
function save(m) {
return store.update('missions', [], all => {
const i = all.findIndex(x => x.id === m.id);
if (i >= 0) all[i] = Object.assign(all[i], m); else all.push(Object.assign({ created: Date.now(), active: true }, m));
return all;
});
}
function remove(id) { return store.update('missions', [], all => all.filter(m => m.id !== id)); }
// each hunter sees the active missions in their own order, so two people comparing notes are
// not looking at the same list; the order is stable for a member within a day
function forMember(memberId) {
const day = ctDay();
return list().filter(m => m.active).map(m => ({ m, k: h(day + ':' + memberId + ':' + m.id).readUInt32BE(0) }))
.sort((a, b) => a.k - b.k).map(x => x.m);
}
// ---- tokens ------------------------------------------------------------------------------
function issue(memberId, missionId) {
const t = crypto.randomBytes(16).toString('hex');
const rec = { t, memberId: Number(memberId), missionId, iat: Date.now(), exp: Date.now() + TOKEN_TTL };
store.update('tokens', {}, all => { for (const k of Object.keys(all)) if (all[k].exp < Date.now() - 3600000) delete all[k]; all[t] = rec; return all; });
return rec;
}
function token(t) { const rec = store.read('tokens', {})[String(t || '')]; return rec && rec.exp > Date.now() ? rec : null; }
// the proof: six characters from HMAC(secret, token); shown by the embed, typed by the hunter
function codeFor(rec) { return h('code:' + rec.t + ':' + rec.memberId + ':' + rec.missionId).toString('hex').slice(0, 6).toUpperCase(); }
// the hiding place: which slot on the page the embed renders in, by day and member
function slotFor(rec, slots) { const n = Math.max(1, Number(slots) || 1); return h('slot:' + ctDay() + ':' + rec.memberId + ':' + rec.missionId).readUInt32BE(0) % n; }
// The embed asks for the code. Answered only when: the token is live, the request's Origin is the
// mission's host, and the dwell has passed since the token was issued. Anything else is silence.
function codeForEmbed(t, origin) {
const rec = token(t); if (!rec) return { error: 'expired' };
const m = get(rec.missionId); if (!m || !m.active) return { error: 'gone' };
const host = String(origin || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/:\d+$/, '').toLowerCase(); // Origin carries a port; the mission host never does
const allowed = [m.host, 'www.' + m.host].map(x => x.toLowerCase());
if (!allowed.includes(host)) return { error: 'origin' };
const waited = (Date.now() - rec.iat) / 1000;
if (waited < (m.dwell || 30)) return { wait: Math.ceil((m.dwell || 30) - waited) };
return { code: codeFor(rec), slot: slotFor(rec, m.slots || 1) };
}
// The hunter submits what they found.
function check(t, memberId, typed) {
const rec = token(t); if (!rec) return { error: 'That mission timed out. Start it again from your board.' };
if (rec.memberId !== Number(memberId)) return { error: 'That mission belongs to a different account.' };
const want = codeFor(rec);
const got = String(typed || '').trim().toUpperCase();
if (got.length !== 6) return { error: 'Codes are 6 characters and you typed ' + got.length + '. Look at the site again: the code sits in six boxes, and a tap on it copies all six.' };
if (got !== want) return { error: 'That is not the code. It is on the site, and it is yours alone.' };
return { ok: true, rec };
}
module.exports = { list, get, save, remove, forMember, issue, token, codeForEmbed, check, ctDay };