LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// Wallet sign-in (SIWE / EIP-4361) + session store for LinkSpin.
|
||||
// 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;
|
||||
let IS_PROD = false;
|
||||
let SITE = 'linkspin-test.saasy.top';
|
||||
|
||||
const CHALLENGE_TTL = 10 * 60 * 1000;
|
||||
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24h — forces a daily re-login so the login ad shows each day
|
||||
const challenges = new Map(); // addressLower -> {message, exp}
|
||||
|
||||
// ---- 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;
|
||||
J.load();
|
||||
}
|
||||
|
||||
// ---- crypto ----
|
||||
function personalDigest(msg) {
|
||||
const m = Buffer.from(msg, 'utf8');
|
||||
const pre = Buffer.from('\x19Ethereum Signed Message:\n' + m.length, 'utf8');
|
||||
return Buffer.from(keccak256(Buffer.concat([pre, m])), 'hex');
|
||||
}
|
||||
function recoverAddress(msg, signature) {
|
||||
const raw = Buffer.from(String(signature).replace(/^0x/, ''), 'hex');
|
||||
if (raw.length !== 65) throw new Error('Bad signature length');
|
||||
let v = raw[64]; if (v >= 27) v -= 27;
|
||||
if (v !== 0 && v !== 1) throw new Error('Bad signature recovery byte');
|
||||
const pub = secp.recoverPublicKey(personalDigest(msg), raw.slice(0, 64), v, false);
|
||||
return '0x' + keccak256(Buffer.from(pub.slice(1))).slice(-40);
|
||||
}
|
||||
const ADDR_RE = /^0x[0-9a-fA-F]{40}$/;
|
||||
function checksumAddress(address) {
|
||||
const a = address.toLowerCase().replace(/^0x/, '');
|
||||
const h = keccak256(a);
|
||||
let out = '0x';
|
||||
for (let i = 0; i < a.length; i++) out += parseInt(h[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- SIWE flow ----
|
||||
function makeChallenge(address) {
|
||||
if (!ADDR_RE.test(address || '')) return { error: 'Bad address' };
|
||||
const a = address.toLowerCase();
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const chainId = chain.getConfig().chainId;
|
||||
const message = `${SITE} wants you to sign in with your Ethereum account:\n${checksumAddress(a)}\n\nLinkSpin member sign-in. This signature is free and cannot move funds or approve anything.\n\nURI: https://${SITE}\nVersion: 1\nChain ID: ${chainId}\nNonce: ${nonce}\nIssued At: ${new Date().toISOString()}`;
|
||||
challenges.set(a, { message, exp: Date.now() + CHALLENGE_TTL });
|
||||
return { message };
|
||||
}
|
||||
async function verifyChallenge(address, signature) {
|
||||
const a = (address || '').toLowerCase();
|
||||
const ch = challenges.get(a);
|
||||
if (!ch || ch.exp < Date.now()) return { error: 'Challenge expired - tap sign-in again.' };
|
||||
let rec;
|
||||
try { rec = recoverAddress(ch.message, signature); } catch (e) { return { error: 'Invalid signature: ' + e.message }; }
|
||||
if (rec !== a) return { error: 'Your wallet signed with a different account than the page is using ('
|
||||
+ rec.slice(0, 6) + '…' + rec.slice(-4) + '). Switch accounts and tap sign-in again.' };
|
||||
challenges.delete(a);
|
||||
return { ok: true, address: a };
|
||||
}
|
||||
|
||||
// ---- 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'; }
|
||||
async function fromRequest(req) {
|
||||
const m = /(?:^|;\s*)iap\.sid=([^;]+)/.exec(req.headers.cookie || '');
|
||||
if (!m) return null;
|
||||
return impl().get(decodeURIComponent(m[1]));
|
||||
}
|
||||
async function refreshMemberId(sess) {
|
||||
if (!sess) return 0;
|
||||
if (!sess.address) {
|
||||
if (sess.memberId) return sess.memberId;
|
||||
// email-only session (phone sign-in with no wallet connected): read the account's
|
||||
// on-chain id instead, so read-only views (My line, earnings, P&L) work from any
|
||||
// device. Not cached into the session: a session without an address stays wallet-less.
|
||||
if (sess.email) { try { const a = await require('./accounts').byEmail(sess.email); if (a && a.memberId) return a.memberId; } catch (e) {} }
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const id = await chain.memberIdByAccount(sess.address);
|
||||
if (id && id !== sess.memberId) await updateSession(sess.token, { memberId: id });
|
||||
return id || sess.memberId || 0;
|
||||
} catch (e) { return sess.memberId || 0; }
|
||||
}
|
||||
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 };
|
||||
Reference in New Issue
Block a user