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

View 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
View File

@ -0,0 +1,4 @@
node_modules/
.env
data/
*.log

51
waitlist-server/README.md Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

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

View 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>

View 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>