issues identified by Davidson CI
pauljd/davidson-ci: Davidson CI / quality (push) Successful in 14s
pauljd/davidson-ci: Davidson CI / ai-review (push) Successful in 1m5s

This commit is contained in:
2026-09-09 22:49:46 +01:00
parent e002304371
commit 9805d5056d
15 changed files with 369 additions and 92 deletions
+6
View File
@@ -48,6 +48,12 @@ if (!$critter) {
exit;
}
if ($method === 'POST' && !csrf_verify($_POST['csrf_token'] ?? null)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
if ($method === 'POST') {
$action = $_POST['action'] ?? '';
+59 -6
View File
@@ -1,6 +1,8 @@
<?php
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
require __DIR__ . '/lib/ticket.php';
require __DIR__ . '/lib/mailer.php';
start_app_session();
if (!isset($_SESSION['username']) || !isset($_SESSION['user_id'])) {
@@ -12,12 +14,38 @@ $isLoggedIn = isset($_SESSION['username']) && isset($_SESSION['user_id']);
const DAILY_CRITTER_LIMIT = 30;
const CRITTER_COOLDOWN_SECONDS = 15;
const VERIFICATION_RESEND_COOLDOWN_SECONDS = 60;
$pdo = getDbConnection();
$creditsStmt = $pdo->prepare('SELECT credits FROM users WHERE id = :id');
$creditsStmt->execute(['id' => $_SESSION['user_id']]);
$userCredits = (int) $creditsStmt->fetchColumn();
$userStmt = $pdo->prepare('SELECT email, username, credits, email_verified_at FROM users WHERE id = :id');
$userStmt->execute(['id' => $_SESSION['user_id']]);
$userRow = $userStmt->fetch(PDO::FETCH_ASSOC);
$userCredits = (int) ($userRow['credits'] ?? 0);
$isEmailVerified = !empty($userRow['email_verified_at']);
$verificationEmailSent = false;
$verificationResendError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'resend_verification') {
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$verificationResendError = 'Your session has expired. Please refresh the page and try again.';
} elseif ($isEmailVerified) {
$verificationResendError = 'Your email is already verified.';
} elseif (
isset($_SESSION['last_verification_resend_at'])
&& (time() - $_SESSION['last_verification_resend_at']) < VERIFICATION_RESEND_COOLDOWN_SECONDS
) {
$verificationResendError = 'Please wait a minute before requesting another verification email.';
} else {
$verifyToken = createTicket((int) $_SESSION['user_id'], 'email_verify', EMAIL_VERIFICATION_LIFETIME_SECONDS);
$appUrl = rtrim(getenv('APP_URL') ?: 'https://ourapp.local', '/');
$verifyLink = $appUrl . '/verify-email.php?token=' . urlencode($verifyToken);
sendEmailVerificationEmail($userRow['email'], $userRow['username'], $verifyLink);
$_SESSION['last_verification_resend_at'] = time();
$verificationEmailSent = true;
}
}
$pdo->exec('CREATE TABLE IF NOT EXISTS critters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -150,6 +178,10 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitReached) {
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
}
foreach ($fields as $field => $info) {
$value = $_POST[$field] ?? '';
$selected[$field] = $value;
@@ -323,7 +355,27 @@ $pageTitle = 'Create Critter';
<body class="bg-amber-50 min-h-screen flex flex-col">
<?php require __DIR__ . '/lib/header.php'; ?>
<main class="flex-1">
<?php if ($dailyLimitReached): ?>
<?php if (!$isEmailVerified): ?>
<div class="max-w-md mx-auto px-6 py-20 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
<i data-lucide="mail-check" class="h-8 w-8"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900 mb-2">Verify your email to start creating</h1>
<p class="text-gray-600">We sent a verification link to your email when you signed up. Verify it to unlock your <?php echo STARTING_CREDITS; ?> free credits.</p>
<?php if ($verificationEmailSent): ?>
<p class="mt-4 text-sm text-green-600">Verification email sent! Check your inbox.</p>
<?php elseif ($verificationResendError): ?>
<p class="mt-4 text-sm text-red-600"><?php echo htmlspecialchars($verificationResendError, ENT_QUOTES, 'UTF-8'); ?></p>
<?php endif; ?>
<form method="post" class="mt-6">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="resend_verification">
<button type="submit" class="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold px-5 py-2.5 shadow-sm hover:from-purple-700 hover:to-pink-600">
<i data-lucide="send" class="h-4 w-4"></i> Resend verification email
</button>
</form>
</div>
<?php elseif ($dailyLimitReached): ?>
<div class="max-w-md mx-auto px-6 py-20 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-purple-50 text-purple-300 mx-auto mb-4">
<i data-lucide="calendar-clock" class="h-8 w-8"></i>
@@ -351,12 +403,13 @@ $pageTitle = 'Create Critter';
</div>
<div class="rounded-3xl bg-white ring-1 ring-gray-100 shadow-sm p-6 sm:p-8">
<?php if (isset($errors['credits']) || isset($errors['cooldown'])): ?>
<?php if (isset($errors['credits']) || isset($errors['cooldown']) || isset($errors['csrf'])): ?>
<div class="mb-5 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['credits'] ?? $errors['cooldown'], ENT_QUOTES, 'UTF-8'); ?>
<?php echo htmlspecialchars($errors['credits'] ?? $errors['cooldown'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form id="createCritterForm" action="create-critter.php" method="post" novalidate class="space-y-5">
<?php echo csrf_field(); ?>
<div>
<label for="adjective_id" class="block text-sm font-medium text-gray-700 mb-1">Adjective</label>
<div class="relative">
+5
View File
@@ -1,4 +1,6 @@
<?php
const STARTING_CREDITS = 3;
function getDbConnection(): PDO
{
static $pdo = null;
@@ -21,6 +23,9 @@ function getDbConnection(): PDO
if (!in_array('credits', $userColumns, true)) {
$pdo->exec('ALTER TABLE users ADD COLUMN credits INTEGER NOT NULL DEFAULT 0');
}
if (!in_array('email_verified_at', $userColumns, true)) {
$pdo->exec('ALTER TABLE users ADD COLUMN email_verified_at TEXT');
}
}
return $pdo;
+55 -11
View File
@@ -10,6 +10,22 @@ if (isset($_SESSION['username'])) {
exit;
}
const PASSWORD_RESET_REQUEST_WINDOW_SECONDS = 3600;
const MAX_PASSWORD_RESET_REQUESTS_PER_IP = 10;
const MAX_PASSWORD_RESET_REQUESTS_PER_EMAIL = 3;
function passwordResetRequestCount(PDO $pdo, string $identifier, int $windowSeconds): int
{
$stmt = $pdo->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;
@@ -17,25 +33,46 @@ $submitted = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
if ($email === '' || !filter_var($email, FILTER_VALIDATE_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();
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$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)');
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);
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$emailIdentifier = 'email:' . strtolower($email);
$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);
$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,
// so this page can't be used to check which emails are registered.
// 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;
}
}
@@ -68,7 +105,14 @@ $pageTitle = 'Forgot Password';
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Forgot your password?</h1>
<p class="mt-2 text-gray-500 text-sm text-center">Enter your email and we'll send you a link to reset it.</p>
<?php if (isset($errors['csrf'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form action="forgot-password.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div class="relative">
+8 -1
View File
@@ -66,7 +66,11 @@ if ($isLoggedIn && $_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
$postedCritterId = $_POST['critter_id'] ?? '';
if (in_array($action, ['toggle_favorite', 'toggle_archived'], true) && ctype_digit((string) $postedCritterId)) {
if (
csrf_verify($_POST['csrf_token'] ?? null)
&& in_array($action, ['toggle_favorite', 'toggle_archived'], true)
&& ctype_digit((string) $postedCritterId)
) {
$stmt = $pdo->prepare('SELECT author_id, isFavorite, isArchived FROM critters WHERE id = :id');
$stmt->execute(['id' => $postedCritterId]);
$target = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -352,6 +356,7 @@ $pageTitle = 'Welcome';
>
</button>
<form action="" method="post" class="absolute top-3 right-3">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="toggle_favorite">
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
<button type="submit" aria-label="<?php echo $critter['isFavorite'] ? 'Unfavorite' : 'Favorite'; ?>" class="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 shadow-sm transition-transform duration-150 hover:bg-white hover:scale-110">
@@ -373,6 +378,7 @@ $pageTitle = 'Welcome';
<i data-lucide="eye" class="h-4 w-4"></i>
</button>
<form action="" method="post">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="toggle_favorite">
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
<button type="submit" aria-label="<?php echo $critter['isFavorite'] ? 'Unfavorite' : 'Favorite'; ?>" class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-pink-500">
@@ -380,6 +386,7 @@ $pageTitle = 'Welcome';
</button>
</form>
<form action="" method="post">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="toggle_archived">
<input type="hidden" name="critter_id" value="<?php echo (int) $critter['id']; ?>">
<button type="submit" aria-label="<?php echo $critter['isArchived'] ? 'Unarchive' : 'Archive'; ?>" class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-green-600">
+6 -1
View File
@@ -207,12 +207,17 @@
if (event.key === 'Escape') closeCritterModal();
});
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.content : '';
}
function toggleModalFavorite() {
if (!currentCritterModalId) return;
fetch('api/critter-details.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: currentCritterModalId, action: 'toggle_favorite' }),
body: new URLSearchParams({ id: currentCritterModalId, action: 'toggle_favorite', csrf_token: getCsrfToken() }),
})
.then((res) => res.json())
.then((data) => {
+1
View File
@@ -2,6 +2,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="<?php echo htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8'); ?>">
<title><?php echo htmlspecialchars($pageTitle ?? 'OurApp', ENT_QUOTES, 'UTF-8'); ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+9 -7
View File
@@ -14,6 +14,7 @@ function configureAppMailer(): PHPMailer
$mail->Host = getenv('SMTP_HOST');
$mail->SMTPAuth = true;
$mail->Port = (int) getenv('SMTP_PORT');
$mail->SMTPSecure = $mail->Port === 465 ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
$mail->Username = getenv('SMTP_USERNAME');
$mail->Password = getenv('SMTP_PASSWORD');
$mail->SMTPOptions = [
@@ -30,24 +31,25 @@ function configureAppMailer(): PHPMailer
return $mail;
}
function sendRegistrationConfirmationEmail(string $toEmail, string $username): bool
function sendEmailVerificationEmail(string $toEmail, string $username, string $verifyLink): bool
{
try {
$mail = configureAppMailer();
$mail->addAddress($toEmail, $username);
$mail->Subject = 'Welcome to OurApp!';
$mail->Subject = 'Verify your email to start creating critters';
$safeUsername = htmlspecialchars($username, ENT_QUOTES, 'UTF-8');
$safeVerifyLink = htmlspecialchars($verifyLink, ENT_QUOTES, 'UTF-8');
$mail->Body = '
<div style="font-family: Arial, sans-serif; max-width: 480px; margin: 0 auto; padding: 32px 24px;">
<h1 style="color: #111827; font-size: 22px; margin-bottom: 8px;">Welcome, ' . $safeUsername . '! &#128062;</h1>
<p style="color: #4b5563; font-size: 15px; line-height: 1.6;">
Your OurApp account has been created. You are ready to start creating unique, AI-generated critters &mdash; pick an adjective, animal, action, and location, and watch your imagination come to life.
Your OurApp account has been created. Verify your email to unlock your free credits and start creating unique, AI-generated critters &mdash; pick an adjective, animal, action, and location, and watch your imagination come to life.
</p>
<p style="margin-top: 24px;">
<a href="https://ourapp.local/" style="display: inline-block; background: linear-gradient(to right, #9333ea, #ec4899); color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 24px; border-radius: 9999px;">
Start Creating
<a href="' . $safeVerifyLink . '" style="display: inline-block; background: linear-gradient(to right, #9333ea, #ec4899); color: #ffffff; text-decoration: none; font-weight: 600; padding: 12px 24px; border-radius: 9999px;">
Verify Email
</a>
</p>
<p style="color: #9ca3af; font-size: 13px; margin-top: 32px;">
@@ -55,13 +57,13 @@ function sendRegistrationConfirmationEmail(string $toEmail, string $username): b
</p>
</div>
';
$mail->AltBody = "Welcome, {$username}!\n\nYour OurApp account has been created. Start creating unique, AI-generated critters at https://ourapp.local/\n\nIf you didn't create this account, you can safely ignore this email.";
$mail->AltBody = "Welcome, {$username}!\n\nYour OurApp account has been created. Verify your email to unlock your free credits and start creating unique, AI-generated critters:\n\n{$verifyLink}\n\nIf you didn't create this account, you can safely ignore this email.";
$mail->send();
return true;
} catch (PHPMailerException $e) {
error_log('Registration confirmation email failed: ' . (isset($mail) ? $mail->ErrorInfo : $e->getMessage()));
error_log('Email verification email failed: ' . (isset($mail) ? $mail->ErrorInfo : $e->getMessage()));
return false;
}
+9 -2
View File
@@ -1,5 +1,6 @@
<?php
define('TICKET_LIFETIME_SECONDS', 20 * 60);
define('EMAIL_VERIFICATION_LIFETIME_SECONDS', 60 * 60 * 24 * 3);
function getTicketSecret(): string
{
@@ -12,9 +13,9 @@ function getTicketSecret(): string
return $secret;
}
function createTicket(int $userId, string $purpose = 'default'): string
function createTicket(int $userId, string $purpose = 'default', ?int $lifetimeSeconds = null): string
{
$expiresAt = time() + TICKET_LIFETIME_SECONDS;
$expiresAt = time() + ($lifetimeSeconds ?? TICKET_LIFETIME_SECONDS);
$payload = $userId . '|' . $expiresAt . '|' . $purpose;
$signature = hash_hmac('sha256', $payload, getTicketSecret());
@@ -75,6 +76,12 @@ function createPasswordResetTicket(PDO $pdo, int $userId): string
{
ensurePasswordResetTicketsTable($pdo);
// Opportunistic cleanup: every new ticket also sweeps out rows that are long past
// useful (expired for over a day), so this table doesn't grow unbounded without
// needing a separate cron job.
$pdo->prepare('DELETE FROM password_reset_tickets WHERE expires_at < :cutoff')
->execute(['cutoff' => time() - 86400]);
$token = createTicket($userId, 'password_reset');
$stmt = $pdo->prepare('INSERT INTO password_reset_tickets (token_hash, user_id, expires_at) VALUES (:token_hash, :user_id, :expires_at)');
+7 -2
View File
@@ -31,6 +31,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
}
if ($email === '') {
$errors['email'] = 'Email is required.';
}
@@ -104,9 +108,9 @@ $pageTitle = 'Log In';
<h1 class="text-2xl font-extrabold text-gray-900">Welcome back, explorer.</h1>
<p class="mt-1 text-gray-500 text-sm">Your critters have been waiting.</p>
<?php if (isset($errors['login'])): ?>
<?php if (isset($errors['login']) || isset($errors['csrf'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['login'], ENT_QUOTES, 'UTF-8'); ?>
<?php echo htmlspecialchars($errors['login'] ?? $errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
@@ -117,6 +121,7 @@ $pageTitle = 'Log In';
<?php endif; ?>
<form action="login.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<div class="relative">
+100 -60
View File
@@ -2,6 +2,7 @@
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
require __DIR__ . '/lib/mailer.php';
require __DIR__ . '/lib/ticket.php';
start_app_session();
if (isset($_SESSION['username'])) {
@@ -9,7 +10,20 @@ if (isset($_SESSION['username'])) {
exit;
}
const STARTING_CREDITS = 3;
const REGISTRATION_WINDOW_SECONDS = 3600;
const MAX_REGISTRATIONS_PER_IP = 5;
function registrationAttemptCount(PDO $pdo, string $identifier, int $windowSeconds): int
{
$stmt = $pdo->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 = '';
@@ -23,74 +37,93 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$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)');
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).';
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
} elseif (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 {
$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, :credits)');
$stmt->execute([
'username' => $username,
'email' => $email,
'password' => $hashedPassword,
'credits' => STARTING_CREDITS,
]);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'UNIQUE constraint failed: users.username')) {
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.';
} 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 ($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)) {
start_app_session();
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $pdo->lastInsertId();
$_SESSION['username'] = $username;
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
sendRegistrationConfirmationEmail($email, $username);
try {
$stmt = $pdo->prepare('INSERT INTO users (username, email, password, credits) VALUES (:username, :email, :password, 0)');
$stmt->execute([
'username' => $username,
'email' => $email,
'password' => $hashedPassword,
]);
recordRegistrationAttempt($pdo, $ipIdentifier);
} 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;
}
}
header('Location: /');
exit;
if (empty($errors)) {
$newUserId = (int) $pdo->lastInsertId();
start_app_session();
session_regenerate_id(true);
$_SESSION['user_id'] = $newUserId;
$_SESSION['username'] = $username;
$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;
}
}
}
}
@@ -122,7 +155,14 @@ $pageTitle = 'Register';
<h1 class="text-2xl font-extrabold text-gray-900">Start your collection.</h1>
<p class="mt-1 text-gray-500 text-sm">Create an account and bring your imagination to life.</p>
<?php if (isset($errors['csrf']) || isset($errors['rate_limit'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['csrf'] ?? $errors['rate_limit'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form action="register.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
<div class="relative">
+10 -1
View File
@@ -18,7 +18,9 @@ $success = false;
if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if ($password === '') {
if (!csrf_verify($_POST['csrf_token'] ?? null)) {
$errors['csrf'] = 'Your session has expired. Please refresh the page and try again.';
} elseif ($password === '') {
$errors['password'] = 'Password is required.';
} elseif (strlen($password) < 8) {
$errors['password'] = 'Password must be at least 8 characters long.';
@@ -70,7 +72,14 @@ $pageTitle = 'Reset Password';
<h1 class="text-2xl font-extrabold text-gray-900 text-center">Choose a new password</h1>
<p class="mt-2 text-gray-500 text-sm text-center">Make it something you'll remember.</p>
<?php if (isset($errors['csrf'])): ?>
<div class="mt-4 rounded-lg bg-red-50 text-red-700 text-sm px-4 py-3">
<?php echo htmlspecialchars($errors['csrf'], ENT_QUOTES, 'UTF-8'); ?>
</div>
<?php endif; ?>
<form action="reset-password.php" method="post" novalidate class="mt-6 space-y-4">
<?php echo csrf_field(); ?>
<input type="hidden" name="token" value="<?php echo htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); ?>">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
+18
View File
@@ -38,3 +38,21 @@ function start_app_session(): void
]);
session_start();
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . htmlspecialchars(csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
}
function csrf_verify(?string $token): bool
{
return is_string($token) && $token !== '' && isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
+3 -1
View File
@@ -62,7 +62,7 @@ if ($critter) {
));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $critter && $isOwner) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $critter && $isOwner && csrf_verify($_POST['csrf_token'] ?? null)) {
$action = $_POST['action'] ?? '';
if ($action === 'toggle_favorite') {
@@ -109,6 +109,7 @@ $pageTitle = 'View Critter';
<div class="flex justify-center gap-4 mt-6">
<?php if ($isOwner): ?>
<form action="single-critter.php?id=<?php echo (int) $critterId; ?>" method="post">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="toggle_favorite">
<button
type="submit"
@@ -118,6 +119,7 @@ $pageTitle = 'View Critter';
</button>
</form>
<form action="single-critter.php?id=<?php echo (int) $critterId; ?>" method="post">
<?php echo csrf_field(); ?>
<input type="hidden" name="action" value="toggle_archived">
<button
type="submit"
+73
View File
@@ -0,0 +1,73 @@
<?php
require __DIR__ . '/session.php';
require __DIR__ . '/db.php';
require __DIR__ . '/lib/ticket.php';
start_app_session();
$token = $_GET['token'] ?? '';
$userId = is_string($token) && $token !== '' ? decodeTicket($token, 'email_verify') : null;
$status = 'invalid';
if ($userId !== null) {
$pdo = getDbConnection();
$stmt = $pdo->prepare('UPDATE users SET email_verified_at = CURRENT_TIMESTAMP, credits = credits + :credits WHERE id = :id AND email_verified_at IS NULL');
$stmt->execute(['credits' => STARTING_CREDITS, 'id' => $userId]);
if ($stmt->rowCount() === 1) {
$status = 'verified';
} else {
$checkStmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = :id');
$checkStmt->execute(['id' => $userId]);
$existing = $checkStmt->fetchColumn();
$status = ($existing !== false && $existing !== null) ? 'already_verified' : 'invalid';
}
}
$pageTitle = 'Verify Email';
?>
<!DOCTYPE html>
<html lang="en">
<?php require __DIR__ . '/lib/head.php'; ?>
<body class="bg-amber-50 min-h-screen flex flex-col">
<?php require __DIR__ . '/lib/header.php'; ?>
<div class="flex-1 flex items-center justify-center bg-gradient-to-b from-amber-50 via-pink-50 to-white p-4 py-12">
<div class="w-full max-w-md bg-white rounded-3xl shadow-xl ring-1 ring-gray-100 p-8 sm:p-10 text-center">
<?php if ($status === 'verified'): ?>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-green-50 text-green-500 mx-auto mb-4">
<i data-lucide="badge-check" class="h-7 w-7"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900">Email verified!</h1>
<p class="mt-2 text-gray-500 text-sm">
Your email is confirmed and your free credits have been added to your account. You're ready to create.
</p>
<a href="/" class="mt-6 w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-pink-500 text-white font-semibold py-3 shadow-sm hover:from-purple-700 hover:to-pink-600">
Start Creating <i data-lucide="arrow-right" class="h-4 w-4"></i>
</a>
<?php elseif ($status === 'already_verified'): ?>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-500 mx-auto mb-4">
<i data-lucide="badge-check" class="h-7 w-7"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900">Already verified</h1>
<p class="mt-2 text-gray-500 text-sm">This email address has already been verified. You're all set.</p>
<a href="/" class="mt-6 w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
Go Home <i data-lucide="arrow-right" class="h-4 w-4"></i>
</a>
<?php else: ?>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-red-50 text-red-500 mx-auto mb-4">
<i data-lucide="shield-alert" class="h-7 w-7"></i>
</div>
<h1 class="text-2xl font-extrabold text-gray-900">Link expired</h1>
<p class="mt-2 text-gray-500 text-sm">
This verification link is invalid or has expired. You can request a new one from the create critter page.
</p>
<a href="/" class="mt-6 w-full inline-flex items-center justify-center gap-2 rounded-xl bg-gray-900 text-white font-semibold py-3 hover:bg-gray-800">
Go Home <i data-lucide="arrow-right" class="h-4 w-4"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php require __DIR__ . '/lib/footer.php'; ?>
</body>
</html>