1ee0b5626e
- Welcome tour (3 levels x 10s) unlocks welcome credits; line banner in Profile; public /wall/<username> - 'Your next move' redesigned as a milestone stepper - Solo composer: BV-style rich editor (H2/H3, inline image+video, undo/redo, raw text) - Solo read reward now requires clicking through to the advertiser, not just dwelling - Sanitizer: inline media whitelist + script/style stripped whole Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
275 lines
12 KiB
JavaScript
275 lines
12 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,
|
|
lineBannerUrl: a.lineBannerUrl || null, lineTargetUrl: a.lineTargetUrl || null,
|
|
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) {
|
|
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 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 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 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,
|
|
lineBannerUrl: r.line_banner_url, lineTargetUrl: r.line_target_url,
|
|
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 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 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 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) {
|
|
const e = normEmail(email);
|
|
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
|
return impl().ensure(e, String(sponsorRef || ''));
|
|
}
|
|
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 || []); }
|
|
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(); }
|
|
|
|
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
|
|
setUsername, setMemberId, namesForMembers, listByReferrer, linkWallet, count,
|
|
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
|
|
byMemberId: id => impl().byMemberId(id) };
|