prepare("SELECT COUNT(*) FROM registration_attempts WHERE identifier = :identifier AND created_at > datetime('now', :window)"); $stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']); return (int) $stmt->fetchColumn(); } function recordRegistrationAttempt(PDO $pdo, string $identifier): void { $pdo->prepare('INSERT INTO registration_attempts (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]); } $errors = []; $username = ''; $email = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $username = trim($_POST['username'] ?? ''); $email = trim($_POST['email'] ?? ''); $password = $_POST['password'] ?? ''; $email = filter_var($email, FILTER_SANITIZE_EMAIL); $pdo = getDbConnection(); $pdo->exec('CREATE TABLE IF NOT EXISTS registration_attempts ( id INTEGER PRIMARY KEY AUTOINCREMENT, identifier TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_registration_attempts_identifier ON registration_attempts (identifier)'); $ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $registrationAttemptId = null; if (!csrf_verify($_POST['csrf_token'] ?? null)) { $errors['csrf'] = 'Your session has expired. Please refresh the page and try again.'; } else { // Check the per-IP limit and reserve a slot atomically, so concurrent requests // can't all read the same stale count before any of them commits. The reservation // is released below if this request doesn't end up creating an account, so only // actual registrations count against the limit — matching the previous behavior. $pdo->exec('BEGIN IMMEDIATE'); try { if (registrationAttemptCount($pdo, $ipIdentifier, REGISTRATION_WINDOW_SECONDS) >= MAX_REGISTRATIONS_PER_IP) { $errors['rate_limit'] = 'Too many accounts have been created from this network. Please try again later.'; } else { recordRegistrationAttempt($pdo, $ipIdentifier); $registrationAttemptId = (int) $pdo->lastInsertId(); } $pdo->exec('COMMIT'); } catch (Throwable $e) { $pdo->exec('ROLLBACK'); throw $e; } } if (empty($errors)) { if ($username === '') { $errors['username'] = 'Username is required.'; } elseif (strlen($username) < 3 || strlen($username) > 12) { $errors['username'] = 'Username must be between 3 and 12 characters long.'; } elseif (!preg_match('/^[a-z0-9]+$/', $username)) { $errors['username'] = 'Username can only contain lowercase letters (a-z) and numbers (0-9).'; } else { $stmt = $pdo->prepare('SELECT 1 FROM users WHERE username = :username'); $stmt->execute(['username' => $username]); if ($stmt->fetch()) { $errors['username'] = 'That username is not available.'; } } if ($email === '') { $errors['email'] = 'Email is required.'; } elseif (strlen($email) > 100) { $errors['email'] = 'Email cannot exceed 100 characters.'; } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors['email'] = 'Please enter a valid email address.'; } else { $stmt = $pdo->prepare('SELECT 1 FROM users WHERE email = :email'); $stmt->execute(['email' => $email]); if ($stmt->fetch()) { $errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead."; } } if ($password === '') { $errors['password'] = 'Password is required.'; } elseif (strlen($password) < 8) { $errors['password'] = 'Password must be at least 8 characters long.'; } elseif (strlen($password) > 72) { $errors['password'] = 'Password cannot exceed 72 characters.'; } if (empty($errors)) { $hashedPassword = password_hash($password, PASSWORD_DEFAULT); try { $stmt = $pdo->prepare('INSERT INTO users (username, email, password, credits) VALUES (:username, :email, :password, 0)'); $stmt->execute([ 'username' => $username, 'email' => $email, 'password' => $hashedPassword, ]); $newUserId = (int) $pdo->lastInsertId(); } catch (PDOException $e) { if (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.username')) { $errors['username'] = 'That username is not available.'; } elseif (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.email')) { $errors['email'] = "We couldn't create your account with those details. If you already have an account, try logging in instead."; } else { throw $e; } } if (empty($errors)) { start_app_session(); session_regenerate_id(true); $_SESSION['user_id'] = $newUserId; $_SESSION['username'] = $username; $_SESSION['session_version'] = 0; $verifyToken = createTicket($newUserId, 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS); $appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/'); $verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken); sendEmailVerificationEmail($email, $username, $verifyLink); header('Location: /'); exit; } } } // Reaching here means this request didn't end in a created account (validation // failed, a username/email collision was caught, or the rate limit itself // rejected it) — release any reserved slot so only actual registrations count. if ($registrationAttemptId !== null) { $pdo->prepare('DELETE FROM registration_attempts WHERE id = :id')->execute(['id' => $registrationAttemptId]); } } $pageTitle = 'Register'; ?>
Create an account and bring your imagination to life.
Already have an account? Log in