v3
This commit is contained in:
@@ -0,0 +1,963 @@
|
||||
<?php
|
||||
/**
|
||||
* gate.php — Allowlist-only email gate with single-session enforcement
|
||||
*
|
||||
* Usage:
|
||||
* <?php require 'gate.php'; ?> at the very top of any protected page.
|
||||
*
|
||||
* How it works:
|
||||
* - Only emails pre-loaded into gate_emails.sqlite are allowed through.
|
||||
* - Each email may hold exactly ONE active session at a time. A new login
|
||||
* from any browser/device silently invalidates all previous sessions for
|
||||
* that email (token rotation).
|
||||
* - Banned emails see a 403 Denied screen regardless of session state.
|
||||
* - Unknown emails see a "not on the list" error with a Patreon signup nudge.
|
||||
*
|
||||
* Requirements: PHP 7.4+, PDO + pdo_sqlite extension.
|
||||
* Manage the allowlist with gate_admin.php.
|
||||
*/
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────────
|
||||
define('GATE_DB_PATH', __DIR__ . '/gate_emails.sqlite');
|
||||
define('GATE_SESSION_KEY', 'gate_verified');
|
||||
define('GATE_SESSION_EMAIL','gate_email');
|
||||
define('GATE_SESSION_TOKEN','gate_token');
|
||||
define('GATE_CAPTCHA_KEY', 'gate_captcha_answer');
|
||||
define('GATE_CAPTCHA_Q', 'gate_captcha_q');
|
||||
define('GATE_PATREON_URL', 'https://www.patreon.com/tyclifford'); // ← replace
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
session_start();
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// DATABASE
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function gate_db(): PDO {
|
||||
static $pdo = null;
|
||||
if ($pdo !== null) return $pdo;
|
||||
|
||||
$pdo = new PDO('sqlite:' . GATE_DB_PATH);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$pdo->exec("PRAGMA journal_mode=WAL");
|
||||
|
||||
$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
|
||||
)");
|
||||
|
||||
return $pdo;
|
||||
}
|
||||
|
||||
function gate_get_record(string $email): ?array {
|
||||
$stmt = gate_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;
|
||||
}
|
||||
|
||||
function gate_create_session(string $email): void {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$db = gate_db();
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE gate_emails
|
||||
SET session_token = :t,
|
||||
last_login = CURRENT_TIMESTAMP,
|
||||
login_ip = :ip,
|
||||
login_page = :p
|
||||
WHERE email = :e COLLATE NOCASE"
|
||||
);
|
||||
$stmt->execute([
|
||||
':t' => $token,
|
||||
':ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
|
||||
':p' => $_SERVER['REQUEST_URI'] ?? '',
|
||||
':e' => strtolower(trim($email)),
|
||||
]);
|
||||
|
||||
$_SESSION[GATE_SESSION_KEY] = true;
|
||||
$_SESSION[GATE_SESSION_EMAIL] = strtolower(trim($email));
|
||||
$_SESSION[GATE_SESSION_TOKEN] = $token;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// REQUEST HANDLING
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function gate_kill_session(): void {
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$p = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
|
||||
}
|
||||
session_destroy();
|
||||
session_start();
|
||||
}
|
||||
|
||||
if (!empty($_SESSION[GATE_SESSION_KEY])) {
|
||||
$sess_email = $_SESSION[GATE_SESSION_EMAIL] ?? '';
|
||||
$sess_token = $_SESSION[GATE_SESSION_TOKEN] ?? '';
|
||||
$record = $sess_email !== '' ? gate_get_record($sess_email) : null;
|
||||
|
||||
if ($record === null) {
|
||||
gate_kill_session();
|
||||
$gate_form_error = 'Your access has been removed. Please contact an administrator.';
|
||||
} elseif ($record['status'] === 'banned') {
|
||||
gate_kill_session();
|
||||
gate_render_denied($sess_email);
|
||||
} elseif ($record['session_token'] !== $sess_token) {
|
||||
gate_kill_session();
|
||||
$gate_form_error = 'Your session was ended because you signed in from another location.';
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$gate_form_error = $gate_form_error ?? '';
|
||||
$gate_show_patreon = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = strtolower(trim($_POST['email'] ?? ''));
|
||||
$answer = trim($_POST['captcha'] ?? '');
|
||||
$expected = $_SESSION[GATE_CAPTCHA_KEY] ?? null;
|
||||
|
||||
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$gate_form_error = 'Please enter a valid email address.';
|
||||
|
||||
} elseif ($expected === null || (int)$answer !== (int)$expected) {
|
||||
$gate_form_error = 'Incorrect answer — please try again.';
|
||||
|
||||
} else {
|
||||
$record = gate_get_record($email);
|
||||
|
||||
if ($record === null) {
|
||||
$gate_form_error = 'That email isn\'t on the access list yet.';
|
||||
$gate_show_patreon = true;
|
||||
|
||||
} elseif ($record['status'] === 'banned') {
|
||||
gate_render_denied($email);
|
||||
|
||||
} else {
|
||||
gate_create_session($email);
|
||||
unset($_SESSION[GATE_CAPTCHA_KEY], $_SESSION[GATE_CAPTCHA_Q]);
|
||||
$scheme = (($_SERVER['HTTPS'] ?? '') === 'on') ? 'https' : 'http';
|
||||
header('Location: ' . $scheme . '://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($_SESSION[GATE_CAPTCHA_KEY]) || $gate_form_error) {
|
||||
$a = random_int(2, 12);
|
||||
$b = random_int(1, 10);
|
||||
$_SESSION[GATE_CAPTCHA_Q] = "$a + $b";
|
||||
$_SESSION[GATE_CAPTCHA_KEY] = $a + $b;
|
||||
}
|
||||
$captcha_question = $_SESSION[GATE_CAPTCHA_Q];
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// STYLES
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function gate_styles(): void { ?>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Share+Tech+Mono&display=swap');
|
||||
|
||||
/* ── Reset & tokens ────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
/* neutrals */
|
||||
--bg: #09090f;
|
||||
--surface: #0f0f1a;
|
||||
--surface-2: #141425;
|
||||
--border: #1e1e38;
|
||||
--border-2: #2a2a48;
|
||||
--text: #e8e8f8;
|
||||
--text-2: #9898c0;
|
||||
--text-3: #58587a;
|
||||
|
||||
/* accents */
|
||||
--green: #00e87a;
|
||||
--green-dim: rgba(0,232,122,.08);
|
||||
--blue: #00c8ff;
|
||||
--purple: #b060ff;
|
||||
--red: #ff3355;
|
||||
--orange: #ff8800;
|
||||
|
||||
/* Patreon */
|
||||
--pat: #ff424d;
|
||||
--pat-lite: rgba(255,66,77,.10);
|
||||
--pat-mid: rgba(255,66,77,.22);
|
||||
--pat-border: rgba(255,66,77,.32);
|
||||
--pat-glow: rgba(255,66,77,.18);
|
||||
|
||||
/* spacing scale */
|
||||
--r: 6px;
|
||||
--gap: 1.25rem;
|
||||
}
|
||||
|
||||
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
background: var(--bg);
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
gap: 0;
|
||||
|
||||
/* ambient glows — subtle, not distracting on small screens */
|
||||
background-image:
|
||||
radial-gradient(ellipse 70% 50% at 20% 10%, rgba(176,96,255,.05) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(0,200,255,.04) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 50% 60% at 50% 50%, rgba(255,66,77,.03) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
/* scanline — very faint, performance-friendly */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 999;
|
||||
background: repeating-linear-gradient(
|
||||
0deg, transparent 0px, transparent 3px,
|
||||
rgba(0,0,0,.06) 3px, rgba(0,0,0,.06) 4px
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared animations ─────────────────────────────────────────────────────── */
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(16px) scale(.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { background-position: 0% 50%; }
|
||||
to { background-position: 200% 50%; }
|
||||
}
|
||||
@keyframes shake {
|
||||
0%,100% { transform: translateX(0); }
|
||||
20%,60% { transform: translateX(-6px); }
|
||||
40%,80% { transform: translateX(6px); }
|
||||
}
|
||||
@keyframes pulse-border {
|
||||
0%,100% { box-shadow: 0 0 0 0 var(--pat-glow); }
|
||||
50% { box-shadow: 0 0 0 6px transparent; }
|
||||
}
|
||||
|
||||
/* ── Outer wrapper ─────────────────────────────────────────────────────────── */
|
||||
/* Mobile: single column stack. Desktop: side-by-side panels. */
|
||||
.gate-wrap {
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: calc(var(--r) + 2px);
|
||||
overflow: hidden;
|
||||
animation: rise .45s cubic-bezier(.22,.68,0,1.2) both;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255,255,255,.04),
|
||||
0 4px 60px rgba(0,0,0,.7),
|
||||
0 0 80px rgba(176,96,255,.05);
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.gate-wrap { flex-direction: row; min-height: 520px; }
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
LEFT PANEL — Patreon CTA
|
||||
════════════════════════════════════════════════════════════════════════════ */
|
||||
.panel-cta {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 2rem 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.panel-cta {
|
||||
flex: 0 0 52%;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 2.75rem 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rainbow top rule (mobile) / left rule (desktop) */
|
||||
.panel-cta::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 2px;
|
||||
background: linear-gradient(90deg,
|
||||
var(--pat), var(--purple), var(--blue), var(--green),
|
||||
var(--blue), var(--purple), var(--pat));
|
||||
background-size: 300% 100%;
|
||||
animation: shimmer 5s linear infinite;
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.panel-cta::before {
|
||||
top: 0; bottom: 0; right: auto;
|
||||
width: 2px; height: auto;
|
||||
background: linear-gradient(180deg,
|
||||
var(--pat), var(--purple), var(--blue), var(--green),
|
||||
var(--blue), var(--purple), var(--pat));
|
||||
background-size: 100% 300%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Faint decorative blob */
|
||||
.panel-cta::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -60px; right: -60px;
|
||||
width: 220px; height: 220px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(255,66,77,.07) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Eyebrow */
|
||||
.cta-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: .65rem;
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--pat);
|
||||
text-shadow: 0 0 12px var(--pat-glow);
|
||||
}
|
||||
|
||||
/* Patreon 'P' icon */
|
||||
.icon-patreon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px; height: 20px;
|
||||
background: var(--pat);
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cta-headline {
|
||||
font-size: clamp(1.55rem, 4vw, 2rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
.cta-headline span {
|
||||
background: linear-gradient(120deg, var(--pat) 0%, #ff8c6a 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.cta-body {
|
||||
font-size: .92rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.cta-body strong { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* ── 24-hour notice strip ───────────────────────────────────────────────────── */
|
||||
.notice-24h {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .65rem;
|
||||
background: rgba(255,136,0,.08);
|
||||
border: 1px solid rgba(255,136,0,.28);
|
||||
border-radius: var(--r);
|
||||
padding: .75rem .9rem;
|
||||
font-size: .82rem;
|
||||
color: rgba(232,200,100,.9);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.notice-24h .notice-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--orange);
|
||||
filter: drop-shadow(0 0 5px rgba(255,136,0,.5));
|
||||
}
|
||||
.notice-24h strong {
|
||||
color: var(--orange);
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: .15rem;
|
||||
}
|
||||
|
||||
/* ── Tier list ──────────────────────────────────────────────────────────────── */
|
||||
.tier-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
.tier-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .65rem;
|
||||
font-size: .88rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.tier-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tier-dot.free { background: var(--green); box-shadow: 0 0 6px rgba(0,232,122,.6); }
|
||||
.tier-dot.paid { background: var(--purple); box-shadow: 0 0 6px rgba(176,96,255,.6); }
|
||||
.tier-list li strong { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* ── Patreon CTA button ─────────────────────────────────────────────────────── */
|
||||
.btn-patreon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .6rem;
|
||||
padding: .9rem 1.5rem;
|
||||
background: var(--pat);
|
||||
color: #fff;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: .92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .02em;
|
||||
border-radius: var(--r);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform .15s, box-shadow .2s, background .15s;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.1) inset,
|
||||
0 4px 20px rgba(255,66,77,.35);
|
||||
animation: pulse-border 3s ease-in-out infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
.btn-patreon::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.12) 0%, transparent 60%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-patreon:hover {
|
||||
background: #ff5a63;
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.15) inset,
|
||||
0 6px 28px rgba(255,66,77,.55);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.btn-patreon:active { transform: translateY(0) scale(.98); }
|
||||
|
||||
.btn-patreon-sub {
|
||||
text-align: center;
|
||||
font-size: .73rem;
|
||||
color: var(--text-3);
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
letter-spacing: .05em;
|
||||
}
|
||||
.btn-patreon-sub a {
|
||||
color: var(--pat);
|
||||
text-decoration: none;
|
||||
opacity: .7;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-patreon-sub a:hover { opacity: 1; }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
RIGHT PANEL — Login form
|
||||
════════════════════════════════════════════════════════════════════════════ */
|
||||
.panel-form {
|
||||
background: var(--surface-2);
|
||||
padding: 2rem 1.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--gap);
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
.panel-form {
|
||||
flex: 1;
|
||||
padding: 2.75rem 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-eyebrow {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: .65rem;
|
||||
letter-spacing: .2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 10px rgba(0,232,122,.5);
|
||||
}
|
||||
.form-eyebrow::before { content: '> '; opacity: .5; }
|
||||
|
||||
.form-title {
|
||||
font-size: clamp(1.3rem, 3.5vw, 1.7rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -.015em;
|
||||
line-height: 1.2;
|
||||
background: linear-gradient(135deg, var(--blue) 0%, var(--purple) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: .82rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
padding: .6rem .85rem;
|
||||
border-left: 2px solid var(--border-2);
|
||||
}
|
||||
|
||||
/* ── Alerts ──────────────────────────────────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .6rem;
|
||||
border-radius: var(--r);
|
||||
padding: .75rem 1rem;
|
||||
font-size: .8rem;
|
||||
line-height: 1.55;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
}
|
||||
.alert svg { flex-shrink: 0; margin-top: 1px; }
|
||||
|
||||
.alert-error {
|
||||
background: rgba(255,51,85,.07);
|
||||
border: 1px solid rgba(255,51,85,.28);
|
||||
color: var(--red);
|
||||
text-shadow: 0 0 8px rgba(255,51,85,.3);
|
||||
animation: shake .35s ease;
|
||||
}
|
||||
.alert-info {
|
||||
background: rgba(0,200,255,.07);
|
||||
border: 1px solid rgba(0,200,255,.25);
|
||||
color: var(--blue);
|
||||
}
|
||||
/* Not-on-list variant — two-part with Patreon nudge */
|
||||
.alert-unlisted {
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
background: rgba(255,66,77,.07);
|
||||
border: 1px solid rgba(255,66,77,.28);
|
||||
color: var(--text-2);
|
||||
animation: shake .35s ease;
|
||||
}
|
||||
.alert-unlisted .al-head {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
color: var(--pat);
|
||||
text-shadow: 0 0 8px var(--pat-glow);
|
||||
font-weight: 600;
|
||||
}
|
||||
.alert-unlisted a {
|
||||
color: var(--pat); text-decoration: none;
|
||||
border-bottom: 1px solid rgba(255,66,77,.3);
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.alert-unlisted a:hover { border-color: rgba(255,66,77,.7); }
|
||||
|
||||
/* ── Form fields ──────────────────────────────────────────────────────────── */
|
||||
.field { display: flex; flex-direction: column; gap: .45rem; }
|
||||
|
||||
.field label {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: .62rem;
|
||||
letter-spacing: .15em;
|
||||
text-transform: uppercase;
|
||||
color: var(--blue);
|
||||
text-shadow: 0 0 8px rgba(0,200,255,.35);
|
||||
}
|
||||
|
||||
.field input[type="email"],
|
||||
.field input[type="number"] {
|
||||
width: 100%;
|
||||
padding: .8rem 1rem;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: .9rem;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,.03);
|
||||
border: 1px solid var(--border-2);
|
||||
border-radius: var(--r);
|
||||
outline: none;
|
||||
transition: border-color .2s, box-shadow .2s, background .2s;
|
||||
/* larger tap target on mobile */
|
||||
min-height: 48px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
.field input[type="number"]::-webkit-outer-spin-button,
|
||||
.field input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.field input::placeholder { color: var(--text-3); }
|
||||
|
||||
.field input[type="email"]:focus {
|
||||
border-color: var(--blue);
|
||||
background: rgba(0,200,255,.04);
|
||||
box-shadow: 0 0 0 3px rgba(0,200,255,.1);
|
||||
}
|
||||
.field input[type="number"]:focus {
|
||||
border-color: var(--green);
|
||||
background: rgba(0,232,122,.03);
|
||||
box-shadow: 0 0 0 3px rgba(0,232,122,.1);
|
||||
}
|
||||
|
||||
/* captcha row */
|
||||
.captcha-row { display: flex; gap: .6rem; align-items: stretch; }
|
||||
.captcha-eq {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 .9rem;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
color: var(--orange);
|
||||
background: rgba(255,136,0,.07);
|
||||
border: 1px solid rgba(255,136,0,.22);
|
||||
border-radius: var(--r);
|
||||
user-select: none;
|
||||
text-shadow: 0 0 8px rgba(255,136,0,.45);
|
||||
min-height: 48px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.captcha-row .field { flex: 1; }
|
||||
|
||||
/* ── Submit button ────────────────────────────────────────────────────────── */
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: .9rem 1.5rem;
|
||||
background: var(--green);
|
||||
color: #050510;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: .9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
border: none;
|
||||
border-radius: var(--r);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform .15s, box-shadow .2s;
|
||||
box-shadow: 0 0 20px rgba(0,232,122,.25), 0 0 40px rgba(0,232,122,.08);
|
||||
}
|
||||
.btn-submit::before {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,.18), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: transform .5s ease;
|
||||
}
|
||||
.btn-submit:hover {
|
||||
box-shadow: 0 0 28px rgba(0,232,122,.45), 0 0 55px rgba(0,232,122,.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.btn-submit:hover::before { transform: translateX(100%); }
|
||||
.btn-submit:active { transform: scale(.985); }
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
||||
.form-footer {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: .65rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
letter-spacing: .05em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.form-footer a {
|
||||
color: rgba(255,66,77,.5);
|
||||
text-decoration: none;
|
||||
transition: color .15s;
|
||||
}
|
||||
.form-footer a:hover { color: rgba(255,66,77,.85); }
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════════════════
|
||||
DENIED PAGE
|
||||
════════════════════════════════════════════════════════════════════════════ */
|
||||
.denied-wrap {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--r) + 2px);
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
animation: rise .45s cubic-bezier(.22,.68,0,1.2) both;
|
||||
box-shadow: 0 4px 60px rgba(0,0,0,.7);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.denied-wrap::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 2px;
|
||||
background: linear-gradient(90deg, var(--red), var(--pat), var(--orange), var(--red));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 3s linear infinite;
|
||||
}
|
||||
.denied-icon {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
background: rgba(255,51,85,.1);
|
||||
border: 1px solid rgba(255,51,85,.3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
box-shadow: 0 0 20px rgba(255,51,85,.15);
|
||||
color: var(--red);
|
||||
}
|
||||
.denied-title {
|
||||
font-size: 1.8rem; font-weight: 700;
|
||||
letter-spacing: -.01em;
|
||||
background: linear-gradient(120deg, var(--red) 0%, #ff80a0 100%);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||
}
|
||||
.denied-body {
|
||||
font-size: .88rem; color: var(--text-2); line-height: 1.7;
|
||||
}
|
||||
.denied-code {
|
||||
font-family: 'Share Tech Mono', monospace; font-size: .73rem;
|
||||
color: var(--red); background: rgba(255,51,85,.06);
|
||||
border: 1px solid rgba(255,51,85,.18); border-radius: var(--r);
|
||||
padding: .6rem 1rem; letter-spacing: .06em;
|
||||
}
|
||||
.denied-code::before { content: '// '; opacity: .5; }
|
||||
</style>
|
||||
<?php }
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// DENIED PAGE
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function gate_render_denied(string $email): never {
|
||||
http_response_code(403);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Access Denied</title>
|
||||
<?php gate_styles(); ?>
|
||||
</head>
|
||||
<body>
|
||||
<div class="denied-wrap">
|
||||
<div class="denied-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="denied-title">Account Suspended</div>
|
||||
<p class="denied-body">
|
||||
Access for this email address has been revoked.
|
||||
</p>
|
||||
<p class="denied-code">status: 403 forbidden | contact to appeal</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
<?php exit; }
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// GATE FORM
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
$is_info_msg = $gate_form_error !== '' && str_contains($gate_form_error, 'session was ended');
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Members Access</title>
|
||||
<?php gate_styles(); ?>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="gate-wrap">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
LEFT / TOP — Patreon CTA panel
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="panel-cta">
|
||||
|
||||
<div class="cta-eyebrow">
|
||||
<span class="icon-patreon" aria-hidden="true">
|
||||
<!-- Patreon 'P' wordmark -->
|
||||
<svg width="12" height="14" viewBox="0 0 12 14" fill="white" xmlns="http://www.w3.org/2000/svg">
|
||||
<ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/>
|
||||
<rect x="0" y="0" width="2.8" height="14" rx="1.2"/>
|
||||
</svg>
|
||||
</span>
|
||||
Membership via Patreon
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="cta-headline">
|
||||
Get <span>free access</span><br>by joining my Patreon
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cta-body">
|
||||
This area is open to all my Patreon members — <strong>no payment required.</strong>
|
||||
Simply join as a free member and your email is added to the access list.
|
||||
You can choose to upgrade to a paid tier anytime to support the work and unlock extra perks.
|
||||
</p>
|
||||
|
||||
<!-- 24-hour notice -->
|
||||
<div class="notice-24h" role="note">
|
||||
<span class="notice-icon" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<div>
|
||||
<strong>Sign up at least 24 hours before your first visit.</strong>
|
||||
After joining Patreon, it may take up to 24 hours for your email to
|
||||
be synced and activated on the access list. Plan ahead!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tier breakdown -->
|
||||
<ul class="tier-list" aria-label="Membership tiers">
|
||||
<li>
|
||||
<span class="tier-dot free" aria-hidden="true"></span>
|
||||
<span><strong>Free member</strong> — full access to this area, no charge</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-dot paid" aria-hidden="true"></span>
|
||||
<span><strong>Paid tier</strong> — support the work & unlock extra perks</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- CTA button -->
|
||||
<a class="btn-patreon"
|
||||
href="<?= htmlspecialchars(GATE_PATREON_URL) ?>"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
aria-label="Join Patreon — free membership grants access">
|
||||
<svg width="14" height="16" viewBox="0 0 12 14" fill="white" aria-hidden="true">
|
||||
<ellipse cx="7.5" cy="4.8" rx="4.5" ry="4.5"/>
|
||||
<rect x="0" y="0" width="2.8" height="14" rx="1.2"/>
|
||||
</svg>
|
||||
Join Free on Patreon
|
||||
</a>
|
||||
<p class="btn-patreon-sub">
|
||||
Already a paid member? <a href="<?= htmlspecialchars(GATE_PATREON_URL) ?>" target="_blank" rel="noopener">Manage your tier →</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════
|
||||
RIGHT / BOTTOM — Login form panel
|
||||
══════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="panel-form">
|
||||
|
||||
<div>
|
||||
<p class="form-eyebrow">Already a member</p>
|
||||
<h1 class="form-title">Sign in with<br>your Patreon email</h1>
|
||||
</div>
|
||||
|
||||
<p class="form-hint">
|
||||
Use the email address linked to your Patreon account.
|
||||
</p>
|
||||
|
||||
<!-- ── Alerts ── -->
|
||||
<?php if ($gate_show_patreon): ?>
|
||||
<div class="alert alert-unlisted" role="alert">
|
||||
<div class="al-head">
|
||||
<svg width="14" height="14" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle cx="7.5" cy="7.5" r="6.5" stroke="currentColor"/>
|
||||
<path d="M7.5 4v4M7.5 10.5v.5" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<?= htmlspecialchars($gate_form_error) ?>
|
||||
</div>
|
||||
<div>
|
||||
That address hasn't been activated yet.
|
||||
<a href="<?= htmlspecialchars(GATE_PATREON_URL) ?>" target="_blank" rel="noopener">Join Patreon as a free member</a>
|
||||
to get access — it may take up to 24 hours to sync after joining.
|
||||
If you joined recently, try again later.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php elseif ($gate_form_error !== ''): ?>
|
||||
<div class="alert <?= $is_info_msg ? 'alert-info' : 'alert-error' ?>" role="alert">
|
||||
<svg width="14" height="14" viewBox="0 0 15 15" fill="none" aria-hidden="true">
|
||||
<circle cx="7.5" cy="7.5" r="6.5" stroke="currentColor"/>
|
||||
<path d="M7.5 4v4M7.5 10.5v.5" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<?= htmlspecialchars($gate_form_error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- ── Form ── -->
|
||||
<form method="POST" action="" novalidate>
|
||||
|
||||
<div class="field" style="margin-bottom:var(--gap)">
|
||||
<label for="gate-email">Patreon email address</label>
|
||||
<input
|
||||
id="gate-email" type="email" name="email"
|
||||
autocomplete="email" placeholder="you@example.com"
|
||||
value="<?= htmlspecialchars($_POST['email'] ?? '') ?>"
|
||||
required autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:var(--gap)">
|
||||
<label style="
|
||||
display:block;
|
||||
font-family:'Share Tech Mono',monospace;
|
||||
font-size:.62rem; letter-spacing:.15em; text-transform:uppercase;
|
||||
color:var(--blue); text-shadow:0 0 8px rgba(0,200,255,.35);
|
||||
margin-bottom:.45rem;
|
||||
">Verify — solve the equation</label>
|
||||
<div class="captcha-row">
|
||||
<div class="captcha-eq" aria-label="Captcha question: <?= htmlspecialchars($captcha_question) ?> equals what?">
|
||||
<?= htmlspecialchars($captcha_question) ?> =
|
||||
</div>
|
||||
<div class="field">
|
||||
<input
|
||||
type="number" name="captcha" inputmode="numeric"
|
||||
placeholder="?" min="0" max="99"
|
||||
required autocomplete="off"
|
||||
aria-label="Answer"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Go</button>
|
||||
</form>
|
||||
|
||||
<p class="form-footer">
|
||||
Not a member yet?
|
||||
<a href="<?= htmlspecialchars(GATE_PATREON_URL) ?>" target="_blank" rel="noopener">Join free on Patreon</a>
|
||||
· Paid tier available · 24 hr activation
|
||||
</p>
|
||||
|
||||
</div><!-- /panel-form -->
|
||||
|
||||
</div><!-- /gate-wrap -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
<?php exit;
|
||||
@@ -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(),
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
require_once('gate.php');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="The web space of Ty Clifford" />
|
||||
<meta name="author" content="Ty Clifford">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
|
||||
<meta name="keywords" content="Ty Clifford, Podcast, Spotify podcast, Keyser, West Virginia, United States, Nerd, Ty on TikTok, Ty's web space, Freedom of Expression">
|
||||
<meta property="og:url" content="https://tyclifford.com/" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Ty Clifford - Hobbies are my hobby." />
|
||||
<meta property="og:title" content="Ty Clifford - Members - Hobbies are my hobby." />
|
||||
<meta property="og:image" content="<?php echo $mainURL; ?>/images/fa2.jpg" />
|
||||
<meta property="og:image:alt" content="Profile image of Ty Clifford. Making a funny face squinting, Eye everything." />
|
||||
<meta name="twitter:site" content="@_tyclifford" />
|
||||
<meta name="twitter:title" content="Ty Clifford" />
|
||||
<meta name="twitter:description" content="Ty's Podcast" />
|
||||
|
||||
<title>Ty Clifford - Hobbies are my hobby.</title>
|
||||
|
||||
<!-- FAV and TOUCH ICONS -->
|
||||
<link rel="shortcut icon" href="<?php echo $mainURL; ?>/images/ico/fa-bl.ico">
|
||||
<link rel="apple-touch-icon" href="<?php echo $mainURL; ?>/images/ico/fa-bl.jpg"/>
|
||||
|
||||
<!-- FONTS -->
|
||||
<link rel="stylesheet" type="text/css" href="<?php echo $mainURL; ?>/css/fonts/hk-grotesk/style.css">
|
||||
<link rel="stylesheet" type="text/css" href="<?php echo $mainURL; ?>/css/fonts/fontello/css/fontello.css">
|
||||
|
||||
<!-- STYLES -->
|
||||
<link rel="stylesheet" type="text/css" href="<?php echo $mainURL; ?>/css/main.css">
|
||||
|
||||
</head>
|
||||
|
||||
<body class="is-text-align-center is-masked-dark is-text-light" style="background-color: black;">
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- CONTENT-WRAP -->
|
||||
<div class="content-wrap">
|
||||
|
||||
<!-- CONTENT -->
|
||||
<div class="content">
|
||||
|
||||
|
||||
<p>Currently no content</p>
|
||||
|
||||
<hr />
|
||||
<p class="is-social-minimal-light"><a class="social-link email" href="mailto:ty@tyclifford.com"> <a class="social-link twitter" href="https://twitter.com/_tyclifford"></a> <a class="social-link github" href="https://github.com/snick512/tyclifforddotcomland"></a></p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<!-- CONTENT-WRAP -->
|
||||
|
||||
<!-- SCRIPTS -->
|
||||
<script src="../js/main.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user