Files
exbyte-depot-viewer-perforce/waitlist-server/server.js
Bonchellon 3954082e8d 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>
2026-07-08 20:40:53 +03:00

210 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"})`);
});