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:
18
waitlist-server/.env.example
Normal file
18
waitlist-server/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. NEVER commit .env.
|
||||
|
||||
# Admin login
|
||||
ADMIN_USER=admin
|
||||
# Generate with: npm run set-password
|
||||
ADMIN_PASS_HASH=
|
||||
|
||||
# Session signing key (>=24 chars). Generate with: npm run gen-secret
|
||||
SESSION_SECRET=
|
||||
|
||||
# Server
|
||||
PORT=8787
|
||||
NODE_ENV=development
|
||||
# Set to 1 (or the hop count) when running behind nginx/Caddy so client IPs are correct
|
||||
TRUST_PROXY=0
|
||||
|
||||
# Path to the landing page folder (default: ../landing)
|
||||
# LANDING_DIR=../landing
|
||||
4
waitlist-server/.gitignore
vendored
Normal file
4
waitlist-server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
51
waitlist-server/README.md
Normal file
51
waitlist-server/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Exbyte Depot — Waitlist server + admin
|
||||
|
||||
Collects waitlist emails from the landing page (deduplicated) and gives you a
|
||||
password-protected admin panel to browse them and export to CSV.
|
||||
|
||||
## Stack
|
||||
- **express** — HTTP + static landing
|
||||
- **better-sqlite3** — storage; `UNIQUE COLLATE NOCASE` on email → no duplicates, ever
|
||||
- **express-rate-limit** — brute-force protection on the login endpoint
|
||||
- **node:crypto** — scrypt password hashing + HMAC-signed session cookies (no extra deps)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd waitlist-server
|
||||
npm install
|
||||
|
||||
cp .env.example .env
|
||||
npm run gen-secret # → paste into SESSION_SECRET in .env
|
||||
npm run set-password # type a password → paste the ADMIN_PASS_HASH line into .env
|
||||
# set ADMIN_USER in .env (default: admin)
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
- Landing: http://localhost:8787/
|
||||
- Admin: http://localhost:8787/admin
|
||||
|
||||
## How it works
|
||||
|
||||
- The landing form POSTs `{ email }` to **`POST /api/waitlist`**.
|
||||
- Invalid emails are rejected; a repeat email is silently deduped (no dup row).
|
||||
- A hidden honeypot field (`website`) traps bots.
|
||||
- **`/admin`** requires login. Session is a signed, HttpOnly, SameSite=Strict cookie
|
||||
(Secure in production), valid 8h. Wrong logins are rate-limited (8 / 15 min / IP).
|
||||
- **Export CSV**: `GET /admin/export.csv` streams all signups (UTF-8 + BOM for Excel).
|
||||
|
||||
## Security notes
|
||||
- No plaintext password anywhere — only a scrypt hash in `.env` (git-ignored).
|
||||
- Refuses to start without `ADMIN_USER`, `ADMIN_PASS_HASH`, and a strong `SESSION_SECRET`.
|
||||
- Security headers set (CSP, X-Frame-Options: DENY, nosniff, no-referrer).
|
||||
- Behind a reverse proxy, set `TRUST_PROXY=1` so rate-limiting and stored IPs are correct,
|
||||
and terminate TLS at the proxy (cookies become `Secure` when `NODE_ENV=production`).
|
||||
|
||||
## Deploy (Linux VPS, sketch)
|
||||
1. `NODE_ENV=production`, real `.env`, `npm ci --omit=dev`.
|
||||
2. Run under systemd (or pm2); put nginx/Caddy in front for TLS + `TRUST_PROXY=1`.
|
||||
3. Point your domain at it; the landing is served from `../landing` (or set `LANDING_DIR`).
|
||||
|
||||
## Data
|
||||
SQLite file at `data/waitlist.db` (git-ignored). Back it up to keep your list.
|
||||
129
waitlist-server/lib/auth.js
Normal file
129
waitlist-server/lib/auth.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
|
||||
//
|
||||
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
|
||||
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
|
||||
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
|
||||
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
|
||||
// session store to leak or lose on restart.
|
||||
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||
|
||||
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
||||
}
|
||||
|
||||
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
|
||||
export function verifyPassword(password, stored) {
|
||||
if (typeof stored !== "string") return false;
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(parts[1], "base64url");
|
||||
expected = Buffer.from(parts[2], "base64url");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== SCRYPT.keylen) return false;
|
||||
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return crypto.timingSafeEqual(got, expected);
|
||||
}
|
||||
|
||||
/** Constant-time string compare (for the username). */
|
||||
export function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// ---- signed session cookies -------------------------------------------------
|
||||
|
||||
const COOKIE_NAME = "exd_admin";
|
||||
|
||||
function sign(data, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
|
||||
}
|
||||
|
||||
/** Build a signed session token that expires after `ttlMs`. */
|
||||
export function issueToken(username, secret, ttlMs) {
|
||||
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
|
||||
const body = Buffer.from(payload).toString("base64url");
|
||||
return `${body}.${sign(body, secret)}`;
|
||||
}
|
||||
|
||||
/** Verify a session token → { u } if valid & unexpired, else null. */
|
||||
export function verifyToken(token, secret) {
|
||||
if (typeof token !== "string" || !token.includes(".")) return null;
|
||||
const [body, mac] = token.split(".");
|
||||
if (!body || !mac) return null;
|
||||
const expected = sign(body, secret);
|
||||
const a = Buffer.from(mac);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
|
||||
return { u: payload.u };
|
||||
}
|
||||
|
||||
/** Parse the admin session cookie out of a raw Cookie header. */
|
||||
export function readSessionCookie(req) {
|
||||
const raw = req.headers.cookie || "";
|
||||
for (const part of raw.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i === -1) continue;
|
||||
if (part.slice(0, i).trim() === COOKIE_NAME) {
|
||||
return decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token, { secure, ttlMs }) {
|
||||
const attrs = [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
"Path=/",
|
||||
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
||||
];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res, { secure }) {
|
||||
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
/** Express middleware: allow through only requests with a valid session. */
|
||||
export function requireAuth(secret) {
|
||||
return (req, res, next) => {
|
||||
const tok = readSessionCookie(req);
|
||||
const sess = tok && verifyToken(tok, secret);
|
||||
if (!sess) {
|
||||
// req.path is stripped of the mount prefix, so match on originalUrl
|
||||
const url = req.originalUrl || req.url || "";
|
||||
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
return res.redirect("/admin/login");
|
||||
}
|
||||
req.admin = sess;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export { COOKIE_NAME };
|
||||
56
waitlist-server/lib/db.js
Normal file
56
waitlist-server/lib/db.js
Normal file
@ -0,0 +1,56 @@
|
||||
// SQLite storage for waitlist signups. One row per unique email — dedup is
|
||||
// enforced by the DB itself (UNIQUE, case-insensitive), so a repeat signup is a
|
||||
// no-op rather than a duplicate row.
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, "..", "data");
|
||||
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, "waitlist.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
source TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const insertStmt = db.prepare(
|
||||
`INSERT INTO signups (email, source, ip, user_agent)
|
||||
VALUES (@email, @source, @ip, @user_agent)
|
||||
ON CONFLICT(email) DO NOTHING`
|
||||
);
|
||||
const countStmt = db.prepare(`SELECT COUNT(*) AS n FROM signups`);
|
||||
const listStmt = db.prepare(
|
||||
`SELECT id, email, source, ip, created_at FROM signups ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
/**
|
||||
* Add an email. Returns { added: true } on a new signup, { added: false } if the
|
||||
* email was already on the list (deduped — no duplicate stored).
|
||||
*/
|
||||
export function addEmail({ email, source = null, ip = null, userAgent = null }) {
|
||||
const info = insertStmt.run({ email, source, ip, user_agent: userAgent });
|
||||
return { added: info.changes > 0 };
|
||||
}
|
||||
|
||||
export function countEmails() {
|
||||
return countStmt.get().n;
|
||||
}
|
||||
|
||||
export function listEmails() {
|
||||
return listStmt.all();
|
||||
}
|
||||
|
||||
export default db;
|
||||
1271
waitlist-server/package-lock.json
generated
Normal file
1271
waitlist-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
waitlist-server/package.json
Normal file
22
waitlist-server/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "exbyte-depot-waitlist",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Waitlist collector + secure admin for the Exbyte Depot landing page",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"set-password": "node scripts/set-password.js",
|
||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('base64url'))\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0"
|
||||
}
|
||||
}
|
||||
37
waitlist-server/scripts/set-password.js
Normal file
37
waitlist-server/scripts/set-password.js
Normal file
@ -0,0 +1,37 @@
|
||||
// Generate an ADMIN_PASS_HASH for .env from a password.
|
||||
//
|
||||
// node scripts/set-password.js "my super secret"
|
||||
// node scripts/set-password.js # prompts (hidden input)
|
||||
//
|
||||
// Paste the printed line into .env — the plaintext password is never stored.
|
||||
|
||||
import readline from "node:readline";
|
||||
import { hashPassword } from "../lib/auth.js";
|
||||
|
||||
function ask(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
// hide typed characters
|
||||
const orig = rl._writeToOutput.bind(rl);
|
||||
rl._writeToOutput = (str) => {
|
||||
if (str.includes(question)) orig(str);
|
||||
else orig("*");
|
||||
};
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
process.stdout.write("\n");
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const argPw = process.argv.slice(2).join(" ").trim();
|
||||
const pw = argPw || (await ask("New admin password: "));
|
||||
|
||||
if (!pw || pw.length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nAdd this line to your .env:\n");
|
||||
console.log(`ADMIN_PASS_HASH=${hashPassword(pw)}\n`);
|
||||
209
waitlist-server/server.js
Normal file
209
waitlist-server/server.js
Normal 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"})`);
|
||||
});
|
||||
155
waitlist-server/views/admin.html
Normal file
155
waitlist-server/views/admin.html
Normal file
@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Waitlist · Exbyte Depot Admin</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
|
||||
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
|
||||
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
|
||||
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
|
||||
.logo svg{width:18px;height:18px}
|
||||
.htitle b{font-size:15px;font-weight:650}
|
||||
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
|
||||
.spacer{flex:1}
|
||||
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
|
||||
.who b{color:var(--txt);font-weight:600}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
|
||||
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
|
||||
.btn:hover{border-color:var(--acc);color:#fff}
|
||||
.btn svg{width:15px;height:15px}
|
||||
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
|
||||
.btn.accent:hover{filter:brightness(1.08)}
|
||||
.btn.ghost{background:transparent}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
|
||||
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
|
||||
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
|
||||
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
|
||||
.stat.accent .n{color:var(--acc-2)}
|
||||
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
|
||||
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
|
||||
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
|
||||
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
|
||||
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
|
||||
tbody tr{border-top:1px solid var(--border)}
|
||||
tbody tr:hover{background:#ffffff06}
|
||||
td.email{font-weight:550}
|
||||
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
|
||||
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
|
||||
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
|
||||
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
|
||||
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
|
||||
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
|
||||
<div class="spacer"></div>
|
||||
<span class="who">Signed in as <b>{{USER}}</b></span>
|
||||
<form method="POST" action="/admin/logout" style="display:inline">
|
||||
<button class="btn ghost" type="submit">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat accent"><div class="n" id="statTotal">—</div><div class="l">Total signups</div></div>
|
||||
<div class="stat"><div class="n" id="statShown">—</div><div class="l">Showing</div></div>
|
||||
<div class="stat"><div class="n" id="statLatest">—</div><div class="l">Latest signup</div></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
|
||||
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
|
||||
</div>
|
||||
<button class="btn" id="refresh" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
<a class="btn accent" href="/admin/export.csv">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" style="display:none">No signups yet.</div>
|
||||
</div>
|
||||
<div class="foot" id="foot"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let all = [];
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
|
||||
|
||||
function render(){
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
|
||||
const tbody = $("rows");
|
||||
tbody.textContent = "";
|
||||
$("empty").style.display = list.length ? "none" : "block";
|
||||
list.forEach((r, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
|
||||
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
|
||||
const src = document.createElement("td");
|
||||
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
|
||||
src.appendChild(tag);
|
||||
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
|
||||
tr.append(idx, em, src, dt);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
$("statShown").textContent = list.length;
|
||||
$("foot").textContent = list.length
|
||||
? `${list.length} of ${all.length} shown` : "";
|
||||
}
|
||||
|
||||
async function load(){
|
||||
try {
|
||||
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
|
||||
if (res.status === 401) { location.href = "/admin/login"; return; }
|
||||
const data = await res.json();
|
||||
all = data.emails || [];
|
||||
$("statTotal").textContent = data.count ?? all.length;
|
||||
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
|
||||
render();
|
||||
} catch (e) {
|
||||
$("empty").style.display = "block";
|
||||
$("empty").textContent = "Failed to load. " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$("search").addEventListener("input", render);
|
||||
$("refresh").addEventListener("click", load);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
waitlist-server/views/login.html
Normal file
72
waitlist-server/views/login.html
Normal file
@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Admin · Exbyte Depot</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
|
||||
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
|
||||
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
|
||||
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
|
||||
.logo svg{width:20px;height:20px}
|
||||
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
||||
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
|
||||
h1{font-size:19px;font-weight:650;margin:0 0 4px}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
|
||||
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
|
||||
text-transform:uppercase;letter-spacing:.4px}
|
||||
.field{margin-bottom:16px}
|
||||
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
|
||||
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||||
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
|
||||
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
|
||||
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
|
||||
button:hover{filter:brightness(1.08)}
|
||||
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
|
||||
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
|
||||
.err.show{display:block}
|
||||
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="POST" action="/admin/login" autocomplete="off">
|
||||
<div class="brand">
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div><b>Exbyte Depot</b><span>Waitlist admin</span></div>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Restricted area — authorized access only.</p>
|
||||
<div class="err" id="err">Invalid username or password.</div>
|
||||
<div class="field">
|
||||
<label for="u">Username</label>
|
||||
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="foot">Protected endpoint · rate-limited</div>
|
||||
</form>
|
||||
<script>
|
||||
if (new URLSearchParams(location.search).get("error")) {
|
||||
document.getElementById("err").classList.add("show");
|
||||
history.replaceState(null, "", "/admin/login");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user