v3
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
<?php
|
||||
/**
|
||||
* gate_admin.php — CLI admin tool for gate.php allowlist management
|
||||
*
|
||||
* Commands:
|
||||
* php gate_admin.php list List all allowlisted emails
|
||||
* php gate_admin.php list-banned List only banned emails
|
||||
* php gate_admin.php add <email> Add email as valid
|
||||
* php gate_admin.php invalidate <email> Ban email (denies access, kills session)
|
||||
* php gate_admin.php restore <email> Restore a banned email to valid
|
||||
* php gate_admin.php delete <email> Remove email from allowlist entirely
|
||||
* php gate_admin.php import <file.csv> Import emails from CSV (added as valid)
|
||||
* php gate_admin.php export <file.csv> 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 <email> Add email to allowlist as valid");
|
||||
out(" php gate_admin.php invalidate <email> Ban email (kills session, denies access)");
|
||||
out(" php gate_admin.php restore <email> Restore a banned email to valid");
|
||||
out(" php gate_admin.php delete <email> Remove email from allowlist entirely");
|
||||
out(" php gate_admin.php import <file.csv> Import emails from CSV (added as valid)");
|
||||
out(" php gate_admin.php export <file.csv> 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 <email>"),
|
||||
'invalidate' => $arg ? cmd_invalidate($arg) : fail("Usage: php gate_admin.php invalidate <email>"),
|
||||
'restore' => $arg ? cmd_restore($arg) : fail("Usage: php gate_admin.php restore <email>"),
|
||||
'delete' => $arg ? cmd_delete($arg) : fail("Usage: php gate_admin.php delete <email>"),
|
||||
'import' => $arg ? cmd_import($arg) : fail("Usage: php gate_admin.php import <file.csv>"),
|
||||
'export' => $arg ? cmd_export($arg) : fail("Usage: php gate_admin.php export <file.csv>"),
|
||||
default => usage(),
|
||||
};
|
||||
Reference in New Issue
Block a user