// 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; // ---- shared helpers ---- function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(String(password), salt, 32); return salt.toString('hex') + ':' + hash.toString('hex'); } function checkPassword(password, stored) { try { const [saltHex, hashHex] = String(stored).split(':'); const hash = crypto.scryptSync(String(password), Buffer.from(saltHex, 'hex'), 32); 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, username: a.username || null, memberId: a.memberId || 0, joinedVia: a.joinedVia || null, lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null, wallOffers: a.wallOffers || null, avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials: a.socials || null, chatAvailable: a.chatAvailable === false ? false : true, lastSeen: a.lastSeen || 0, address: a.address || null, created: a.created } : null; const USER_RE = /^[a-zA-Z0-9_]{3,20}$/; const normUser = u => String(u || '').trim().toLowerCase(); // ---- 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, via) { 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(), joinedVia: via || null }; 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 byUsername(u) { for (const a of Object.values(this.db.byEmail)) if (a.username === u) return pub(a); return null; }, async setUsername(e, u) { const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; for (const a of Object.values(this.db.byEmail)) if (a.username === u && a.email !== e) return { error: 'That username is taken. Try another.' }; acct.username = u; this.save(); return { ok: true, account: pub(acct) }; }, async setLineBanner(e, bannerUrl, targetUrl) { const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; acct.lineBannerUrl = bannerUrl || null; acct.lineTargetUrl = targetUrl || null; this.save(); return { ok: true, account: pub(acct) }; }, async setWallOffers(e, json) { const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; acct.wallOffers = json || null; this.save(); return { ok: true, account: pub(acct) }; }, async setProfile(e, avatarUrl, bio, socials) { const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; if (avatarUrl !== undefined) acct.avatarUrl = avatarUrl || null; if (bio !== undefined) acct.bio = bio || null; if (socials !== undefined) acct.socials = socials || null; this.save(); return { ok: true, account: pub(acct) }; }, async touchSeen(e) { const a = this.db.byEmail[e]; if (a) { a.lastSeen = Date.now(); this.save(); } }, async setChatAvailable(e, v) { const a = this.db.byEmail[e]; if (!a) return { error: 'No such account.' }; a.chatAvailable = !!v; this.save(); return { ok: true, available: !!v }; }, async getMutes(e) { const a = this.db.byEmail[e]; return (a && Array.isArray(a.chatMutes)) ? a.chatMutes : []; }, async setMute(owner, target, muted) { const a = this.db.byEmail[owner]; if (!a) return { error: 'No such account.' }; const set = new Set((a.chatMutes || []).map(x => String(x).toLowerCase())); const t = String(target).toLowerCase(); if (muted) set.add(t); else set.delete(t); a.chatMutes = [...set].slice(0, 500); this.save(); return { ok: true, muted: !!muted }; }, async byMemberId(id) { const a = Object.values(this.db.byEmail).find(x => x.memberId === Number(id)); return a ? pub(a) : null; }, async setMemberId(e, id) { const acct = this.db.byEmail[e]; if (acct && acct.memberId !== id) { acct.memberId = id; this.save(); } }, async namesForMembers(ids) { const out = {}; for (const a of Object.values(this.db.byEmail)) if (a.username && a.memberId && ids.includes(a.memberId)) out[a.memberId] = a.username; return out; }, async listByReferrer(refs) { const set = new Set(refs.filter(Boolean).map(String)); return Object.values(this.db.byEmail) .filter(a => set.has(String(a.sponsorRef || ''))) .sort((a, b) => b.created - a.created) .slice(0, 200).map(pub); }, 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 listAll(limit) { return Object.values(this.db.byEmail).sort((a, b) => (b.created || 0) - (a.created || 0)).slice(0, limit).map(pub); }, async setSponsorRef(e, ref) { const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' }; acct.sponsorRef = ref; 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, username: r.username, memberId: r.member_id || 0, joinedVia: r.joined_via || null, lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url, wallOffers: r.wall_offers || null, avatarUrl: r.avatar_url, bio: r.bio, socials: r.socials, chatAvailable: r.chat_available === 0 ? false : true, lastSeen: Number(r.last_seen || 0), 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, via) { const code = newCode(); let created = false; try { await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via) VALUES (?,NULL,?,?,NULL,?,?)', [e, ref, code, Date.now(), via || null]); 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 byUsername(u) { const r = await db.q('SELECT * FROM accounts WHERE username=?', [u]); return rowPub(r[0]); }, async setUsername(e, u) { try { await db.q('UPDATE accounts SET username=? WHERE email=?', [u, e]); } catch (err) { if (err.code === 'ER_DUP_ENTRY') return { error: 'That username is taken. Try another.' }; throw err; } return { ok: true, account: await this.byEmail(e) }; }, async setLineBanner(e, bannerUrl, targetUrl) { await db.q('UPDATE accounts SET line_banner_url=?, line_target_url=? WHERE email=?', [bannerUrl || null, targetUrl || null, e]); return { ok: true, account: await this.byEmail(e) }; }, async setWallOffers(e, json) { await db.q('UPDATE accounts SET wall_offers=? WHERE email=?', [json || null, e]); return { ok: true, account: await this.byEmail(e) }; }, async setProfile(e, avatarUrl, bio, socials) { if (avatarUrl !== undefined) await db.q('UPDATE accounts SET avatar_url=? WHERE email=?', [avatarUrl || null, e]); if (bio !== undefined) await db.q('UPDATE accounts SET bio=? WHERE email=?', [bio || null, e]); if (socials !== undefined) await db.q('UPDATE accounts SET socials=? WHERE email=?', [socials || null, e]); return { ok: true, account: await this.byEmail(e) }; }, async touchSeen(e) { await db.q('UPDATE accounts SET last_seen=? WHERE email=?', [Date.now(), e]); }, async setChatAvailable(e, v) { await db.q('UPDATE accounts SET chat_available=? WHERE email=?', [v ? 1 : 0, e]); return { ok: true, available: !!v }; }, async getMutes(e) { const r = await db.q('SELECT chat_mutes FROM accounts WHERE email=?', [e]); try { const arr = JSON.parse((r[0] && r[0].chat_mutes) || '[]'); return Array.isArray(arr) ? arr : []; } catch (x) { return []; } }, async setMute(owner, target, muted) { const cur = await this.getMutes(owner); const set = new Set(cur.map(x => String(x).toLowerCase())); const t = String(target).toLowerCase(); if (muted) set.add(t); else set.delete(t); await db.q('UPDATE accounts SET chat_mutes=? WHERE email=?', [JSON.stringify([...set].slice(0, 500)), owner]); return { ok: true, muted: !!muted }; }, async byMemberId(id) { const r = await db.q('SELECT * FROM accounts WHERE member_id=?', [Number(id)]); return rowPub(r[0]); }, async setMemberId(e, id) { await db.q('UPDATE accounts SET member_id=? WHERE email=? AND (member_id IS NULL OR member_id<>?)', [id, e, id]); }, async namesForMembers(ids) { if (!ids.length) return {}; const rows = await db.q('SELECT member_id, username FROM accounts WHERE username IS NOT NULL AND member_id IN (' + ids.map(() => '?').join(',') + ')', ids); const out = {}; for (const r of rows) out[r.member_id] = r.username; return out; }, async listByReferrer(refs) { const clean = refs.filter(Boolean).map(String); if (!clean.length) return []; const rows = await db.q('SELECT * FROM accounts WHERE sponsor_ref IN (' + clean.map(() => '?').join(',') + ') ORDER BY created DESC LIMIT 200', clean); return rows.map(rowPub); }, 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 listAll(limit) { const rows = await db.q('SELECT * FROM accounts ORDER BY created DESC LIMIT ?', [Number(limit) || 500]); return rows.map(rowPub); }, async setSponsorRef(e, ref) { const r = await db.q('UPDATE accounts SET sponsor_ref=? WHERE email=?', [ref, e]); if (!r.affectedRows) return { error: 'No such account.' }; 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.' }; return impl().signup(e, String(password), String(sponsorRef || '')); } async function login(email, password) { return impl().login(normEmail(email), String(password || '')); } async function ensure(email, sponsorRef, via) { const e = normEmail(email); if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' }; return impl().ensure(e, String(sponsorRef || ''), String(via || '').toLowerCase().slice(0, 20) || null); } 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 byUsername(u) { const n = normUser(u); return USER_RE.test(n) ? impl().byUsername(n) : null; } async function setUsername(email, username) { const n = normUser(username); if (!USER_RE.test(n)) return { error: 'Usernames are 3 to 20 letters, numbers, or underscores.' }; if (/^\d+$/.test(n)) return { error: 'Usernames need at least one letter.' }; // keep /join/ unambiguous return impl().setUsername(normEmail(email), n); } async function setMemberId(email, id) { return impl().setMemberId(normEmail(email), Number(id) || 0); } async function namesForMembers(ids) { return impl().namesForMembers([...new Set(ids)].filter(n => n > 0)); } async function listByReferrer(refs) { return impl().listByReferrer(refs || []); } // the tokens a member could have been joined under (code, username, or numeric id) function refTokens(a) { return [a.code, a.username, a.memberId ? String(a.memberId) : null].filter(Boolean).map(String); } // walk the downline breadth-first to `depth` levels. Returns // [{ level, members:[pub...] }]; emails are included on pub but the CALLER // decides who may see them (directs only, per product rule). async function downline(email, depth = 3) { const root = await byEmail(email); if (!root) return []; const seen = new Set([String(root.email)]); const levels = []; let frontier = [root]; for (let lvl = 1; lvl <= depth; lvl++) { const toks = [...new Set(frontier.flatMap(refTokens))]; if (!toks.length) break; const kids = (await listByReferrer(toks)).filter(k => !seen.has(String(k.email))); if (!kids.length) break; kids.forEach(k => seen.add(String(k.email))); levels.push({ level: lvl, members: kids }); frontier = kids; } return levels; } async function linkWallet(email, address) { const a = normAddr(address); if (!/^0x[0-9a-f]{40}$/.test(a)) return { error: 'Bad wallet address.' }; return impl().linkWallet(normEmail(email), a); } async function count() { return impl().count(); } // admin: newest-first account list, and re-pointing a member's sponsor (a // username, share code, or numeric member id: the same tokens join links use) async function listAll(limit = 500) { return impl().listAll(limit); } async function setSponsorRef(email, ref) { return impl().setSponsorRef(normEmail(email), String(ref || '').trim().toLowerCase().slice(0, 40)); } // resolve a member's DIRECT sponsor account (the token they joined under) async function sponsorOf(email) { const root = await byEmail(email); if (!root || !root.sponsorRef) return null; const ref = String(root.sponsorRef); let s = null; try { s = await byCode(ref); } catch (e) {} if (!s) { try { s = await byUsername(ref); } catch (e) {} } if (!s && /^\d+$/.test(ref)) { try { s = await byMemberId(Number(ref)); } catch (e) {} } return s || null; } // is `memberEmail` anywhere in `sponsorEmail`'s downline (to `depth` levels)? async function isDownlineOf(sponsorEmail, memberEmail, depth = 3) { const target = String(memberEmail || '').toLowerCase(); const levels = await downline(sponsorEmail, depth); return levels.some(L => L.members.some(m => String(m.email || '').toLowerCase() === target)); } async function getChatSettings(email) { const a = await byEmail(email); return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) }; } module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, listAll, setSponsorRef, setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count, setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t), setWallOffers: (e, j) => impl().setWallOffers(String(e || '').toLowerCase(), j), setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials), touchSeen: e => impl().touchSeen(String(e || '').toLowerCase()), setChatAvailable: (e, v) => impl().setChatAvailable(String(e || '').toLowerCase(), v), getMutes: e => impl().getMutes(String(e || '').toLowerCase()), setMute: (o, t, m) => impl().setMute(String(o || '').toLowerCase(), String(t || '').toLowerCase(), m), sponsorOf, isDownlineOf, getChatSettings, byMemberId: id => impl().byMemberId(id) };