8656f7ce4d
The Members page loads the newest 500 accounts and built its sponsor name lookup from that same list. Once the site passed 500 accounts every sponsor who joined before that window became unfindable, so the page labelled them "dead link: <name>" with a tooltip saying the member would fall into the holding tank. It was doing that to 144 of the 500 rows, including cryptoassets 76 times, and not one token on the whole site is genuinely unresolvable. Sponsorship itself was never affected: assignment at purchase time looks each token up directly rather than scanning a list. accounts.identities() returns the identity columns for every account with no limit, which stays cheap at any size, and the page uses that for the lookup while still showing 500 rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
501 lines
28 KiB
JavaScript
501 lines
28 KiB
JavaScript
// 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, joinedRef: a.joinedRef || 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 = {};
|
|
if (!this.db.positions) this.db.positions = {};
|
|
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, joinedRef) {
|
|
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, joinedRef: joinedRef || 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.' };
|
|
if (this.db.positions[a]) return { error: 'That wallet is already a linked position' + (this.db.positions[a].email === e ? ' on this account.' : ' on 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); },
|
|
// every account's identity, never a page of them: a lookup built from a capped list silently
|
|
// calls every older member unreachable (the admin table labelled 144 live sponsors 'dead link'
|
|
// once the site passed 500 accounts, Marty 2026-09-23)
|
|
async identities() { return Object.values(this.db.byEmail).map(a => ({ email: a.email, username: a.username || null, code: a.code || null, memberId: a.memberId || 0 })); },
|
|
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; },
|
|
// ---- linked positions (extra wallets on one account) ----
|
|
async positions(e) {
|
|
return Object.entries(this.db.positions).filter(([, p]) => p.email === e)
|
|
.map(([address, p]) => ({ address, email: p.email, memberId: p.memberId || 0, created: p.created }))
|
|
.sort((x, y) => x.created - y.created);
|
|
},
|
|
async addPosition(e, a) {
|
|
const acct = this.db.byEmail[e];
|
|
if (!acct) return { error: 'No such account.' };
|
|
if (!acct.address) return { error: 'Link your main wallet first.' };
|
|
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
|
if (this.db.byAddress[a]) return { error: 'That wallet is already the main wallet of another account.' };
|
|
const cur = this.db.positions[a];
|
|
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
|
if (!cur) { this.db.positions[a] = { email: e, memberId: 0, created: Date.now() }; this.save(); }
|
|
return { ok: true, address: a, created: !cur };
|
|
},
|
|
async setPositionMember(a, id) { const p = this.db.positions[a]; if (p && p.memberId !== id) { p.memberId = id; this.save(); } },
|
|
async positionOwner(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId || 0 } : null; },
|
|
async positionByMember(id) { const e = Object.entries(this.db.positions).find(([, p]) => p.memberId === Number(id)); return e ? { address: e[0], email: e[1].email, memberId: e[1].memberId } : null; },
|
|
async removePosition(e, a) {
|
|
const p = this.db.positions[a];
|
|
if (!p || p.email !== e) return { error: 'No such position.' };
|
|
if (p.memberId) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
|
delete this.db.positions[a]; this.save(); return { ok: true };
|
|
},
|
|
// admin only: point the account at a different main wallet (or none); the caller re-reads the member id
|
|
async adminSetAddress(e, a) {
|
|
const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
|
|
if (a) {
|
|
if (this.db.byAddress[a] && this.db.byAddress[a] !== e) return { error: 'That wallet is the main wallet of another account.' };
|
|
if (this.db.positions[a]) return { error: 'That wallet is a linked position on ' + (this.db.positions[a].email === e ? 'this' : 'another') + ' account.' };
|
|
}
|
|
if (acct.address) delete this.db.byAddress[acct.address];
|
|
acct.address = a || null; if (a) this.db.byAddress[a] = e; this.save(); return { ok: true, account: pub(acct) };
|
|
},
|
|
async positionByAddress(a) { const p = this.db.positions[a]; return p ? { address: a, email: p.email, memberId: p.memberId } : null; },
|
|
async removeAccount(e) {
|
|
const acct = this.db.byEmail[e]; if (!acct) return { error: 'No such account.' };
|
|
if (acct.address) delete this.db.byAddress[acct.address]; if (acct.code) delete this.db.byCode[acct.code];
|
|
for (const [a, p] of Object.entries(this.db.positions)) if (p.email === e) delete this.db.positions[a];
|
|
delete this.db.byEmail[e]; this.save(); return { ok: true };
|
|
},
|
|
};
|
|
|
|
// ---- 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, joinedRef: r.joined_ref || 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, joinedRef) {
|
|
const code = newCode();
|
|
let created = false;
|
|
try {
|
|
await db.q('INSERT INTO accounts (email,pass,sponsor_ref,code,address,created,joined_via,joined_ref) VALUES (?,NULL,?,?,NULL,?,?,?)',
|
|
[e, ref, code, Date.now(), via || null, joinedRef || 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.' };
|
|
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0];
|
|
if (pos) return { error: 'That wallet is already a linked position' + (pos.email === e ? ' on this account.' : ' on a different account.') };
|
|
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); },
|
|
// identity columns for EVERY account, no limit: small enough to stay cheap at any size, and a
|
|
// sponsor lookup must never be built from a page of members (see the JSON note above)
|
|
async identities() { const rows = await db.q('SELECT email, username, code, member_id FROM accounts');
|
|
return rows.map(r => ({ email: r.email, username: r.username || null, code: r.code || null, memberId: Number(r.member_id) || 0 })); },
|
|
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); },
|
|
// ---- linked positions (extra wallets on one account) ----
|
|
async positions(e) {
|
|
const rows = await db.q('SELECT * FROM positions WHERE email=? ORDER BY created', [e]);
|
|
return rows.map(r => ({ address: r.address, email: r.email, memberId: r.member_id || 0, created: Number(r.created) }));
|
|
},
|
|
async addPosition(e, a) {
|
|
const acct = await this.byEmail(e);
|
|
if (!acct) return { error: 'No such account.' };
|
|
if (!acct.address) return { error: 'Link your main wallet first.' };
|
|
if (acct.address === a) return { error: 'That is your main wallet. Switch to a different account in your wallet app, then try again.' };
|
|
if (await this.byAddress(a)) return { error: 'That wallet is already the main wallet of another account.' };
|
|
const cur = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
|
if (cur && cur.email !== e) return { error: 'That wallet is already a position on a different account.' };
|
|
if (!cur) await db.q('INSERT INTO positions (address,email,member_id,created) VALUES (?,?,0,?)', [a, e, Date.now()]);
|
|
return { ok: true, address: a, created: !cur };
|
|
},
|
|
async setPositionMember(a, id) { await db.q('UPDATE positions SET member_id=? WHERE address=? AND member_id<>?', [id, a, id]); },
|
|
async positionOwner(a) {
|
|
const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0];
|
|
return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null;
|
|
},
|
|
async positionByMember(id) { const r = await db.q('SELECT address, email, member_id FROM positions WHERE member_id=? LIMIT 1', [Number(id)]); return r[0] ? { address: r[0].address, email: r[0].email, memberId: r[0].member_id } : null; },
|
|
async removePosition(e, a) {
|
|
const r = (await db.q('SELECT * FROM positions WHERE address=? AND email=?', [a, e]))[0];
|
|
if (!r) return { error: 'No such position.' };
|
|
if (r.member_id) return { error: 'That position is already registered on-chain and cannot be unlinked.' };
|
|
await db.q('DELETE FROM positions WHERE address=?', [a]); return { ok: true };
|
|
},
|
|
async adminSetAddress(e, a) {
|
|
if (a) {
|
|
const o = (await db.q('SELECT email FROM accounts WHERE address=?', [a]))[0]; if (o && o.email !== e) return { error: 'That wallet is the main wallet of another account.' };
|
|
const pos = (await db.q('SELECT email FROM positions WHERE address=?', [a]))[0]; if (pos) return { error: 'That wallet is a linked position on ' + (pos.email === e ? 'this' : 'another') + ' account.' };
|
|
}
|
|
await db.q('UPDATE accounts SET address=? WHERE email=?', [a || null, e]); return { ok: true, account: await this.byEmail(e) };
|
|
},
|
|
async positionByAddress(a) { const r = (await db.q('SELECT * FROM positions WHERE address=?', [a]))[0]; return r ? { address: r.address, email: r.email, memberId: r.member_id || 0 } : null; },
|
|
async removeAccount(e) {
|
|
await db.q('DELETE FROM positions WHERE email=?', [e]);
|
|
const r = await db.q('DELETE FROM accounts WHERE email=?', [e]); return r.affectedRows ? { ok: true } : { error: 'No such account.' };
|
|
},
|
|
};
|
|
|
|
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, joinedRef) {
|
|
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, String(joinedRef || '').toLowerCase().slice(0, 80) || 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/<number> 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 identities() { return impl().identities(); }
|
|
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, byMemberId: id => impl().byMemberId(Number(id) || 0), listAll, identities, 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()),
|
|
adminSetAddress: (e, a) => impl().adminSetAddress(String(e || '').toLowerCase(), a ? normAddr(a) : null),
|
|
positionByAddress: a => impl().positionByAddress(normAddr(a)),
|
|
removeAccount: e => impl().removeAccount(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,
|
|
positions: e => impl().positions(normEmail(e)),
|
|
addPosition: (e, a) => { const x = normAddr(a); return /^0x[0-9a-f]{40}$/.test(x) ? impl().addPosition(normEmail(e), x) : Promise.resolve({ error: 'Bad wallet address.' }); },
|
|
setPositionMember: (a, id) => impl().setPositionMember(normAddr(a), Number(id) || 0),
|
|
positionOwner: a => impl().positionOwner(normAddr(a)),
|
|
positionByMember: id => impl().positionByMember(Number(id) || 0),
|
|
removePosition: (e, a) => impl().removePosition(normEmail(e), normAddr(a)),
|
|
byMemberId: id => impl().byMemberId(id) };
|