Client: - Commit: batch `p4 reopen` so huge changelists (63k+) no longer blow the Windows command-line limit; live "N / total" progress + spinner during commit. - Submit dialog: white arrow, "N of total" + % bar, running file log, Cancel button (aborts pre-commit; changelist stays pending). - Disk scan: streaming reconcile with a live "found N files" counter + Cancel. - Thumbnails: only decode browser-renderable image formats; glyph fallback (+ onError) instead of a broken-image icon for bmp/tga/dds/etc. Landing + waitlist: - waitlist-server: Express + SQLite email collector (dedup) with a secure admin panel (scrypt auth, rate-limited login, signed cookies) and CSV export. - landing form now POSTs signups to the waitlist API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
7.3 KiB
JavaScript
210 lines
7.3 KiB
JavaScript
// 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"})`);
|
||
});
|