// Exbyte Depot — waitlist collector + secure admin panel. // // Public: serves the landing page and accepts POST /api/waitlist (dedup). // Admin: password-gated (scrypt), rate-limited, signed-cookie session. // view signups + export CSV. No plaintext secrets in the codebase. import express from "express"; import rateLimit from "express-rate-limit"; import path from "node:path"; import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { addEmail, countEmails, listEmails } from "./lib/db.js"; import { verifyPassword, safeEqual, issueToken, requireAuth, setSessionCookie, clearSessionCookie, } from "./lib/auth.js"; // --- load .env (built into Node, no dotenv dependency) ---------------------- const __dirname = path.dirname(fileURLToPath(import.meta.url)); try { process.loadEnvFile(path.join(__dirname, ".env")); } catch { /* no .env file — rely on real environment variables */ } const { PORT = 8787, NODE_ENV = "development", ADMIN_USER, ADMIN_PASS_HASH, SESSION_SECRET, TRUST_PROXY = "0", LANDING_DIR = path.join(__dirname, "..", "landing"), } = process.env; const IS_PROD = NODE_ENV === "production"; const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours // --- refuse to boot without the security-critical config -------------------- const missing = []; if (!ADMIN_USER) missing.push("ADMIN_USER"); if (!ADMIN_PASS_HASH) missing.push("ADMIN_PASS_HASH"); if (!SESSION_SECRET || SESSION_SECRET.length < 24) missing.push("SESSION_SECRET (>=24 chars)"); if (missing.length) { console.error( `\n[waitlist] Refusing to start — missing/weak config: ${missing.join(", ")}\n` + ` 1) cp .env.example .env\n` + ` 2) npm run gen-secret # paste into SESSION_SECRET\n` + ` 3) npm run set-password # paste into ADMIN_PASS_HASH\n` ); process.exit(1); } const app = express(); app.disable("x-powered-by"); if (TRUST_PROXY !== "0") app.set("trust proxy", Number(TRUST_PROXY) || 1); app.use(express.json({ limit: "16kb" })); app.use(express.urlencoded({ extended: false, limit: "16kb" })); // --- security headers (self-contained; no external origins) ----------------- app.use((req, res, next) => { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); res.setHeader( "Content-Security-Policy", [ "default-src 'self'", "style-src 'self' 'unsafe-inline'", "script-src 'self' 'unsafe-inline'", "img-src 'self' data:", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ].join("; ") ); next(); }); // --- rate limiters ---------------------------------------------------------- const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 8, // per IP per window standardHeaders: true, legacyHeaders: false, message: { error: "Too many attempts. Try again later." }, }); const waitlistLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, message: { error: "Too many requests. Slow down." }, }); // --- helpers ---------------------------------------------------------------- const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; function normalizeEmail(v) { return String(v || "").trim().toLowerCase(); } function clientIp(req) { return (req.ip || "").replace(/^::ffff:/, "") || null; } function sendView(res, file, replacements = {}) { let html = fs.readFileSync(path.join(__dirname, "views", file), "utf8"); for (const [k, v] of Object.entries(replacements)) { html = html.replaceAll(`{{${k}}}`, v); } res.type("html").send(html); } // ============================================================================ // PUBLIC — waitlist signup // ============================================================================ app.post("/api/waitlist", waitlistLimiter, (req, res) => { // honeypot: bots fill hidden fields; humans leave them blank if (req.body && typeof req.body.website === "string" && req.body.website.trim() !== "") { return res.json({ ok: true, deduped: true }); // silently accept, store nothing } const email = normalizeEmail(req.body?.email); if (!email || email.length > 254 || !EMAIL_RE.test(email)) { return res.status(400).json({ ok: false, error: "Enter a valid email." }); } try { const { added } = addEmail({ email, source: (req.body?.source || "landing").toString().slice(0, 64), ip: clientIp(req), userAgent: (req.headers["user-agent"] || "").slice(0, 300), }); return res.json({ ok: true, added, deduped: !added }); } catch (e) { console.error("[waitlist] insert failed:", e.message); return res.status(500).json({ ok: false, error: "Server error." }); } }); // ============================================================================ // ADMIN — auth // ============================================================================ app.get("/admin/login", (req, res) => sendView(res, "login.html")); app.post("/admin/login", loginLimiter, (req, res) => { const user = String(req.body?.username || ""); const pass = String(req.body?.password || ""); const ok = safeEqual(user, ADMIN_USER) && verifyPassword(pass, ADMIN_PASS_HASH); if (!ok) { return res.status(401).redirect("/admin/login?error=1"); } const token = issueToken(ADMIN_USER, SESSION_SECRET, SESSION_TTL_MS); setSessionCookie(res, token, { secure: IS_PROD, ttlMs: SESSION_TTL_MS }); res.redirect("/admin"); }); app.post("/admin/logout", (req, res) => { clearSessionCookie(res, { secure: IS_PROD }); res.redirect("/admin/login"); }); // everything below requires a valid session app.use("/admin", requireAuth(SESSION_SECRET)); app.get("/admin", (req, res) => sendView(res, "admin.html", { USER: req.admin.u })); app.get("/admin/api/emails", (req, res) => { res.json({ count: countEmails(), emails: listEmails() }); }); // CSV export app.get("/admin/export.csv", (req, res) => { const rows = listEmails(); const esc = (v) => { const s = v == null ? "" : String(v); return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; }; const header = ["email", "source", "ip", "created_at"]; const lines = [header.join(",")]; for (const r of rows) { lines.push([r.email, r.source, r.ip, r.created_at].map(esc).join(",")); } const csv = "" + lines.join("\r\n"); // BOM so Excel reads UTF-8 const stamp = new Date().toISOString().slice(0, 10); res.setHeader("Content-Type", "text/csv; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="waitlist-${stamp}.csv"`); res.send(csv); }); // ============================================================================ // LANDING (static) — served last so /api and /admin win // ============================================================================ app.use( express.static(LANDING_DIR, { extensions: ["html"], setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"), }) ); app.listen(PORT, () => { console.log(`[waitlist] listening on http://localhost:${PORT}`); console.log(`[waitlist] landing: ${LANDING_DIR}`); console.log(`[waitlist] admin: http://localhost:${PORT}/admin (${IS_PROD ? "prod" : "dev"})`); });