diff --git a/Dockerfile b/Dockerfile index 7057669..4dc953c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM node:22-alpine WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund COPY . . RUN mkdir -p /app/data ENV NODE_ENV=production diff --git a/accounts.js b/accounts.js index bbb538a..3e88f33 100644 --- a/accounts.js +++ b/accounts.js @@ -1,44 +1,16 @@ -// Site-side member accounts for InstantAdPay. -// The chain is the source of truth for money, credits, and qualification; -// this module holds what the chain doesn't: free members (email + password, -// the way normal people join), sponsor attribution before first purchase -// (spec §4), and the wallet link once one is connected at purchase time. -// Wiping this file = the clean reset between rehearsal and mainnet. +// Site-side member accounts. Dual-mode: +// MySQL (db.enabled) for real concurrency in production, +// JSON volume file as the no-DATABASE_URL fallback (local dev). +// All exported functions are async; both modes return identical shapes. +// The chain remains the source of truth for money/credits/qualification. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const db = require('./db'); let DATA_DIR = null; -const FILE = () => path.join(DATA_DIR, 'accounts.json'); -let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 }; -function load() { - try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} - if (!db || !db.v) db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 }; - if (db.v === 1) { db.v = 2; db.byEmail = db.byEmail || {}; } // early rehearsal file - if (!db.byCode) db.byCode = {}; - // every account carries a share code from day one (backfill older records) - for (const a of Object.values(db.byEmail)) { - if (!a.code) { a.code = genCode(); db.byCode[a.code] = a.email; } - else if (!db.byCode[a.code]) db.byCode[a.code] = a.email; - } -} -function genCode() { - let c; - do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); } - while (!c || c.length < 6 || (db.byCode && db.byCode[c]) || /^\d+$/.test(c)); - return c; -} -function save() { - try { - const tmp = FILE() + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(db), { mode: 0o600 }); - fs.renameSync(tmp, FILE()); - } catch (e) { console.error('accounts save failed', e.message); } -} -function init(opts) { DATA_DIR = opts.dataDir; load(); } - -// ---- password hashing (scrypt, no deps) ---- +// ---- shared helpers ---- function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(String(password), salt, 32); @@ -51,85 +23,154 @@ function checkPassword(password, stored) { return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch (e) { return false; } } - const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const normEmail = e => String(e || '').trim().toLowerCase(); const normAddr = a => String(a || '').trim().toLowerCase(); +function newCode(taken) { + let c; + do { c = crypto.randomBytes(5).toString('base64url').replace(/[-_]/g, '').slice(0, 7).toLowerCase(); } + while (!c || c.length < 6 || /^\d+$/.test(c) || (taken && taken(c))); + return c; +} +const pub = a => a ? { email: a.email, sponsorRef: a.sponsorRef || '', code: a.code || null, + address: a.address || null, created: a.created } : null; -// ---- email accounts (the normal join path) ---- -function signup(email, password, sponsorRef) { +// ---- JSON fallback ---- +const J = { + db: { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 }, + FILE: () => path.join(DATA_DIR, 'accounts.json'), + load() { + try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {} + if (!this.db || !this.db.v) this.db = { v: 2, byEmail: {}, byAddress: {}, byCode: {}, joins: 0 }; + if (!this.db.byCode) this.db.byCode = {}; + for (const a of Object.values(this.db.byEmail)) { + if (!a.code) { a.code = newCode(c => this.db.byCode[c]); this.db.byCode[a.code] = a.email; } + else if (!this.db.byCode[a.code]) this.db.byCode[a.code] = a.email; + } + }, + save() { + try { + const tmp = this.FILE() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(this.db), { mode: 0o600 }); + fs.renameSync(tmp, this.FILE()); + } catch (e) { console.error('accounts save failed', e.message); } + }, + async signup(e, password, ref) { + if (this.db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' }; + const code = newCode(c => this.db.byCode[c]); + this.db.byEmail[e] = { email: e, pass: hashPassword(password), sponsorRef: ref, code, address: null, created: Date.now() }; + this.db.byCode[code] = e; + this.save(); + return { ok: true, created: true, account: pub(this.db.byEmail[e]) }; + }, + async login(e, password) { + const a = this.db.byEmail[e]; + if (!a || !a.pass || !checkPassword(password, a.pass)) return { error: 'Wrong email or password.' }; + return { ok: true, account: pub(a) }; + }, + async ensure(e, ref) { + let created = false; + if (!this.db.byEmail[e]) { + const code = newCode(c => this.db.byCode[c]); + this.db.byEmail[e] = { email: e, pass: null, sponsorRef: ref, code, address: null, created: Date.now() }; + this.db.byCode[code] = e; + created = true; + this.save(); + } + return { ok: true, created, account: pub(this.db.byEmail[e]) }; + }, + async byEmail(e) { return pub(this.db.byEmail[e]); }, + async byAddress(a) { const e = this.db.byAddress[a]; return e ? pub(this.db.byEmail[e]) : null; }, + async byCode(c) { const e = this.db.byCode[c]; return e ? pub(this.db.byEmail[e]) : null; }, + async linkWallet(e, a) { + const acct = this.db.byEmail[e]; + if (!acct) return { error: 'No such account.' }; + if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet ' + + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Connect that wallet instead.' }; + if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' }; + acct.address = a; + this.db.byAddress[a] = e; + this.save(); + return { ok: true, account: pub(acct) }; + }, + async count() { return Object.keys(this.db.byEmail).length; } +}; + +// ---- MySQL mode ---- +const rowPub = r => r ? pub({ email: r.email, sponsorRef: r.sponsor_ref, code: r.code, address: r.address, created: Number(r.created) }) : null; +const D = { + async signup(e, password, ref) { + const code = newCode(); + try { + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,NULL,?)', + [e, hashPassword(password), ref, code, Date.now()]); + } catch (err) { + if (err.code === 'ER_DUP_ENTRY') return String(err.message).includes('code') + ? this.signup(e, password, ref) // code collision: retry with a new code + : { error: 'That email already has an account. Log in instead.' }; + throw err; + } + return { ok: true, created: true, account: await this.byEmail(e) }; + }, + async login(e, password) { + const rows = await db.q('SELECT * FROM accounts WHERE email=?', [e]); + if (!rows.length || !rows[0].pass || !checkPassword(password, rows[0].pass)) return { error: 'Wrong email or password.' }; + return { ok: true, account: rowPub(rows[0]) }; + }, + async ensure(e, ref) { + const code = newCode(); + let created = false; + try { + await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,NULL,?,?,NULL,?)', + [e, ref, code, Date.now()]); + created = true; + } catch (err) { + if (err.code !== 'ER_DUP_ENTRY') throw err; + if (String(err.message).includes('code')) return this.ensure(e, ref); + } + return { ok: true, created, account: await this.byEmail(e) }; + }, + async byEmail(e) { const r = await db.q('SELECT * FROM accounts WHERE email=?', [e]); return rowPub(r[0]); }, + async byAddress(a) { const r = await db.q('SELECT * FROM accounts WHERE address=?', [a]); return rowPub(r[0]); }, + async byCode(c) { const r = await db.q('SELECT * FROM accounts WHERE code=?', [c]); return rowPub(r[0]); }, + async linkWallet(e, a) { + const cur = await this.byEmail(e); + if (!cur) return { error: 'No such account.' }; + if (cur.address && cur.address !== a) return { error: 'This account is already linked to wallet ' + + cur.address.slice(0, 6) + '…' + cur.address.slice(-4) + '. Connect that wallet instead.' }; + try { await db.q('UPDATE accounts SET address=? WHERE email=?', [a, e]); } + catch (err) { + if (err.code === 'ER_DUP_ENTRY') return { error: 'That wallet is already linked to a different account.' }; + throw err; + } + return { ok: true, account: await this.byEmail(e) }; + }, + async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); } +}; + +const impl = () => db.enabled() ? D : J; +function init(opts) { DATA_DIR = opts.dataDir; J.load(); } + +async function signup(email, password, sponsorRef) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; if (String(password || '').length < 8) return { error: 'Password needs at least 8 characters.' }; - if (db.byEmail[e]) return { error: 'That email already has an account. Log in instead.' }; - const code = genCode(); - db.byEmail[e] = { - email: e, - pass: hashPassword(password), - sponsorRef: String(sponsorRef || ''), // first touch; resolved to a chain id at buy time - code, - address: null, - created: Date.now() - }; - db.byCode[code] = e; - db.joins += 1; - save(); - return { ok: true, created: true, account: publicView(db.byEmail[e]) }; + return impl().signup(e, String(password), String(sponsorRef || '')); } -function login(email, password) { - const e = normEmail(email); - const acct = db.byEmail[e]; - if (!acct || !checkPassword(password, acct.pass)) return { error: 'Wrong email or password.' }; - acct.lastSeen = Date.now(); save(); - return { ok: true, account: publicView(acct) }; -} -// Passwordless path: a verified email code proves ownership, so the account -// may exist with no password at all. -function ensure(email, sponsorRef) { +async function login(email, password) { return impl().login(normEmail(email), String(password || '')); } +async function ensure(email, sponsorRef) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; - let created = false; - if (!db.byEmail[e]) { - const code = genCode(); - db.byEmail[e] = { email: e, pass: null, sponsorRef: String(sponsorRef || ''), code, address: null, created: Date.now() }; - db.byCode[code] = e; - db.joins += 1; - created = true; - save(); - } - return { ok: true, created, account: publicView(db.byEmail[e]) }; + return impl().ensure(e, String(sponsorRef || '')); } -function byCode(code) { - const e = db.byCode[String(code || '').toLowerCase()]; - return e ? publicView(db.byEmail[e]) : null; -} -function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; } -function byAddress(address) { - const e = db.byAddress[normAddr(address)]; - return e ? publicView(db.byEmail[e]) : null; -} - -// ---- wallet link (happens at purchase / payout activation time) ---- -// First link wins and is permanent for the account; one wallet, one account. -function linkWallet(email, address) { - const e = normEmail(email); +async function byEmail(email) { return impl().byEmail(normEmail(email)); } +async function byAddress(address) { return impl().byAddress(normAddr(address)); } +async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); } +async function linkWallet(email, address) { const a = normAddr(address); - const acct = db.byEmail[e]; - if (!acct) return { error: 'No such account.' }; if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' }; - if (acct.address && acct.address !== a) return { error: 'This account is already linked to wallet ' - + acct.address.slice(0, 6) + '…' + acct.address.slice(-4) + '. Earnings pay to that wallet. Connect it instead.' }; - if (db.byAddress[a] && db.byAddress[a] !== e) return { error: 'That wallet is already linked to a different account.' }; - acct.address = a; - db.byAddress[a] = e; - save(); - return { ok: true, account: publicView(acct) }; + return impl().linkWallet(normEmail(email), a); } - -function publicView(a) { - return { email: a.email, sponsorRef: a.sponsorRef || String(a.sponsorId || '') || '', - code: a.code || null, address: a.address || null, created: a.created }; -} -function count() { return Object.keys(db.byEmail).length; } +async function count() { return impl().count(); } module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, linkWallet, count }; diff --git a/ads.js b/ads.js index 9b7e950..bd51794 100644 --- a/ads.js +++ b/ads.js @@ -1,61 +1,41 @@ -// Ad engine v1 (spec §8b): banners, text ads, login ads served against -// PURCHASED on-chain credits. The chain is the money truth: spend accrues -// here, and burns are queued for the engine signer to consume() on-chain -// (admin runs the burner; see /api/admin/burns). Earned-credit pool and the -// richer §8b types (inbox, surf, rotation, directory) come later. +// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js: +// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path, +// JSON volume file fallback for local dev. +// Spend accrues per campaign; burns queue for the engine signer to consume() +// on-chain (/api/admin/burns). All exported functions are async. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const db = require('./db'); let DATA_DIR = null; let chain = null; -const FILE = () => path.join(DATA_DIR, 'campaigns.json'); const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json'); -let db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] }; - // REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates. -// Integer math: impressions accrue per campaign; credits deduct per BATCH. function rates() { let saved = {}; try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {} return Object.assign({ - bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent - textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent - loginCreditsPerDay: 100, // $1.00/day - burnBatchMin: 50 // queue a burn every 50cr of spend + bannerBatch: 10, bannerCreditsPerBatch: 2, + textBatch: 10, textCreditsPerBatch: 1, + loginCreditsPerDay: 100, + burnBatchMin: 50 }, saved); } - -function load() { - try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {} - if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] }; +function setRates(patch) { + fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2)); + return rates(); } -function save() { - try { - const tmp = FILE() + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(db)); - fs.renameSync(tmp, FILE()); - } catch (e) { console.error('ads save failed', e.message); } -} -function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; load(); } const TYPES = ['banner', 'text', 'login']; const URL_RE = /^https?:\/\/[^\s]+$/i; - -// unburned spend per member = credits owed to the burn queue + campaign accruals -function unburnedSpend(memberId) { - let s = 0; - for (const b of db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount; - for (const c of db.campaigns) if (c.memberId === memberId) s += c.accrued || 0; - return s; +const bid = () => crypto.randomBytes(8).toString('hex'); +function batchFor(type, r) { + return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch } + : type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null; } -async function availableCredits(memberId) { - const onchain = await chain.creditBalance(memberId, 0); - return Math.max(0, onchain - unburnedSpend(memberId)); -} - -async function createCampaign(owner, memberId, input) { +function validate(input) { const type = String(input.type || ''); if (!TYPES.includes(type)) return { error: 'Unknown ad type.' }; const name = String(input.name || '').trim().slice(0, 60); @@ -64,122 +44,226 @@ async function createCampaign(owner, memberId, input) { if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' }; const budget = Math.floor(Number(input.budget) || 0); if (budget < 10) return { error: 'Minimum budget is 10 credits.' }; - const avail = await availableCredits(memberId); - if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; - - const c = { id: db.nextId++, owner, memberId, type, name, targetUrl, - budget, spent: 0, accrued: 0, imps: 0, clicks: 0, batchImps: 0, - status: 'active', created: Date.now() }; - if (type === 'banner') { - const imageUrl = String(input.imageUrl || '').trim(); - if (!URL_RE.test(imageUrl)) return { error: 'Banner image URL must start with http(s)://' }; - c.imageUrl = imageUrl; + const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null }; + if (type === 'banner' || type === 'login') { + out.imageUrl = String(input.imageUrl || '').trim(); + if (!URL_RE.test(out.imageUrl)) return { error: (type === 'banner' ? 'Banner' : 'Login') + ' ads need an image URL starting with http(s)://' }; } if (type === 'text') { - c.title = String(input.title || '').trim().slice(0, 60); - c.body = String(input.body || '').trim().slice(0, 140); - if (!c.title) return { error: 'Text ads need a headline.' }; + out.title = String(input.title || '').trim().slice(0, 60); + out.body = String(input.body || '').trim().slice(0, 140); + if (!out.title) return { error: 'Text ads need a headline.' }; } - if (type === 'login') { - c.imageUrl = String(input.imageUrl || '').trim(); - if (!URL_RE.test(c.imageUrl)) return { error: 'Login ads need an image URL.' }; - } - db.campaigns.push(c); - save(); - return { ok: true, campaign: pub(c) }; + return { ok: true, c: out }; } +const pubC = c => ({ id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null, + title: c.title || null, body: c.body || null, budget: c.budget, spent: (c.spent || 0) + (c.accrued || 0), + imps: c.imps || 0, clicks: c.clicks || 0, status: c.status, created: c.created }); +const served = c => ({ id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id, + imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null }); -function listCampaigns(owner) { - return db.campaigns.filter(c => c.owner === owner).map(pub); -} -function setStatus(owner, id, status) { - const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner); - if (!c) return { error: 'No such campaign.' }; - if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' }; - if (c.status === 'out' && status === 'active' && c.spent + c.accrued >= c.budget) return { error: 'Budget exhausted. Raise it first.' }; - c.status = status; - save(); - return { ok: true, campaign: pub(c) }; -} -function pub(c) { - return { id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null, - title: c.title || null, body: c.body || null, budget: c.budget, spent: c.spent + (c.accrued || 0), - imps: c.imps, clicks: c.clicks, status: c.status, created: c.created }; -} - -// ---- serving ---- -function chargeBatch(c, batch, credits) { - c.batchImps += 1; - if (c.batchImps >= batch) { - c.batchImps = 0; - c.accrued = (c.accrued || 0) + credits; - // roll accruals into the burn queue in chunks +// ---- JSON fallback ---- +const J = { + db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] }, + FILE: () => path.join(DATA_DIR, 'campaigns.json'), + load() { + try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) {} + if (!this.db || this.db.v !== 1) this.db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] }; + }, + save() { + try { + const tmp = this.FILE() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(this.db)); + fs.renameSync(tmp, this.FILE()); + } catch (e) { console.error('ads save failed', e.message); } + }, + async unburned(memberId) { + let s = 0; + for (const b of this.db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount; + for (const c of this.db.campaigns) if (c.memberId === memberId) s += c.accrued || 0; + return s; + }, + async create(owner, memberId, c) { + const row = Object.assign({ id: this.db.nextId++, owner, memberId, spent: 0, accrued: 0, + imps: 0, clicks: 0, batchImps: 0, status: 'active', created: Date.now() }, c); + this.db.campaigns.push(row); + this.save(); + return pubC(row); + }, + async list(owner) { return this.db.campaigns.filter(c => c.owner === owner).map(pubC); }, + async setStatus(owner, id, status) { + const c = this.db.campaigns.find(x => x.id === Number(id) && x.owner === owner); + if (!c) return { error: 'No such campaign.' }; + c.status = status; + this.save(); + return { ok: true, campaign: pubC(c) }; + }, + async serve(type) { const r = rates(); - if (c.accrued >= r.burnBatchMin) { - db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId, - amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); - c.spent += c.accrued; - c.accrued = 0; + const pool = this.db.campaigns.filter(c => c.type === type && c.status === 'active'); + if (!pool.length) return null; + const c = pool[Math.floor(Math.random() * pool.length)]; + c.imps += 1; + const b = batchFor(type, r); + if (b) { + c.batchImps += 1; + if (c.batchImps >= b.n) { + c.batchImps = 0; + c.accrued = (c.accrued || 0) + b.cr; + if (c.accrued >= r.burnBatchMin) { + this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); + c.spent += c.accrued; c.accrued = 0; + } + if (c.spent + c.accrued >= c.budget) c.status = 'out'; + } } - if (c.spent + c.accrued >= c.budget) c.status = 'out'; - } -} -function serve(type) { - const r = rates(); - const pool = db.campaigns.filter(c => c.type === type && c.status === 'active'); - if (!pool.length) return null; - const c = pool[Math.floor(Math.random() * pool.length)]; - c.imps += 1; - if (type === 'banner') chargeBatch(c, r.bannerBatch, r.bannerCreditsPerBatch); - if (type === 'text') chargeBatch(c, r.textBatch, r.textCreditsPerBatch); - // login ads are per-day; impressions tracked, charged by the daily sweep - save(); - return { id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id, - imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null }; -} -function click(id) { - const c = db.campaigns.find(x => x.id === Number(id)); - if (!c) return null; - c.clicks += 1; - save(); - return c.targetUrl; -} - -// daily charge for login ads (called by a boot + interval sweep) -function dailySweep() { - const r = rates(); - const today = new Date().toISOString().slice(0, 10); - let charged = 0; - for (const c of db.campaigns) { - if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue; - c.lastDayCharged = today; - c.accrued = (c.accrued || 0) + r.loginCreditsPerDay; - if (c.accrued >= r.burnBatchMin) { - db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId, - amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); - c.spent += c.accrued; c.accrued = 0; + this.save(); + return served(c); + }, + async click(id) { + const c = this.db.campaigns.find(x => x.id === Number(id)); + if (!c) return null; + c.clicks += 1; + this.save(); + return c.targetUrl; + }, + async dailySweep() { + const r = rates(); + const today = new Date().toISOString().slice(0, 10); + let n = 0; + for (const c of this.db.campaigns) { + if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue; + c.lastDayCharged = today; + c.accrued = (c.accrued || 0) + r.loginCreditsPerDay; + if (c.accrued >= r.burnBatchMin) { + this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() }); + c.spent += c.accrued; c.accrued = 0; + } + if (c.spent + c.accrued >= c.budget) c.status = 'out'; + n += 1; } - if (c.spent + c.accrued >= c.budget) c.status = 'out'; - charged += 1; + if (n) this.save(); + return n; + }, + async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); }, + async markBurned(id, tx) { + const b = this.db.burnsPending.find(x => x.id === id); + if (!b) return { error: 'No such burn.' }; + b.burnedTx = tx; b.burnedAt = Date.now(); + this.save(); + return { ok: true }; } - if (charged) save(); - return charged; -} +}; -// ---- burn queue (admin/engine) ---- -function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); } -function markBurned(id, tx) { - const b = db.burnsPending.find(x => x.id === id); - if (!b) return { error: 'No such burn.' }; - b.burnedTx = tx; b.burnedAt = Date.now(); - save(); - return { ok: true }; +// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ---- +const rowC = r => ({ id: r.id, owner: r.owner_email, memberId: r.member_id, type: r.type, name: r.name, + targetUrl: r.target_url, imageUrl: r.image_url, title: r.title, body: r.body, budget: r.budget, + spent: r.spent, accrued: r.accrued, imps: r.imps, clicks: r.clicks, batchImps: r.batch_imps, + status: r.status, created: Number(r.created) }); +const D = { + async unburned(memberId) { + const a = await db.q('SELECT COALESCE(SUM(amount),0) s FROM burns WHERE member_id=? AND burned_tx IS NULL', [memberId]); + const b = await db.q('SELECT COALESCE(SUM(accrued),0) s FROM campaigns WHERE member_id=?', [memberId]); + return Number(a[0].s) + Number(b[0].s); + }, + async create(owner, memberId, c) { + const r = await db.q(`INSERT INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body,budget,created) + VALUES (?,?,?,?,?,?,?,?,?,?)`, + [owner, memberId, c.type, c.name, c.targetUrl, c.imageUrl, c.title, c.body, c.budget, Date.now()]); + const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [r.insertId]); + return pubC(rowC(rows[0])); + }, + async list(owner) { + const rows = await db.q('SELECT * FROM campaigns WHERE owner_email=? ORDER BY id DESC', [owner]); + return rows.map(r => pubC(rowC(r))); + }, + async setStatus(owner, id, status) { + const r = await db.q('UPDATE campaigns SET status=? WHERE id=? AND owner_email=?', [status, Number(id), owner]); + if (!r.affectedRows) return { error: 'No such campaign.' }; + const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [Number(id)]); + return { ok: true, campaign: pubC(rowC(rows[0])) }; + }, + async serve(type) { + const r = rates(); + const rows = await db.q('SELECT * FROM campaigns WHERE type=? AND status=\'active\' ORDER BY RAND() LIMIT 1', [type]); + if (!rows.length) return null; + const c = rowC(rows[0]); + await db.q('UPDATE campaigns SET imps=imps+1, batch_imps=batch_imps+1 WHERE id=?', [c.id]); + const b = batchFor(type, r); + if (b) { + // atomic batch rollover: only one worker wins the WHERE guard + await db.q('UPDATE campaigns SET batch_imps=batch_imps-?, accrued=accrued+? WHERE id=? AND batch_imps>=?', + [b.n, b.cr, c.id, b.n]); + await this.rollBurn(c.id, r); + } + return served(c); + }, + async rollBurn(id, r) { + const rows = await db.q('SELECT * FROM campaigns WHERE id=?', [id]); + if (!rows.length) return; + const c = rowC(rows[0]); + if (c.accrued >= r.burnBatchMin) { + const upd = await db.q('UPDATE campaigns SET spent=spent+?, accrued=accrued-? WHERE id=? AND accrued>=?', + [c.accrued, c.accrued, id, c.accrued]); + if (upd.affectedRows) { + await db.q('INSERT INTO burns (id,member_id,amount,ref,ts) VALUES (?,?,?,?,?)', + [bid(), c.memberId, c.accrued, 'campaign-' + id, Date.now()]); + } + } + await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [id]); + }, + async click(id) { + const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]); + if (!rows.length) return null; + await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]); + return rows[0].target_url; + }, + async dailySweep() { + const r = rates(); + const today = new Date().toISOString().slice(0, 10); + const upd = await db.q(`UPDATE campaigns SET last_day_charged=?, accrued=accrued+? + WHERE type='login' AND status='active' AND (last_day_charged IS NULL OR last_day_charged<>?)`, + [today, r.loginCreditsPerDay, today]); + if (upd.affectedRows) { + const rows = await db.q('SELECT id FROM campaigns WHERE type=\'login\' AND last_day_charged=?', [today]); + for (const row of rows) await this.rollBurn(row.id, r); + } + return upd.affectedRows || 0; + }, + async pendingBurns() { + const rows = await db.q('SELECT * FROM burns WHERE burned_tx IS NULL ORDER BY ts'); + return rows.map(b => ({ id: b.id, memberId: b.member_id, amount: b.amount, ref: b.ref, ts: Number(b.ts) })); + }, + async markBurned(id, tx) { + const r = await db.q('UPDATE burns SET burned_tx=?, burned_at=? WHERE id=? AND burned_tx IS NULL', [String(tx), Date.now(), String(id)]); + return r.affectedRows ? { ok: true } : { error: 'No such burn.' }; + } +}; + +const impl = () => db.enabled() ? D : J; +function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); } + +async function availableCredits(memberId) { + const onchain = await chain.creditBalance(memberId, 0); + return Math.max(0, onchain - await impl().unburned(memberId)); } -function setRates(patch) { - const cur = rates(); - fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2)); - return rates(); +async function createCampaign(owner, memberId, input) { + const v = validate(input); + if (v.error) return v; + const avail = await availableCredits(memberId); + if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; + return { ok: true, campaign: await impl().create(owner, memberId, v.c) }; } +async function listCampaigns(owner) { return impl().list(owner); } +async function setStatus(owner, id, status) { + if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' }; + return impl().setStatus(owner, id, status); +} +async function serve(type) { return TYPES.includes(type) ? impl().serve(type) : null; } +async function click(id) { return impl().click(id); } +async function dailySweep() { return impl().dailySweep(); } +async function pendingBurns() { return impl().pendingBurns(); } +async function markBurned(id, tx) { return impl().markBurned(id, tx); } module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, serve, click, dailySweep, availableCredits, pendingBurns, markBurned }; diff --git a/auth.js b/auth.js index 3ebf88a..5375231 100644 --- a/auth.js +++ b/auth.js @@ -1,15 +1,12 @@ -// Wallet sign-in (SIWE / EIP-4361) for InstantAdPay. -// Pattern lifted from the RM Circle messages.js implementation (proven with -// MetaMask's friendly sign-in UI). One free signature, cannot move funds. -// -// Difference from RM Circle: a wallet WITHOUT an on-chain member id still gets -// a session — free members exist site-side only until their payout activation -// or first purchase writes them on-chain (spec §4). +// Wallet sign-in (SIWE / EIP-4361) + session store for InstantAdPay. +// Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set, +// volume JSON otherwise. All session functions are async. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { keccak256 } = require('./vendor/sha3'); const secp = require('./vendor/secp256k1'); +const db = require('./db'); let DATA_DIR = null; let chain = null; @@ -19,27 +16,76 @@ let SITE = 'instantadpay.com'; const CHALLENGE_TTL = 10 * 60 * 1000; const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days const challenges = new Map(); // addressLower -> {message, exp} -let sessions = new Map(); // token -> {address, memberId, expires} -const SESS_FILE = () => path.join(DATA_DIR, 'sessions.json'); -function loadSessions() { - try { - const o = JSON.parse(fs.readFileSync(SESS_FILE(), 'utf8')); - sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); - } catch (e) { sessions = new Map(); } -} -function saveSessions() { - try { - const tmp = SESS_FILE() + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 }); - fs.renameSync(tmp, SESS_FILE()); - } catch (e) { console.error('session save failed', e.message); } -} +// ---- JSON session fallback ---- +const J = { + sessions: new Map(), + FILE: () => path.join(DATA_DIR, 'sessions.json'), + load() { + try { + const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); + this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now())); + } catch (e) { this.sessions = new Map(); } + }, + save() { + try { + const tmp = this.FILE() + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 }); + fs.renameSync(tmp, this.FILE()); + } catch (e) { console.error('session save failed', e.message); } + }, + async mint(fields) { + const token = crypto.randomBytes(32).toString('hex'); + this.sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields, + { expires: Date.now() + SESSION_TTL })); + this.save(); + return token; + }, + async get(token) { + const s = this.sessions.get(token); + if (!s || s.expires < Date.now()) return null; + return Object.assign({ token }, s); + }, + async update(token, fields) { + const s = this.sessions.get(token); + if (!s) return; + this.sessions.set(token, Object.assign({}, s, fields)); + this.save(); + }, + async drop(token) { this.sessions.delete(token); this.save(); } +}; + +// ---- MySQL session mode ---- +const D = { + async mint(fields) { + const token = crypto.randomBytes(32).toString('hex'); + await db.q('INSERT INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)', + [token, fields.email || null, fields.address || null, fields.memberId || 0, Date.now() + SESSION_TTL]); + return token; + }, + async get(token) { + const rows = await db.q('SELECT * FROM sessions WHERE token=? AND expires>?', [token, Date.now()]); + if (!rows.length) return null; + const r = rows[0]; + return { token, email: r.email, address: r.address, memberId: r.member_id, expires: Number(r.expires) }; + }, + async update(token, fields) { + const sets = [], vals = []; + if ('email' in fields) { sets.push('email=?'); vals.push(fields.email); } + if ('address' in fields) { sets.push('address=?'); vals.push(fields.address); } + if ('memberId' in fields) { sets.push('member_id=?'); vals.push(fields.memberId); } + if (!sets.length) return; + vals.push(token); + await db.q('UPDATE sessions SET ' + sets.join(',') + ' WHERE token=?', vals); + }, + async drop(token) { await db.q('DELETE FROM sessions WHERE token=?', [token]); } +}; +const impl = () => db.enabled() ? D : J; function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd; if (opts.site) SITE = opts.site; - loadSessions(); + J.load(); } // ---- crypto ---- @@ -65,7 +111,7 @@ function checksumAddress(address) { return out; } -// ---- auth flow ---- +// ---- SIWE flow ---- function makeChallenge(address) { if (!ADDR_RE.test(address || '')) return { error: 'Bad address' }; const a = address.toLowerCase(); @@ -86,45 +132,30 @@ async function verifyChallenge(address, signature) { challenges.delete(a); return { ok: true, address: a }; } -// Sessions carry {email, address, memberId} — email accounts are the normal -// join path; the wallet fields fill in when one is linked at purchase time. -function mintSession(fields) { - const token = crypto.randomBytes(32).toString('hex'); - sessions.set(token, Object.assign({ email: null, address: null, memberId: 0 }, fields, - { expires: Date.now() + SESSION_TTL })); - saveSessions(); - return token; -} -function updateSession(token, fields) { - const s = sessions.get(token); - if (!s) return; - sessions.set(token, Object.assign({}, s, fields)); - saveSessions(); -} + +// ---- sessions ---- +async function mintSession(fields) { return impl().mint(fields || {}); } +async function updateSession(token, fields) { return impl().update(token, fields || {}); } function sessionCookie(token) { return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`; } function clearCookie() { return 'iap.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; } -function fromRequest(req) { +async function fromRequest(req) { const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || ''); if (!m) return null; - const token = decodeURIComponent(m[1]); - const s = sessions.get(token); - if (!s || s.expires < Date.now()) return null; - return Object.assign({ token }, s); + return impl().get(decodeURIComponent(m[1])); } async function refreshMemberId(sess) { - // called after an on-chain action so the session learns its new member id - if (!sess.address) return sess.memberId || 0; + if (!sess || !sess.address) return (sess && sess.memberId) || 0; try { const id = await chain.memberIdByAccount(sess.address); - if (id && id !== sess.memberId) updateSession(sess.token, { memberId: id }); + if (id && id !== sess.memberId) await updateSession(sess.token, { memberId: id }); return id || sess.memberId || 0; } catch (e) { return sess.memberId || 0; } } -function logout(req) { - const s = fromRequest(req); - if (s) { sessions.delete(s.token); saveSessions(); } +async function logout(req) { + const s = await fromRequest(req); + if (s) await impl().drop(s.token); } module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout }; diff --git a/db.js b/db.js new file mode 100644 index 0000000..026e3d3 --- /dev/null +++ b/db.js @@ -0,0 +1,118 @@ +// MySQL data layer (Marty: real concurrency over flat files). +// DATABASE_URL present -> mysql2 pool, schema bootstrap, one-time import of +// any existing volume JSON (accounts/sessions/ +// campaigns) so nothing is lost on the switch. +// DATABASE_URL absent -> db.enabled() is false and the modules fall back to +// their JSON stores (local dev keeps working). +// The chain index stays a file: it is a rebuildable cache of the blockchain. +const fs = require('fs'); +const path = require('path'); + +let pool = null; +let DATA_DIR = null; + +function enabled() { return !!process.env.DATABASE_URL; } + +async function init(opts) { + DATA_DIR = opts.dataDir; + if (!enabled()) return false; + const mysql = require('mysql2/promise'); + pool = mysql.createPool(process.env.DATABASE_URL + '?connectionLimit=10&charset=utf8mb4'); + await bootstrap(); + await importJsonOnce(); + console.log('db: MySQL connected, schema ready'); + return true; +} +const q = async (sql, params) => (await pool.query(sql, params))[0]; + +async function bootstrap() { + await q(`CREATE TABLE IF NOT EXISTS accounts ( + email VARCHAR(190) PRIMARY KEY, + pass VARCHAR(200) NULL, + sponsor_ref VARCHAR(32) NOT NULL DEFAULT '', + code VARCHAR(16) NOT NULL UNIQUE, + address VARCHAR(64) NULL UNIQUE, + created BIGINT NOT NULL, + last_seen BIGINT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(80) PRIMARY KEY, + email VARCHAR(190) NULL, + address VARCHAR(64) NULL, + member_id INT NOT NULL DEFAULT 0, + expires BIGINT NOT NULL, + INDEX (expires) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS campaigns ( + id INT AUTO_INCREMENT PRIMARY KEY, + owner_email VARCHAR(190) NOT NULL, + member_id INT NOT NULL, + type VARCHAR(12) NOT NULL, + name VARCHAR(80) NOT NULL, + target_url VARCHAR(500) NOT NULL, + image_url VARCHAR(500) NULL, + title VARCHAR(80) NULL, + body VARCHAR(200) NULL, + budget INT NOT NULL, + spent INT NOT NULL DEFAULT 0, + accrued INT NOT NULL DEFAULT 0, + imps INT NOT NULL DEFAULT 0, + clicks INT NOT NULL DEFAULT 0, + batch_imps INT NOT NULL DEFAULT 0, + status VARCHAR(12) NOT NULL DEFAULT 'active', + last_day_charged VARCHAR(10) NULL, + created BIGINT NOT NULL, + INDEX (owner_email), INDEX (type, status), INDEX (member_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS burns ( + id VARCHAR(32) PRIMARY KEY, + member_id INT NOT NULL, + amount INT NOT NULL, + ref VARCHAR(64) NOT NULL, + ts BIGINT NOT NULL, + burned_tx VARCHAR(80) NULL, + burned_at BIGINT NULL, + INDEX (burned_tx) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); +} + +// one-time import: only when the tables are empty and JSON files exist +async function importJsonOnce() { + const [{ n }] = await q('SELECT COUNT(*) n FROM accounts'); + if (n > 0) return; + try { + const a = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'accounts.json'), 'utf8')); + for (const acc of Object.values(a.byEmail || {})) { + await q('INSERT IGNORE INTO accounts (email,pass,sponsor_ref,code,address,created) VALUES (?,?,?,?,?,?)', + [acc.email, acc.pass || null, String(acc.sponsorRef || acc.sponsorId || ''), acc.code, acc.address || null, acc.created || Date.now()]); + } + console.log('db: imported', Object.keys(a.byEmail || {}).length, 'account(s) from JSON'); + } catch (e) {} + try { + const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'campaigns.json'), 'utf8')); + for (const cp of c.campaigns || []) { + await q(`INSERT IGNORE INTO campaigns (owner_email,member_id,type,name,target_url,image_url,title,body, + budget,spent,accrued,imps,clicks,batch_imps,status,last_day_charged,created) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [cp.owner, cp.memberId, cp.type, cp.name, cp.targetUrl, cp.imageUrl || null, cp.title || null, cp.body || null, + cp.budget, cp.spent || 0, cp.accrued || 0, cp.imps || 0, cp.clicks || 0, cp.batchImps || 0, + cp.status, cp.lastDayCharged || null, cp.created || Date.now()]); + } + for (const b of c.burnsPending || []) { + await q('INSERT IGNORE INTO burns (id,member_id,amount,ref,ts,burned_tx,burned_at) VALUES (?,?,?,?,?,?,?)', + [b.id, b.memberId, b.amount, b.ref, b.ts, b.burnedTx || null, b.burnedAt || null]); + } + console.log('db: imported', (c.campaigns || []).length, 'campaign(s) from JSON'); + } catch (e) {} + try { + const s = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'sessions.json'), 'utf8')); + for (const [tok, sess] of Object.entries(s)) { + if (sess.expires > Date.now()) { + await q('INSERT IGNORE INTO sessions (token,email,address,member_id,expires) VALUES (?,?,?,?,?)', + [tok, sess.email || null, sess.address || null, sess.memberId || 0, sess.expires]); + } + } + } catch (e) {} +} + +module.exports = { init, enabled, q: (...a) => q(...a) }; diff --git a/package.json b/package.json index 59a1914..f784270 100644 --- a/package.json +++ b/package.json @@ -5,5 +5,6 @@ "description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.", "main": "server.js", "scripts": {"start": "node server.js", "dev": "node --watch server.js"}, - "engines": {"node": ">=20"} + "engines": {"node": ">=20"}, + "dependencies": {"mysql2": "^3.11.0"} } diff --git a/server.js b/server.js index 271bdd7..4267d5c 100644 --- a/server.js +++ b/server.js @@ -23,13 +23,8 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme'; const IS_PROD = process.env.NODE_ENV === 'production'; const SITE_FILE = path.join(DATA_DIR, 'site.json'); +const db = require('./db'); fs.mkdirSync(DATA_DIR, { recursive: true }); -chain.init({ onEvent: ev => pushFeed(ev) }); -auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); -accounts.init({ dataDir: DATA_DIR }); -ads.init({ dataDir: DATA_DIR, chain }); -mailer.init({ dataDir: DATA_DIR }); -chatbot.init({ dataDir: DATA_DIR, chain }); const chatHits = new Map(); function chatLimited(ip) { const now = Date.now(), rec = chatHits.get(ip); @@ -39,8 +34,17 @@ function chatLimited(ip) { } // magic-code sign-in: emailLower -> {code, exp, tries} const emailCodes = new Map(); -setTimeout(() => ads.dailySweep(), 60 * 1000); -setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges +async function boot() { + await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode) + chain.init({ onEvent: ev => pushFeed(ev) }); + auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' }); + accounts.init({ dataDir: DATA_DIR }); + ads.init({ dataDir: DATA_DIR, chain }); + mailer.init({ dataDir: DATA_DIR }); + chatbot.init({ dataDir: DATA_DIR, chain }); + setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000); + setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000); +} function siteConfig() { let saved = {}; @@ -101,16 +105,16 @@ async function resolveSponsorToken(tok) { const t = String(tok || '').trim().toLowerCase(); if (!t) return 0; if (/^\d+$/.test(t)) return Number(t); - const acct = accounts.byCode(t); + const acct = await accounts.byCode(t); if (!acct || !acct.address) return 0; try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; } } // The moment someone joins through a code, nudge its owner to activate. -function nudgeReferrer(ref) { +async function nudgeReferrer(ref) { try { const t = String(ref || '').trim().toLowerCase(); if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return; - const owner = accounts.byCode(t); + const owner = await accounts.byCode(t); if (!owner || !owner.email || owner.address) return; // already activated-ready mailer.send(owner.email, 'Someone just joined through your InstantAdPay link', 'Good news: a new member just signed up through your share link.\n\n' @@ -176,7 +180,7 @@ const server = http.createServer(async (req, res) => { } if (p === '/api/stats' && req.method === 'GET') { let members = 0; try { members = await chain.memberCount(); } catch (e) {} - return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: accounts.count() }, chain.totals())); + return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: await accounts.count() }, chain.totals())); } // -- 24/7 assistant @@ -193,19 +197,19 @@ const server = http.createServer(async (req, res) => { if (p === '/api/signup' && req.method === 'POST') { const b = await readBody(req); const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution - const r = accounts.signup(b.email, b.password, ref); + const r = await accounts.signup(b.email, b.password, ref); if (r.error) return json(res, 400, r); - nudgeReferrer(ref); - const token = auth.mintSession({ email: r.account.email }); + nudgeReferrer(ref).catch(() => {}); + const token = await auth.mintSession({ email: r.account.email }); return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); } if (p === '/api/login' && req.method === 'POST') { const b = await readBody(req); - const r = accounts.login(b.email, b.password); + const r = await accounts.login(b.email, b.password); if (r.error) return json(res, 400, r); let memberId = 0; if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} } - const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); + const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); } @@ -238,12 +242,12 @@ const server = http.createServer(async (req, res) => { if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' }); emailCodes.delete(e); const ref = parseCookies(req)['iap.sponsor'] || ''; - const r = accounts.ensure(e, ref); // first touch wins; existing accounts unchanged + const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged if (r.error) return json(res, 400, r); - if (r.created) nudgeReferrer(ref); + if (r.created) nudgeReferrer(ref).catch(() => {}); let memberId = 0; if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} } - const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); + const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId }); return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) }); } @@ -260,27 +264,27 @@ const server = http.createServer(async (req, res) => { if (r.error) return json(res, 400, r); let memberId = 0; try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {} - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (s && s.email) { - const lr = accounts.linkWallet(s.email, r.address); + const lr = await accounts.linkWallet(s.email, r.address); if (lr.error) return json(res, 400, lr); - auth.updateSession(s.token, { address: r.address, memberId }); + await auth.updateSession(s.token, { address: r.address, memberId }); return json(res, 200, { ok: true, linked: true, address: r.address, memberId }); } - const acct = accounts.byAddress(r.address); - const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId }); + const acct = await accounts.byAddress(r.address); + const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId }); return json(res, 200, { ok: true, address: r.address, memberId }, { 'Set-Cookie': auth.sessionCookie(token) }); } if (p === '/api/auth/logout' && req.method === 'POST') { - auth.logout(req); + await auth.logout(req); return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() }); } if (p === '/api/me' && req.method === 'GET') { - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (!s) return json(res, 200, { signedIn: false }); const memberId = await auth.refreshMemberId(s); - const acct = (s.email && accounts.byEmail(s.email)) || (s.address && accounts.byAddress(s.address)) || null; + const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null; const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']); const out = { signedIn: true, email: s.email || (acct && acct.email) || null, address: s.address || (acct && acct.address) || null, memberId, @@ -297,7 +301,7 @@ const server = http.createServer(async (req, res) => { } if (p === '/api/my/activity' && req.method === 'GET') { - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (!s) return json(res, 401, { error: 'Sign in first.' }); const id = s.memberId || await auth.refreshMemberId(s); if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] }); @@ -312,26 +316,26 @@ const server = http.createServer(async (req, res) => { // -- ad engine (spec §8b v1: banners, text, login ads) if (p === '/api/ads/slot' && req.method === 'GET') { - const ad = ads.serve(String(u.searchParams.get('type') || 'banner')); + const ad = await ads.serve(String(u.searchParams.get('type') || 'banner')); return json(res, 200, { ad }); } m = /^\/api\/ads\/click\/(\d+)$/.exec(p); if (m && req.method === 'GET') { - const target = ads.click(m[1]); + const target = await ads.click(m[1]); if (!target) { res.writeHead(404, baseHeaders()); return res.end(); } res.writeHead(302, baseHeaders({ Location: target })); return res.end(); } if (p === '/api/my/campaigns' && req.method === 'GET') { - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const memberId = await auth.refreshMemberId(s); - const out = { campaigns: ads.listCampaigns(s.email), rates: ads.rates() }; + const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates() }; out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0; return json(res, 200, out); } if (p === '/api/my/campaigns' && req.method === 'POST') { - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const memberId = await auth.refreshMemberId(s); if (!memberId) return json(res, 400, { error: 'Buy an ad package first. Campaigns spend the on-chain credits it mints.' }); @@ -341,21 +345,21 @@ const server = http.createServer(async (req, res) => { } m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p); if (m && req.method === 'POST') { - const s = auth.fromRequest(req); + const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); - const r = ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active'); + const r = await ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active'); return json(res, r.error ? 400 : 200, r); } // -- admin (Bearer ADMIN_PASSWORD) if (p === '/api/admin/burns' && req.method === 'GET') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); - return json(res, 200, { pending: ads.pendingBurns() }); + return json(res, 200, { pending: await ads.pendingBurns() }); } if (p === '/api/admin/burns/mark' && req.method === 'POST') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); const b = await readBody(req); - const r = ads.markBurned(b.id, b.tx); + const r = await ads.markBurned(b.id, b.tx); return json(res, r.error ? 400 : 200, r); } if (p === '/api/admin/rates' && req.method === 'PATCH') { @@ -399,4 +403,6 @@ const server = http.createServer(async (req, res) => { try { json(res, 500, { error: 'server error' }); } catch (_) {} } }); -server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName}`)); +boot().then(() => { + server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`)); +}).catch(e => { console.error('boot failed:', e.message); process.exit(1); });