MySQL data layer: accounts, sessions, campaigns, burns (Marty: real concurrency)
Coolify MySQL (instantadpay-db) via DATABASE_URL; db.js bootstraps schema and one-time imports the volume JSON. accounts/auth/ads are dual-mode: the MySQL path uses guarded UPDATEs for the concurrent ad-serving hot path; without DATABASE_URL the JSON stores remain (local dev). All data functions async; server boots through db.init. Chain index stays a file: it is a rebuildable cache of the blockchain, which remains the money truth. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|||||||
+142
-101
@@ -1,44 +1,16 @@
|
|||||||
// Site-side member accounts for InstantAdPay.
|
// Site-side member accounts. Dual-mode:
|
||||||
// The chain is the source of truth for money, credits, and qualification;
|
// MySQL (db.enabled) for real concurrency in production,
|
||||||
// this module holds what the chain doesn't: free members (email + password,
|
// JSON volume file as the no-DATABASE_URL fallback (local dev).
|
||||||
// the way normal people join), sponsor attribution before first purchase
|
// All exported functions are async; both modes return identical shapes.
|
||||||
// (spec §4), and the wallet link once one is connected at purchase time.
|
// The chain remains the source of truth for money/credits/qualification.
|
||||||
// Wiping this file = the clean reset between rehearsal and mainnet.
|
|
||||||
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 db = require('./db');
|
||||||
|
|
||||||
let DATA_DIR = null;
|
let DATA_DIR = null;
|
||||||
const FILE = () => path.join(DATA_DIR, 'accounts.json');
|
|
||||||
let db = { v: 2, byEmail: {}, byAddress: {}, joins: 0 };
|
|
||||||
|
|
||||||
function load() {
|
// ---- shared helpers ----
|
||||||
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) ----
|
|
||||||
function hashPassword(password) {
|
function hashPassword(password) {
|
||||||
const salt = crypto.randomBytes(16);
|
const salt = crypto.randomBytes(16);
|
||||||
const hash = crypto.scryptSync(String(password), salt, 32);
|
const hash = crypto.scryptSync(String(password), salt, 32);
|
||||||
@@ -51,85 +23,154 @@ function checkPassword(password, stored) {
|
|||||||
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
|
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'));
|
||||||
} catch (e) { return false; }
|
} catch (e) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||||
const normEmail = e => String(e || '').trim().toLowerCase();
|
const normEmail = e => String(e || '').trim().toLowerCase();
|
||||||
const normAddr = a => String(a || '').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) ----
|
// ---- JSON fallback ----
|
||||||
function signup(email, password, sponsorRef) {
|
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);
|
const e = normEmail(email);
|
||||||
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
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 (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.' };
|
return impl().signup(e, String(password), String(sponsorRef || ''));
|
||||||
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]) };
|
|
||||||
}
|
}
|
||||||
function login(email, password) {
|
async function login(email, password) { return impl().login(normEmail(email), String(password || '')); }
|
||||||
const e = normEmail(email);
|
async function ensure(email, sponsorRef) {
|
||||||
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) {
|
|
||||||
const e = normEmail(email);
|
const e = normEmail(email);
|
||||||
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
||||||
let created = false;
|
return impl().ensure(e, String(sponsorRef || ''));
|
||||||
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]) };
|
async function byEmail(email) { return impl().byEmail(normEmail(email)); }
|
||||||
}
|
async function byAddress(address) { return impl().byAddress(normAddr(address)); }
|
||||||
function byCode(code) {
|
async function byCode(code) { return impl().byCode(String(code || '').toLowerCase()); }
|
||||||
const e = db.byCode[String(code || '').toLowerCase()];
|
async function linkWallet(email, address) {
|
||||||
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);
|
|
||||||
const a = normAddr(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 (!/^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 '
|
return impl().linkWallet(normEmail(email), a);
|
||||||
+ 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) };
|
|
||||||
}
|
}
|
||||||
|
async function count() { return impl().count(); }
|
||||||
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; }
|
|
||||||
|
|
||||||
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, linkWallet, count };
|
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, linkWallet, count };
|
||||||
|
|||||||
@@ -1,61 +1,41 @@
|
|||||||
// Ad engine v1 (spec §8b): banners, text ads, login ads served against
|
// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js:
|
||||||
// PURCHASED on-chain credits. The chain is the money truth: spend accrues
|
// MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path,
|
||||||
// here, and burns are queued for the engine signer to consume() on-chain
|
// JSON volume file fallback for local dev.
|
||||||
// (admin runs the burner; see /api/admin/burns). Earned-credit pool and the
|
// Spend accrues per campaign; burns queue for the engine signer to consume()
|
||||||
// richer §8b types (inbox, surf, rotation, directory) come later.
|
// on-chain (/api/admin/burns). All exported functions are async.
|
||||||
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 db = require('./db');
|
||||||
|
|
||||||
let DATA_DIR = null;
|
let DATA_DIR = null;
|
||||||
let chain = null;
|
let chain = null;
|
||||||
const FILE = () => path.join(DATA_DIR, 'campaigns.json');
|
|
||||||
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.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.
|
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
|
||||||
// Integer math: impressions accrue per campaign; credits deduct per BATCH.
|
|
||||||
function rates() {
|
function rates() {
|
||||||
let saved = {};
|
let saved = {};
|
||||||
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
|
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
|
||||||
return Object.assign({
|
return Object.assign({
|
||||||
bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent
|
bannerBatch: 10, bannerCreditsPerBatch: 2,
|
||||||
textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent
|
textBatch: 10, textCreditsPerBatch: 1,
|
||||||
loginCreditsPerDay: 100, // $1.00/day
|
loginCreditsPerDay: 100,
|
||||||
burnBatchMin: 50 // queue a burn every 50cr of spend
|
burnBatchMin: 50
|
||||||
}, saved);
|
}, saved);
|
||||||
}
|
}
|
||||||
|
function setRates(patch) {
|
||||||
function load() {
|
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2));
|
||||||
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
|
return rates();
|
||||||
if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
|
|
||||||
}
|
}
|
||||||
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 TYPES = ['banner', 'text', 'login'];
|
||||||
const URL_RE = /^https?:\/\/[^\s]+$/i;
|
const URL_RE = /^https?:\/\/[^\s]+$/i;
|
||||||
|
const bid = () => crypto.randomBytes(8).toString('hex');
|
||||||
// unburned spend per member = credits owed to the burn queue + campaign accruals
|
function batchFor(type, r) {
|
||||||
function unburnedSpend(memberId) {
|
return type === 'banner' ? { n: r.bannerBatch, cr: r.bannerCreditsPerBatch }
|
||||||
let s = 0;
|
: type === 'text' ? { n: r.textBatch, cr: r.textCreditsPerBatch } : null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
async function availableCredits(memberId) {
|
function validate(input) {
|
||||||
const onchain = await chain.creditBalance(memberId, 0);
|
|
||||||
return Math.max(0, onchain - unburnedSpend(memberId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createCampaign(owner, memberId, input) {
|
|
||||||
const type = String(input.type || '');
|
const type = String(input.type || '');
|
||||||
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
|
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
|
||||||
const name = String(input.name || '').trim().slice(0, 60);
|
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)://' };
|
if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
|
||||||
const budget = Math.floor(Number(input.budget) || 0);
|
const budget = Math.floor(Number(input.budget) || 0);
|
||||||
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
|
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
|
||||||
const avail = await availableCredits(memberId);
|
const out = { type, name, targetUrl, budget, imageUrl: null, title: null, body: null };
|
||||||
if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' };
|
if (type === 'banner' || type === 'login') {
|
||||||
|
out.imageUrl = String(input.imageUrl || '').trim();
|
||||||
const c = { id: db.nextId++, owner, memberId, type, name, targetUrl,
|
if (!URL_RE.test(out.imageUrl)) return { error: (type === 'banner' ? 'Banner' : 'Login') + ' ads need an image URL starting with http(s)://' };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
c.title = String(input.title || '').trim().slice(0, 60);
|
out.title = String(input.title || '').trim().slice(0, 60);
|
||||||
c.body = String(input.body || '').trim().slice(0, 140);
|
out.body = String(input.body || '').trim().slice(0, 140);
|
||||||
if (!c.title) return { error: 'Text ads need a headline.' };
|
if (!out.title) return { error: 'Text ads need a headline.' };
|
||||||
}
|
}
|
||||||
if (type === 'login') {
|
return { ok: true, c: out };
|
||||||
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) };
|
|
||||||
}
|
}
|
||||||
|
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) {
|
// ---- JSON fallback ----
|
||||||
return db.campaigns.filter(c => c.owner === owner).map(pub);
|
const J = {
|
||||||
}
|
db: { v: 1, nextId: 1, campaigns: [], burnsPending: [] },
|
||||||
function setStatus(owner, id, status) {
|
FILE: () => path.join(DATA_DIR, 'campaigns.json'),
|
||||||
const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
|
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.' };
|
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;
|
c.status = status;
|
||||||
save();
|
this.save();
|
||||||
return { ok: true, campaign: pub(c) };
|
return { ok: true, campaign: pubC(c) };
|
||||||
}
|
},
|
||||||
function pub(c) {
|
async serve(type) {
|
||||||
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
|
|
||||||
const r = rates();
|
const r = rates();
|
||||||
|
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) {
|
if (c.accrued >= r.burnBatchMin) {
|
||||||
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
|
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
||||||
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
c.spent += c.accrued; c.accrued = 0;
|
||||||
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) {
|
this.save();
|
||||||
const r = rates();
|
return served(c);
|
||||||
const pool = db.campaigns.filter(c => c.type === type && c.status === 'active');
|
},
|
||||||
if (!pool.length) return null;
|
async click(id) {
|
||||||
const c = pool[Math.floor(Math.random() * pool.length)];
|
const c = this.db.campaigns.find(x => x.id === Number(id));
|
||||||
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;
|
if (!c) return null;
|
||||||
c.clicks += 1;
|
c.clicks += 1;
|
||||||
save();
|
this.save();
|
||||||
return c.targetUrl;
|
return c.targetUrl;
|
||||||
}
|
},
|
||||||
|
async dailySweep() {
|
||||||
// daily charge for login ads (called by a boot + interval sweep)
|
|
||||||
function dailySweep() {
|
|
||||||
const r = rates();
|
const r = rates();
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
let charged = 0;
|
let n = 0;
|
||||||
for (const c of db.campaigns) {
|
for (const c of this.db.campaigns) {
|
||||||
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue;
|
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue;
|
||||||
c.lastDayCharged = today;
|
c.lastDayCharged = today;
|
||||||
c.accrued = (c.accrued || 0) + r.loginCreditsPerDay;
|
c.accrued = (c.accrued || 0) + r.loginCreditsPerDay;
|
||||||
if (c.accrued >= r.burnBatchMin) {
|
if (c.accrued >= r.burnBatchMin) {
|
||||||
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
|
this.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
||||||
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
|
||||||
c.spent += c.accrued; c.accrued = 0;
|
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';
|
||||||
charged += 1;
|
n += 1;
|
||||||
}
|
}
|
||||||
if (charged) save();
|
if (n) this.save();
|
||||||
return charged;
|
return n;
|
||||||
}
|
},
|
||||||
|
async pendingBurns() { return this.db.burnsPending.filter(b => !b.burnedTx); },
|
||||||
// ---- burn queue (admin/engine) ----
|
async markBurned(id, tx) {
|
||||||
function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); }
|
const b = this.db.burnsPending.find(x => x.id === id);
|
||||||
function markBurned(id, tx) {
|
|
||||||
const b = db.burnsPending.find(x => x.id === id);
|
|
||||||
if (!b) return { error: 'No such burn.' };
|
if (!b) return { error: 'No such burn.' };
|
||||||
b.burnedTx = tx; b.burnedAt = Date.now();
|
b.burnedTx = tx; b.burnedAt = Date.now();
|
||||||
save();
|
this.save();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
function setRates(patch) {
|
};
|
||||||
const cur = rates();
|
|
||||||
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2));
|
// ---- MySQL mode: guarded UPDATEs keep the serving path concurrency-safe ----
|
||||||
return rates();
|
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));
|
||||||
|
}
|
||||||
|
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,
|
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
|
||||||
serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
|
serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
// Wallet sign-in (SIWE / EIP-4361) for InstantAdPay.
|
// Wallet sign-in (SIWE / EIP-4361) + session store for InstantAdPay.
|
||||||
// Pattern lifted from the RM Circle messages.js implementation (proven with
|
// Sessions are dual-mode like accounts.js: MySQL when DATABASE_URL is set,
|
||||||
// MetaMask's friendly sign-in UI). One free signature, cannot move funds.
|
// volume JSON otherwise. All session functions are async.
|
||||||
//
|
|
||||||
// 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).
|
|
||||||
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 { keccak256 } = require('./vendor/sha3');
|
const { keccak256 } = require('./vendor/sha3');
|
||||||
const secp = require('./vendor/secp256k1');
|
const secp = require('./vendor/secp256k1');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
let DATA_DIR = null;
|
let DATA_DIR = null;
|
||||||
let chain = null;
|
let chain = null;
|
||||||
@@ -19,27 +16,76 @@ let SITE = 'instantadpay.com';
|
|||||||
const CHALLENGE_TTL = 10 * 60 * 1000;
|
const CHALLENGE_TTL = 10 * 60 * 1000;
|
||||||
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
const SESSION_TTL = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
const challenges = new Map(); // addressLower -> {message, exp}
|
const challenges = new Map(); // addressLower -> {message, exp}
|
||||||
let sessions = new Map(); // token -> {address, memberId, expires}
|
|
||||||
|
|
||||||
const SESS_FILE = () => path.join(DATA_DIR, 'sessions.json');
|
// ---- JSON session fallback ----
|
||||||
function loadSessions() {
|
const J = {
|
||||||
|
sessions: new Map(),
|
||||||
|
FILE: () => path.join(DATA_DIR, 'sessions.json'),
|
||||||
|
load() {
|
||||||
try {
|
try {
|
||||||
const o = JSON.parse(fs.readFileSync(SESS_FILE(), 'utf8'));
|
const o = JSON.parse(fs.readFileSync(this.FILE(), 'utf8'));
|
||||||
sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
|
this.sessions = new Map(Object.entries(o).filter(([, s]) => s.expires > Date.now()));
|
||||||
} catch (e) { sessions = new Map(); }
|
} catch (e) { this.sessions = new Map(); }
|
||||||
}
|
},
|
||||||
function saveSessions() {
|
save() {
|
||||||
try {
|
try {
|
||||||
const tmp = SESS_FILE() + '.tmp';
|
const tmp = this.FILE() + '.tmp';
|
||||||
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
|
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.sessions)), { mode: 0o600 });
|
||||||
fs.renameSync(tmp, SESS_FILE());
|
fs.renameSync(tmp, this.FILE());
|
||||||
} catch (e) { console.error('session save failed', e.message); }
|
} 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) {
|
function init(opts) {
|
||||||
DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd;
|
DATA_DIR = opts.dataDir; chain = opts.chain; IS_PROD = !!opts.isProd;
|
||||||
if (opts.site) SITE = opts.site;
|
if (opts.site) SITE = opts.site;
|
||||||
loadSessions();
|
J.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- crypto ----
|
// ---- crypto ----
|
||||||
@@ -65,7 +111,7 @@ function checksumAddress(address) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- auth flow ----
|
// ---- SIWE flow ----
|
||||||
function makeChallenge(address) {
|
function makeChallenge(address) {
|
||||||
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
|
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
|
||||||
const a = address.toLowerCase();
|
const a = address.toLowerCase();
|
||||||
@@ -86,45 +132,30 @@ async function verifyChallenge(address, signature) {
|
|||||||
challenges.delete(a);
|
challenges.delete(a);
|
||||||
return { ok: true, address: 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.
|
// ---- sessions ----
|
||||||
function mintSession(fields) {
|
async function mintSession(fields) { return impl().mint(fields || {}); }
|
||||||
const token = crypto.randomBytes(32).toString('hex');
|
async function updateSession(token, fields) { return impl().update(token, fields || {}); }
|
||||||
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();
|
|
||||||
}
|
|
||||||
function sessionCookie(token) {
|
function sessionCookie(token) {
|
||||||
return `iap.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL / 1000}${IS_PROD ? '; Secure' : ''}`;
|
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 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 || '');
|
const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || '');
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const token = decodeURIComponent(m[1]);
|
return impl().get(decodeURIComponent(m[1]));
|
||||||
const s = sessions.get(token);
|
|
||||||
if (!s || s.expires < Date.now()) return null;
|
|
||||||
return Object.assign({ token }, s);
|
|
||||||
}
|
}
|
||||||
async function refreshMemberId(sess) {
|
async function refreshMemberId(sess) {
|
||||||
// called after an on-chain action so the session learns its new member id
|
if (!sess || !sess.address) return (sess && sess.memberId) || 0;
|
||||||
if (!sess.address) return sess.memberId || 0;
|
|
||||||
try {
|
try {
|
||||||
const id = await chain.memberIdByAccount(sess.address);
|
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;
|
return id || sess.memberId || 0;
|
||||||
} catch (e) { return sess.memberId || 0; }
|
} catch (e) { return sess.memberId || 0; }
|
||||||
}
|
}
|
||||||
function logout(req) {
|
async function logout(req) {
|
||||||
const s = fromRequest(req);
|
const s = await fromRequest(req);
|
||||||
if (s) { sessions.delete(s.token); saveSessions(); }
|
if (s) await impl().drop(s.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|
module.exports = { init, makeChallenge, verifyChallenge, mintSession, updateSession, sessionCookie, clearCookie, fromRequest, refreshMemberId, logout };
|
||||||
|
|||||||
@@ -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) };
|
||||||
+2
-1
@@ -5,5 +5,6 @@
|
|||||||
"description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
|
"description": "InstantAdPay membership advertising site - immutable on-chain settlement, transparent ledger.",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {"start": "node server.js", "dev": "node --watch server.js"},
|
"scripts": {"start": "node server.js", "dev": "node --watch server.js"},
|
||||||
"engines": {"node": ">=20"}
|
"engines": {"node": ">=20"},
|
||||||
|
"dependencies": {"mysql2": "^3.11.0"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,8 @@ const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|||||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
||||||
|
|
||||||
|
const db = require('./db');
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
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();
|
const chatHits = new Map();
|
||||||
function chatLimited(ip) {
|
function chatLimited(ip) {
|
||||||
const now = Date.now(), rec = chatHits.get(ip);
|
const now = Date.now(), rec = chatHits.get(ip);
|
||||||
@@ -39,8 +34,17 @@ function chatLimited(ip) {
|
|||||||
}
|
}
|
||||||
// magic-code sign-in: emailLower -> {code, exp, tries}
|
// magic-code sign-in: emailLower -> {code, exp, tries}
|
||||||
const emailCodes = new Map();
|
const emailCodes = new Map();
|
||||||
setTimeout(() => ads.dailySweep(), 60 * 1000);
|
async function boot() {
|
||||||
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
|
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() {
|
function siteConfig() {
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -101,16 +105,16 @@ async function resolveSponsorToken(tok) {
|
|||||||
const t = String(tok || '').trim().toLowerCase();
|
const t = String(tok || '').trim().toLowerCase();
|
||||||
if (!t) return 0;
|
if (!t) return 0;
|
||||||
if (/^\d+$/.test(t)) return Number(t);
|
if (/^\d+$/.test(t)) return Number(t);
|
||||||
const acct = accounts.byCode(t);
|
const acct = await accounts.byCode(t);
|
||||||
if (!acct || !acct.address) return 0;
|
if (!acct || !acct.address) return 0;
|
||||||
try { return await chain.memberIdByAccount(acct.address); } catch (e) { 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.
|
// The moment someone joins through a code, nudge its owner to activate.
|
||||||
function nudgeReferrer(ref) {
|
async function nudgeReferrer(ref) {
|
||||||
try {
|
try {
|
||||||
const t = String(ref || '').trim().toLowerCase();
|
const t = String(ref || '').trim().toLowerCase();
|
||||||
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return;
|
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
|
if (!owner || !owner.email || owner.address) return; // already activated-ready
|
||||||
mailer.send(owner.email, 'Someone just joined through your InstantAdPay link',
|
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'
|
'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') {
|
if (p === '/api/stats' && req.method === 'GET') {
|
||||||
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
|
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
|
// -- 24/7 assistant
|
||||||
@@ -193,19 +197,19 @@ const server = http.createServer(async (req, res) => {
|
|||||||
if (p === '/api/signup' && req.method === 'POST') {
|
if (p === '/api/signup' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution
|
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);
|
if (r.error) return json(res, 400, r);
|
||||||
nudgeReferrer(ref);
|
nudgeReferrer(ref).catch(() => {});
|
||||||
const token = auth.mintSession({ email: r.account.email });
|
const token = await auth.mintSession({ email: r.account.email });
|
||||||
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||||||
}
|
}
|
||||||
if (p === '/api/login' && req.method === 'POST') {
|
if (p === '/api/login' && req.method === 'POST') {
|
||||||
const b = await readBody(req);
|
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);
|
if (r.error) return json(res, 400, r);
|
||||||
let memberId = 0;
|
let memberId = 0;
|
||||||
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
|
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) });
|
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.' });
|
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
|
||||||
emailCodes.delete(e);
|
emailCodes.delete(e);
|
||||||
const ref = parseCookies(req)['iap.sponsor'] || '';
|
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.error) return json(res, 400, r);
|
||||||
if (r.created) nudgeReferrer(ref);
|
if (r.created) nudgeReferrer(ref).catch(() => {});
|
||||||
let memberId = 0;
|
let memberId = 0;
|
||||||
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
|
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) });
|
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);
|
if (r.error) return json(res, 400, r);
|
||||||
let memberId = 0;
|
let memberId = 0;
|
||||||
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
||||||
const s = auth.fromRequest(req);
|
const s = await auth.fromRequest(req);
|
||||||
if (s && s.email) {
|
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);
|
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 });
|
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
|
||||||
}
|
}
|
||||||
const acct = accounts.byAddress(r.address);
|
const acct = await accounts.byAddress(r.address);
|
||||||
const token = auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
||||||
return json(res, 200, { ok: true, address: r.address, memberId },
|
return json(res, 200, { ok: true, address: r.address, memberId },
|
||||||
{ 'Set-Cookie': auth.sessionCookie(token) });
|
{ 'Set-Cookie': auth.sessionCookie(token) });
|
||||||
}
|
}
|
||||||
if (p === '/api/auth/logout' && req.method === 'POST') {
|
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() });
|
return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
|
||||||
}
|
}
|
||||||
if (p === '/api/me' && req.method === 'GET') {
|
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 });
|
if (!s) return json(res, 200, { signedIn: false });
|
||||||
const memberId = await auth.refreshMemberId(s);
|
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 sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
|
||||||
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
|
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
|
||||||
address: s.address || (acct && acct.address) || null, memberId,
|
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') {
|
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.' });
|
if (!s) return json(res, 401, { error: 'Sign in first.' });
|
||||||
const id = s.memberId || await auth.refreshMemberId(s);
|
const id = s.memberId || await auth.refreshMemberId(s);
|
||||||
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] });
|
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)
|
// -- ad engine (spec §8b v1: banners, text, login ads)
|
||||||
if (p === '/api/ads/slot' && req.method === 'GET') {
|
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 });
|
return json(res, 200, { ad });
|
||||||
}
|
}
|
||||||
m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
|
m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
|
||||||
if (m && req.method === 'GET') {
|
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(); }
|
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
|
||||||
res.writeHead(302, baseHeaders({ Location: target }));
|
res.writeHead(302, baseHeaders({ Location: target }));
|
||||||
return res.end();
|
return res.end();
|
||||||
}
|
}
|
||||||
if (p === '/api/my/campaigns' && req.method === 'GET') {
|
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.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
const memberId = await auth.refreshMemberId(s);
|
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;
|
out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0;
|
||||||
return json(res, 200, out);
|
return json(res, 200, out);
|
||||||
}
|
}
|
||||||
if (p === '/api/my/campaigns' && req.method === 'POST') {
|
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.' });
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
const memberId = await auth.refreshMemberId(s);
|
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.' });
|
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);
|
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
|
||||||
if (m && req.method === 'POST') {
|
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.' });
|
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);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- admin (Bearer ADMIN_PASSWORD)
|
// -- admin (Bearer ADMIN_PASSWORD)
|
||||||
if (p === '/api/admin/burns' && req.method === 'GET') {
|
if (p === '/api/admin/burns' && req.method === 'GET') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
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 (p === '/api/admin/burns/mark' && req.method === 'POST') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
const b = await readBody(req);
|
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);
|
return json(res, r.error ? 400 : 200, r);
|
||||||
}
|
}
|
||||||
if (p === '/api/admin/rates' && req.method === 'PATCH') {
|
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 (_) {}
|
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); });
|
||||||
|
|||||||
Reference in New Issue
Block a user