Admin portal (/admin) + free house ads; POL amounts show two decimals
- /admin: email magic-code sign-in allowlisted to ADMIN_EMAIL, 12h admin session (cookie iap.adm, persisted in the volume). Bearer ADMIN_PASSWORD API access still works. Member area shows an Admin link for that email. - House ads: admin places banner/text/login/solo/video/featured/visits campaigns owned by house@instantadpay.com that cost nothing; budget is only a delivery cap, spend is never charged or burned. - Admin APIs: overview, all campaigns (+pause/resume any), members (+re-point sponsor), reports (+resolve), pending burns, rates/site config get+patch, creative upload. - fmtPol rounds to two decimals everywhere (dashboard, toasts, prices). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,30 @@ const ROOT = __dirname;
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
||||
const ADMIN_EMAIL = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
||||
// Admin portal sessions: email-code sign-in allowlisted to ADMIN_EMAIL, kept
|
||||
// in the volume so a restart doesn't log the admin out. Separate cookie and
|
||||
// store from member sessions; the Bearer ADMIN_PASSWORD API path still works.
|
||||
const ADMIN_SESS_FILE = path.join(DATA_DIR, 'admin-sessions.json');
|
||||
const ADMIN_TTL = 12 * 60 * 60 * 1000;
|
||||
let adminSessions = {};
|
||||
try { adminSessions = JSON.parse(fs.readFileSync(ADMIN_SESS_FILE, 'utf8')) || {}; } catch (e) { adminSessions = {}; }
|
||||
function saveAdminSessions() {
|
||||
const now = Date.now();
|
||||
for (const k of Object.keys(adminSessions)) if (!adminSessions[k] || adminSessions[k].expires < now) delete adminSessions[k];
|
||||
try { fs.writeFileSync(ADMIN_SESS_FILE, JSON.stringify(adminSessions), { mode: 0o600 }); } catch (e) {}
|
||||
}
|
||||
function mintAdminSession(email) {
|
||||
const t = crypto.randomBytes(32).toString('hex');
|
||||
adminSessions[t] = { email, expires: Date.now() + ADMIN_TTL };
|
||||
saveAdminSessions();
|
||||
return t;
|
||||
}
|
||||
function adminTokenOf(req) { const m = /(?:^|;\s*)iap\.adm=([^;]+)/.exec(req.headers.cookie || ''); return m ? decodeURIComponent(m[1]) : null; }
|
||||
function adminFromRequest(req) { const t = adminTokenOf(req); const s = t && adminSessions[t]; return (s && s.expires > Date.now()) ? s : null; }
|
||||
function dropAdminSession(req) { const t = adminTokenOf(req); if (t && adminSessions[t]) { delete adminSessions[t]; saveAdminSessions(); } }
|
||||
function adminCookie(t) { return 'iap.adm=' + encodeURIComponent(t) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (ADMIN_TTL / 1000) + (IS_PROD ? '; Secure' : ''); }
|
||||
function clearAdminCookie() { return 'iap.adm=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; }
|
||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
||||
|
||||
@@ -112,6 +136,40 @@ function frameFetch(url, depth) {
|
||||
});
|
||||
});
|
||||
}
|
||||
// shared upload path for member creatives (/api/my/upload) and admin house-ad
|
||||
// creatives (/api/admin/upload): `who` keys the per-day upload counter
|
||||
async function handleUpload(req, res, who) {
|
||||
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
||||
'video/mp4': 'mp4', 'video/webm': 'webm' };
|
||||
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
|
||||
const isVideo = ct.startsWith('video/');
|
||||
const key = who + ':' + new Date().toISOString().slice(0, 10);
|
||||
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
|
||||
let buf;
|
||||
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
|
||||
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
|
||||
const magicOk = buf.length > 16 && (
|
||||
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
|
||||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
|
||||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
|
||||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
|
||||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
|
||||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
|
||||
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
|
||||
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
|
||||
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
||||
// video goes to DO Spaces when configured (keeps big files off the volume);
|
||||
// images stay local. Falls back to the volume if Spaces isn't set or errors.
|
||||
if (isVideo && spaces.enabled()) {
|
||||
try {
|
||||
const url = await spaces.put('iap-uploads/' + name, buf, ct);
|
||||
return json(res, 200, { url, type: 'video' });
|
||||
} catch (e) { console.error('spaces put', e.message); /* fall through to volume */ }
|
||||
}
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
|
||||
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
async function frameCheck(url) {
|
||||
const h = await frameFetch(url, 0);
|
||||
if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' };
|
||||
@@ -214,7 +272,8 @@ function parseCookies(req) {
|
||||
}
|
||||
function isAdmin(req) {
|
||||
const h = req.headers.authorization || '';
|
||||
return h === 'Bearer ' + ADMIN_PASSWORD;
|
||||
if (h === 'Bearer ' + ADMIN_PASSWORD) return true;
|
||||
return !!adminFromRequest(req); // /admin portal session
|
||||
}
|
||||
// attach a memberId->username map to events so activity shows real people
|
||||
async function attachNames(evts) {
|
||||
@@ -657,6 +716,7 @@ const server = http.createServer(async (req, res) => {
|
||||
joined: r.created,
|
||||
status: r.address ? 'wallet linked' : 'joined free'
|
||||
}));
|
||||
out.isAdmin = !!(ADMIN_EMAIL && out.email && String(out.email).toLowerCase() === ADMIN_EMAIL); // shows the Admin link
|
||||
return json(res, 200, out);
|
||||
}
|
||||
if (p === '/api/my/profile' && req.method === 'POST') {
|
||||
@@ -1175,36 +1235,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/api/my/upload' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
|
||||
'video/mp4': 'mp4', 'video/webm': 'webm' };
|
||||
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
|
||||
const isVideo = ct.startsWith('video/');
|
||||
const key = s.email + ':' + new Date().toISOString().slice(0, 10);
|
||||
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
|
||||
let buf;
|
||||
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
|
||||
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
|
||||
const magicOk = buf.length > 16 && (
|
||||
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
|
||||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
|
||||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
|
||||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
|
||||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
|
||||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
|
||||
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
|
||||
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
|
||||
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
|
||||
// video goes to DO Spaces when configured (keeps big files off the volume);
|
||||
// images stay local. Falls back to the volume if Spaces isn't set or errors.
|
||||
if (isVideo && spaces.enabled()) {
|
||||
try {
|
||||
const url = await spaces.put('iap-uploads/' + name, buf, ct);
|
||||
return json(res, 200, { url, type: 'video' });
|
||||
} catch (e) { console.error('spaces put', e.message); /* fall through to volume */ }
|
||||
}
|
||||
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
|
||||
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
|
||||
return handleUpload(req, res, s.email);
|
||||
}
|
||||
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
|
||||
if (m && req.method === 'POST') {
|
||||
@@ -1289,7 +1320,116 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
|
||||
// -- admin (Bearer ADMIN_PASSWORD)
|
||||
// -- admin portal: email magic-code sign-in, allowlisted to ADMIN_EMAIL
|
||||
if (p === '/api/admin/auth/start' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const e = String(b.email || '').trim().toLowerCase();
|
||||
if (!ADMIN_EMAIL) return json(res, 503, { error: 'ADMIN_EMAIL is not set on the server.' });
|
||||
if (!e || e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' });
|
||||
const k = 'admin:' + e;
|
||||
const prev = emailCodes.get(k);
|
||||
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
emailCodes.set(k, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
|
||||
if (mailer.hasKey()) {
|
||||
try { await mailer.sendCode(e, code); } catch (err) {
|
||||
console.error('admin sendCode failed', err.message);
|
||||
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
||||
}
|
||||
return json(res, 200, { ok: true, sent: true });
|
||||
}
|
||||
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
|
||||
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
||||
}
|
||||
if (p === '/api/admin/auth/verify' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const e = String(b.email || '').trim().toLowerCase();
|
||||
const k = 'admin:' + e;
|
||||
const rec = emailCodes.get(k);
|
||||
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
|
||||
rec.tries += 1;
|
||||
if (rec.tries > 6) { emailCodes.delete(k); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
|
||||
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
|
||||
emailCodes.delete(k);
|
||||
if (e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' });
|
||||
const token = mintAdminSession(e);
|
||||
return json(res, 200, { ok: true, email: e }, { 'Set-Cookie': adminCookie(token) });
|
||||
}
|
||||
if (p === '/api/admin/auth/logout' && req.method === 'POST') {
|
||||
dropAdminSession(req);
|
||||
return json(res, 200, { ok: true }, { 'Set-Cookie': clearAdminCookie() });
|
||||
}
|
||||
if (p === '/api/admin/me' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 200, { admin: false });
|
||||
return json(res, 200, { admin: true, email: ADMIN_EMAIL });
|
||||
}
|
||||
if (p === '/api/admin/overview' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const camps = await ads.adminList();
|
||||
const byStatus = {}, byType = {};
|
||||
for (const c of camps) { byStatus[c.status] = (byStatus[c.status] || 0) + 1; byType[c.type] = (byType[c.type] || 0) + 1; }
|
||||
let memberCount = null; try { memberCount = await chain.memberCount(); } catch (e) {}
|
||||
const cc = chain.getConfig();
|
||||
return json(res, 200, { accounts: await accounts.count(), memberCount, campaigns: camps.length,
|
||||
house: camps.filter(c => c.house).length, byStatus, byType,
|
||||
openReports: await reports.openCount(), pendingBurns: (await ads.pendingBurns()).length,
|
||||
chain: { contract: cc.contract, chainId: cc.chainId, chainName: cc.chainName, explorer: cc.explorer },
|
||||
site: siteConfig(), rates: ads.rates() });
|
||||
}
|
||||
if (p === '/api/admin/members' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { members: await accounts.listAll(500) });
|
||||
}
|
||||
if (p === '/api/admin/members' && req.method === 'PATCH') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const b = await readBody(req);
|
||||
if (!b.email) return json(res, 400, { error: 'Which member?' });
|
||||
const r = await accounts.setSponsorRef(b.email, b.sponsorRef);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/admin/campaigns' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { campaigns: await ads.adminList(), rates: ads.rates(), bannerSizes: ads.bannerSizes(), houseOwner: ads.HOUSE_OWNER });
|
||||
}
|
||||
if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const b = await readBody(req);
|
||||
if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) {
|
||||
const fc = await frameCheck(b.targetUrl);
|
||||
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||||
}
|
||||
const r = await ads.createHouseCampaign(b);
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
m = /^\/api\/admin\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
|
||||
if (m && req.method === 'POST') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
const r = await ads.adminSetStatus(m[1], m[2] === 'pause' ? 'paused' : 'active');
|
||||
return json(res, r.error ? 400 : 200, r);
|
||||
}
|
||||
if (p === '/api/admin/reports' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { reports: await reports.list(200) });
|
||||
}
|
||||
m = /^\/api\/admin\/reports\/(\d+)\/resolve$/.exec(p);
|
||||
if (m && req.method === 'POST') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, await reports.resolve(m[1]));
|
||||
}
|
||||
if (p === '/api/admin/upload' && req.method === 'POST') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return handleUpload(req, res, 'admin');
|
||||
}
|
||||
if (p === '/api/admin/rates' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { rates: ads.rates() });
|
||||
}
|
||||
if (p === '/api/admin/site' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { site: siteConfig() });
|
||||
}
|
||||
|
||||
// -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session)
|
||||
if (p === '/api/admin/burns' && req.method === 'GET') {
|
||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||
return json(res, 200, { pending: await ads.pendingBurns() });
|
||||
@@ -1332,6 +1472,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/privacy') return sendFile(res, path.join(PUBLIC_DIR, 'privacy.html'));
|
||||
if (p === '/disclaimer') return sendFile(res, path.join(PUBLIC_DIR, 'disclaimer.html'));
|
||||
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
||||
if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html'));
|
||||
if (p === '/shorts') return sendFile(res, path.join(PUBLIC_DIR, 'shorts.html'));
|
||||
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
|
||||
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
|
||||
|
||||
Reference in New Issue
Block a user