v0.3.2 — commit/submit progress, cancel, thumbnails, waitlist landing+admin

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>
This commit is contained in:
Bonchellon
2026-07-08 20:40:53 +03:00
parent 5652f489a2
commit 3954082e8d
21 changed files with 3753 additions and 144 deletions

209
waitlist-server/server.js Normal file
View File

@ -0,0 +1,209 @@
// 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"})`);
});