Add email as valid * php gate_admin.php invalidate Ban email (denies access, kills session) * php gate_admin.php restore Restore a banned email to valid * php gate_admin.php delete Remove email from allowlist entirely * php gate_admin.php import Import emails from CSV (added as valid) * php gate_admin.php export Export full allowlist to CSV * * CSV format (import): one email per line, or email,status with header row. * CSV format (export): email,status,added_at,last_login,login_ip,login_page * * Requirements: PHP 7.4+, pdo_sqlite. * Drop alongside gate.php and gate_emails.sqlite. */ // ── Config ──────────────────────────────────────────────────────────────────── define('GATE_DB_PATH', __DIR__ . '/gate_emails.sqlite'); // ───────────────────────────────────────────────────────────────────────────── if (php_sapi_name() !== 'cli') { http_response_code(403); exit("This script must be run from the command line.\n"); } // ═════════════════════════════════════════════════════════════════════════════ // DATABASE // ═════════════════════════════════════════════════════════════════════════════ function db(): PDO { static $pdo = null; if ($pdo !== null) return $pdo; if (!file_exists(GATE_DB_PATH)) { // Create fresh DB if it doesn't exist yet (first-time setup) out("Database not found — creating new one at: " . GATE_DB_PATH); } $pdo = new PDO('sqlite:' . GATE_DB_PATH); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->exec("PRAGMA journal_mode=WAL"); // Create or migrate to the current schema $pdo->exec("CREATE TABLE IF NOT EXISTS gate_emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE COLLATE NOCASE, status TEXT NOT NULL DEFAULT 'valid' CHECK(status IN ('valid','banned')), session_token TEXT DEFAULT NULL, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME DEFAULT NULL, login_ip TEXT DEFAULT NULL, login_page TEXT DEFAULT NULL )"); // ── Migrate from the old multi-row schema (v1/v2) if present ───────────── $cols = array_column( $pdo->query("PRAGMA table_info(gate_emails)")->fetchAll(PDO::FETCH_ASSOC), 'name' ); $needs_migration = in_array('ip', $cols, true) && !in_array('status', $cols, true); if ($needs_migration) { out("Detected old schema — migrating to allowlist format..."); // Pull distinct emails, preserving ban state $rows = $pdo->query( "SELECT email, MAX(CASE WHEN invalidated = 1 THEN 1 ELSE 0 END) AS ever_banned, MIN(visited) AS first_seen FROM gate_emails GROUP BY email COLLATE NOCASE" )->fetchAll(PDO::FETCH_ASSOC); // Rename old table, create new one, migrate data $pdo->exec("ALTER TABLE gate_emails RENAME TO gate_emails_legacy"); $pdo->exec("CREATE TABLE gate_emails ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE COLLATE NOCASE, status TEXT NOT NULL DEFAULT 'valid' CHECK(status IN ('valid','banned')), session_token TEXT DEFAULT NULL, added_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_login DATETIME DEFAULT NULL, login_ip TEXT DEFAULT NULL, login_page TEXT DEFAULT NULL )"); $ins = $pdo->prepare( "INSERT OR IGNORE INTO gate_emails (email, status, added_at) VALUES (:e, :s, :a)" ); foreach ($rows as $r) { $ins->execute([ ':e' => strtolower(trim($r['email'])), ':s' => $r['ever_banned'] ? 'banned' : 'valid', ':a' => $r['first_seen'], ]); } $migrated = count($rows); out("Migration complete — $migrated email(s) moved. Legacy table kept as 'gate_emails_legacy'."); } return $pdo; } /** Fetch the single row for an email or null. */ function db_get(string $email): ?array { $stmt = db()->prepare( "SELECT * FROM gate_emails WHERE email = :e COLLATE NOCASE LIMIT 1" ); $stmt->execute([':e' => strtolower(trim($email))]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } // ═════════════════════════════════════════════════════════════════════════════ // OUTPUT HELPERS // ═════════════════════════════════════════════════════════════════════════════ const COL_WIDTH = 110; function out(string $msg = ''): void { echo $msg . PHP_EOL; } function fail(string $msg): never { fwrite(STDERR, "ERROR: $msg\n"); exit(1); } function hr(): void { out(str_repeat('─', COL_WIDTH)); } function prompt_confirm(string $question): bool { fwrite(STDOUT, $question . ' [y/N] '); return strtolower(trim(fgets(STDIN))) === 'y'; } function normalize_email(string $raw): string { $email = strtolower(trim($raw)); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { fail("'$raw' is not a valid email address."); } return $email; } function status_label(string $status): string { return $status === 'banned' ? '🚫 banned' : '✓ valid'; } function print_table(array $rows): void { if (empty($rows)) { out("(no records)"); return; } hr(); printf("%-4s %-38s %-10s %-19s %-19s %-15s %s\n", 'ID', 'Email', 'Status', 'Added', 'Last Login', 'Login IP', 'Login Page'); hr(); foreach ($rows as $r) { printf("%-4s %-38s %-10s %-19s %-19s %-15s %s\n", $r['id'], $r['email'], status_label($r['status']), $r['added_at'] ?? '—', $r['last_login'] ?? 'never', $r['login_ip'] ?? '—', $r['login_page'] ?? '—' ); } hr(); } // ═════════════════════════════════════════════════════════════════════════════ // COMMANDS // ═════════════════════════════════════════════════════════════════════════════ /** List all emails (or only banned ones). */ function cmd_list(bool $banned_only = false): void { $sql = $banned_only ? "SELECT * FROM gate_emails WHERE status = 'banned' ORDER BY email" : "SELECT * FROM gate_emails ORDER BY email"; $rows = db()->query($sql)->fetchAll(PDO::FETCH_ASSOC); $n = count($rows); $label = $banned_only ? "Banned emails" : "All allowlisted emails"; out("$label ($n record" . ($n !== 1 ? 's' : '') . "):"); print_table($rows); } /** Add a new email as valid (no-op if already present). */ function cmd_add(string $email): void { $email = normalize_email($email); $existing = db_get($email); if ($existing !== null) { out("'$email' is already in the allowlist (status: {$existing['status']})."); out("Use 'restore' to re-enable a banned email, or 'invalidate' to ban it."); return; } $stmt = db()->prepare( "INSERT INTO gate_emails (email, status) VALUES (:e, 'valid')" ); $stmt->execute([':e' => $email]); out("✓ Added '$email' as valid."); } /** Ban an email — kills its active session token, denies all future access. */ function cmd_invalidate(string $email): void { $email = normalize_email($email); $record = db_get($email); if ($record === null) { fail("'$email' is not in the allowlist. Use 'add' first."); } if ($record['status'] === 'banned') { out("'$email' is already banned."); return; } db()->prepare( "UPDATE gate_emails SET status = 'banned', session_token = NULL WHERE email = :e COLLATE NOCASE" )->execute([':e' => $email]); out("✓ Banned '$email'."); out(" Their session token has been cleared — the next page load will deny them."); } /** Restore a banned email to valid status. */ function cmd_restore(string $email): void { $email = normalize_email($email); $record = db_get($email); if ($record === null) { fail("'$email' is not in the allowlist."); } if ($record['status'] === 'valid') { out("'$email' is already valid. Nothing to restore."); return; } db()->prepare( "UPDATE gate_emails SET status = 'valid' WHERE email = :e COLLATE NOCASE" )->execute([':e' => $email]); out("✓ Restored '$email' to valid status."); out(" They may now pass through the gate again."); } /** Permanently remove an email from the allowlist. */ function cmd_delete(string $email): void { $email = normalize_email($email); $record = db_get($email); if ($record === null) { fail("'$email' is not in the allowlist."); } if (!prompt_confirm("Permanently remove '$email' from the allowlist. Confirm?")) { out("Aborted — nothing changed."); exit(0); } db()->prepare("DELETE FROM gate_emails WHERE email = :e COLLATE NOCASE") ->execute([':e' => $email]); out("✓ Deleted '$email' from the allowlist."); } // ───────────────────────────────────────────────────────────────────────────── // CSV IMPORT // ───────────────────────────────────────────────────────────────────────────── /** * Import emails from a CSV file. * * Accepted formats: * A) Single-column — one email per line (with or without header "email"): * user@example.com * * B) Two-column — email + status (imported status respected): * email,status * user@example.com,valid * bad@example.com,banned * * Rules: * - New emails are inserted with status from the file (default: valid). * - Existing emails: status is updated only if the CSV explicitly provides one * AND it differs — otherwise the existing record is left untouched. * - Blank lines and lines starting with # are skipped. * - Invalid email addresses are reported and skipped. */ function cmd_import(string $path): void { if (!file_exists($path)) { fail("File not found: $path"); } $handle = fopen($path, 'r'); if (!$handle) fail("Cannot open: $path"); $added = 0; $updated = 0; $skipped = 0; $invalid = 0; $line_no = 0; // Peek at the first non-blank, non-comment line to detect format $has_status_col = false; $first_data = null; $rewind_lines = []; while (!feof($handle)) { $raw = fgets($handle); $line_no++; $trimmed = trim($raw); if ($trimmed === '' || str_starts_with($trimmed, '#')) { $rewind_lines[] = $raw; continue; } $cols = str_getcsv($trimmed); if (count($cols) >= 2) { $h0 = strtolower(trim($cols[0])); $h1 = strtolower(trim($cols[1])); // If it looks like a header row, note format and skip it if ($h0 === 'email' && in_array($h1, ['status', 'banned', 'valid'], true)) { $has_status_col = true; break; } // Data row with two cols — infer status column present if (filter_var(trim($cols[0]), FILTER_VALIDATE_EMAIL) && in_array($h1, ['valid', 'banned'], true)) { $has_status_col = true; } } $first_data = $raw; break; } // Restart from beginning rewind($handle); $line_no = 0; $db = db(); $ins = $db->prepare( "INSERT INTO gate_emails (email, status) VALUES (:e, :s) ON CONFLICT(email) DO NOTHING" ); $upd = $db->prepare( "UPDATE gate_emails SET status = :s WHERE email = :e COLLATE NOCASE" ); $chk = $db->prepare( "SELECT status FROM gate_emails WHERE email = :e COLLATE NOCASE LIMIT 1" ); $db->beginTransaction(); try { while (!feof($handle)) { $raw = fgets($handle); if ($raw === false) break; $line_no++; $trimmed = trim($raw); // Skip blanks and comments if ($trimmed === '' || str_starts_with($trimmed, '#')) continue; $cols = str_getcsv($trimmed); $raw_email = trim($cols[0] ?? ''); $raw_status = strtolower(trim($cols[1] ?? '')); // Skip header row if (strtolower($raw_email) === 'email' && in_array($raw_status, ['status','valid','banned',''], true)) { continue; } // Validate email $email = strtolower($raw_email); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { out(" Line $line_no: skipping invalid address — '$raw_email'"); $invalid++; continue; } // Determine intended status $status = in_array($raw_status, ['valid', 'banned'], true) ? $raw_status : 'valid'; // Check existing record $chk->execute([':e' => $email]); $existing_status = $chk->fetchColumn(); if ($existing_status === false) { // New email — insert $ins->execute([':e' => $email, ':s' => $status]); $added++; } elseif ($has_status_col && $raw_status !== '' && $existing_status !== $status) { // Existing email with explicit new status — update $upd->execute([':s' => $status, ':e' => $email]); $updated++; } else { $skipped++; } } $db->commit(); } catch (Throwable $ex) { $db->rollBack(); fail("Import failed at line $line_no: " . $ex->getMessage()); } fclose($handle); out("✓ Import complete from: $path"); out(" Added: $added"); out(" Updated: $updated"); out(" Skipped (already present, no change): $skipped"); if ($invalid > 0) { out(" Invalid (skipped): $invalid"); } } // ───────────────────────────────────────────────────────────────────────────── // CSV EXPORT // ───────────────────────────────────────────────────────────────────────────── /** * Export the full allowlist to a CSV file. * * Output columns: email, status, added_at, last_login, login_ip, login_page * The session_token column is intentionally excluded for security. */ function cmd_export(string $path): void { if (file_exists($path) && !prompt_confirm("'$path' already exists. Overwrite?")) { out("Aborted — nothing changed."); exit(0); } $handle = fopen($path, 'w'); if (!$handle) fail("Cannot write to: $path"); $rows = db()->query( "SELECT email, status, added_at, last_login, login_ip, login_page FROM gate_emails ORDER BY email" )->fetchAll(PDO::FETCH_ASSOC); // Header fputcsv($handle, ['email', 'status', 'added_at', 'last_login', 'login_ip', 'login_page']); foreach ($rows as $r) { fputcsv($handle, [ $r['email'], $r['status'], $r['added_at'] ?? '', $r['last_login'] ?? '', $r['login_ip'] ?? '', $r['login_page'] ?? '', ]); } fclose($handle); $n = count($rows); out("✓ Exported $n email" . ($n !== 1 ? 's' : '') . " to: $path"); } // ═════════════════════════════════════════════════════════════════════════════ // DISPATCH // ═════════════════════════════════════════════════════════════════════════════ $argv = $_SERVER['argv'] ?? []; $cmd = $argv[1] ?? ''; $arg = $argv[2] ?? ''; function usage(): void { out("gate_admin.php — manage the gate.php email allowlist"); out(""); out("Commands:"); out(" php gate_admin.php list List all allowlisted emails"); out(" php gate_admin.php list-banned List only banned emails"); out(" php gate_admin.php add Add email to allowlist as valid"); out(" php gate_admin.php invalidate Ban email (kills session, denies access)"); out(" php gate_admin.php restore Restore a banned email to valid"); out(" php gate_admin.php delete Remove email from allowlist entirely"); out(" php gate_admin.php import Import emails from CSV (added as valid)"); out(" php gate_admin.php export Export allowlist to CSV"); out(""); out("CSV import format (two supported variants):"); out(" Simple: one email per line"); out(" Extended: email,status (header row optional; status = valid|banned)"); out(""); out("CSV export columns: email, status, added_at, last_login, login_ip, login_page"); out(""); out("Ban lifecycle:"); out(" add → email joins allowlist as valid"); out(" invalidate → status = banned, session_token cleared (denied on next request)"); out(" restore → status = valid (user can log in again)"); out(" delete → row removed entirely (user treated as unknown on next visit)"); } match ($cmd) { 'list' => cmd_list(false), 'list-banned' => cmd_list(true), 'add' => $arg ? cmd_add($arg) : fail("Usage: php gate_admin.php add "), 'invalidate' => $arg ? cmd_invalidate($arg) : fail("Usage: php gate_admin.php invalidate "), 'restore' => $arg ? cmd_restore($arg) : fail("Usage: php gate_admin.php restore "), 'delete' => $arg ? cmd_delete($arg) : fail("Usage: php gate_admin.php delete "), 'import' => $arg ? cmd_import($arg) : fail("Usage: php gate_admin.php import "), 'export' => $arg ? cmd_export($arg) : fail("Usage: php gate_admin.php export "), default => usage(), };