prepare("SELECT COUNT(*) FROM password_reset_requests WHERE identifier = :identifier AND created_at > datetime('now', :window)"); $stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']); return (int) $stmt->fetchColumn(); } function recordPasswordResetRequest(PDO $pdo, string $identifier): void { $pdo->prepare('INSERT INTO password_reset_requests (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]); } $errors = []; $email = ''; $submitted = false; if ($_SERVER['REQUEST_METHOD'] === 'POST') { $email = trim($_POST['email'] ?? ''); if (!csrf_verify($_POST['csrf_token'] ?? null)) { $errors['csrf'] = 'Your session has expired. Please refresh the page and try again.'; } elseif ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { $errors['email'] = 'Please enter a valid email address.'; } if (empty($errors)) { $pdo = getDbConnection(); $pdo->exec('CREATE TABLE IF NOT EXISTS password_reset_requests ( 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_password_reset_requests_identifier ON password_reset_requests (identifier)'); $ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); $emailIdentifier = 'email:' . strtolower($email); // Check both limits and record the reservation atomically, so concurrent requests // can't all read the same stale counts before any of them commits. Kept as a short // transaction that excludes the mail send below, since holding a write transaction // open across a slow network call is what caused the "database is locked" issue // fixed earlier in create-critter.php. $pdo->exec('BEGIN IMMEDIATE'); try { $rateLimited = passwordResetRequestCount($pdo, $ipIdentifier, PASSWORD_RESET_REQUEST_WINDOW_SECONDS) >= MAX_PASSWORD_RESET_REQUESTS_PER_IP || passwordResetRequestCount($pdo, $emailIdentifier, PASSWORD_RESET_REQUEST_WINDOW_SECONDS) >= MAX_PASSWORD_RESET_REQUESTS_PER_EMAIL; if (!$rateLimited) { recordPasswordResetRequest($pdo, $ipIdentifier); recordPasswordResetRequest($pdo, $emailIdentifier); } $pdo->exec('COMMIT'); } catch (Throwable $e) { $pdo->exec('ROLLBACK'); throw $e; } if (!$rateLimited) { $stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = :email'); $stmt->execute(['email' => $email]); $user = $stmt->fetch(PDO::FETCH_ASSOC); if ($user) { $token = createPasswordResetTicket($pdo, (int) $user['id']); $appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/'); $resetLink = $appUrl . '/reset-password.php?token=' . urlencode($token); sendPasswordResetEmail($email, $user['username'], $resetLink); } } // Always show the same success state, whether or not the email matched an account // or the request was throttled, so this page can't be used to probe which emails // are registered or to detect the rate limit. $submitted = true; } } $pageTitle = 'Forgot Password'; ?>
If an account exists for , we've sent instructions to reset your password. The link expires in 20 minutes.
Enter your email and we'll send you a link to reset it.
Remembered it? Log in