Fixdd additonal issues identified by Davidson CI

This commit is contained in:
2026-09-09 21:02:31 +01:00
parent 0a64c09066
commit 59a89667f7
7 changed files with 160 additions and 36 deletions
+15 -8
View File
@@ -56,9 +56,11 @@ $pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_verb_id ON critters (verb_id
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_location_id ON critters (location_id)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_critters_author_id ON critters (author_id)');
function countCrittersCreatedToday(PDO $pdo): int
function countCrittersCreatedTodayByAuthor(PDO $pdo, int $authorId): int
{
return (int) $pdo->query("SELECT COUNT(*) FROM critters WHERE date(datetime) = date('now')")->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM critters WHERE author_id = :author_id AND date(datetime) = date('now')");
$stmt->execute(['author_id' => $authorId]);
return (int) $stmt->fetchColumn();
}
function secondsSinceLastCritterByAuthor(PDO $pdo, int $authorId): ?int
@@ -69,7 +71,7 @@ function secondsSinceLastCritterByAuthor(PDO $pdo, int $authorId): ?int
return $seconds === false ? null : (int) $seconds;
}
$dailyLimitReached = countCrittersCreatedToday($pdo) >= DAILY_CRITTER_LIMIT;
$dailyLimitReached = countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT;
$cooldownRemaining = 0;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@@ -157,7 +159,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitRea
}
}
if (empty($errors) && countCrittersCreatedToday($pdo) >= DAILY_CRITTER_LIMIT) {
if (empty($errors) && countCrittersCreatedTodayByAuthor($pdo, (int) $_SESSION['user_id']) >= DAILY_CRITTER_LIMIT) {
$dailyLimitReached = true;
}
@@ -249,10 +251,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitRea
$output = $prediction['output'] ?? null;
$imageUrl = is_array($output) ? ($output[0] ?? null) : $output;
}
} else {
// The Replicate request never went through, so give the reserved credit back.
$refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id');
$refundStmt->execute(['id' => $_SESSION['user_id']]);
}
$imageFilename = null;
@@ -282,6 +280,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && $userCredits > 0 && !$dailyLimitRea
}
}
if ($imageFilename === null) {
// Every unsuccessful path above (request failure, failed/canceled prediction,
// disallowed or missing image URL, oversized or non-image download) leaves
// $imageFilename null, so this single check refunds the reserved credit for
// all of them instead of only the initial request-failure case.
$refundStmt = $pdo->prepare('UPDATE users SET credits = credits + 1 WHERE id = :id');
$refundStmt->execute(['id' => $_SESSION['user_id']]);
}
$stmt = $pdo->prepare('INSERT INTO critters (noun_id, adjective_id, verb_id, location_id, author_id, image, datetime) VALUES (:noun_id, :adjective_id, :verb_id, :location_id, :author_id, :image, :datetime)');
$stmt->execute([
'noun_id' => $selected['noun_id'],
+1 -1
View File
@@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
$token = createTicket((int) $user['id'], 'password_reset');
$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);
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/media/output.css?v=<?php echo filemtime(__DIR__ . '/../media/output.css'); ?>">
<script src="https://unpkg.com/lucide@1.41.0/dist/umd/lucide.min.js"></script>
<script src="https://unpkg.com/lucide@1.41.0/dist/umd/lucide.min.js" integrity="sha384-5hfcTHqJTrDq2YPubIhzTtbnAx6tDcmRrxlfwLEierMHQtmz030891dt1/8VAupY" crossorigin="anonymous"></script>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script type="text/javascript">
Paddle.Environment.set("sandbox");
+57
View File
@@ -58,3 +58,60 @@ function decodeTicket(string $ticket, string $purpose = 'default'): ?int
return (int) $userId;
}
function ensurePasswordResetTicketsTable(PDO $pdo): void
{
$pdo->exec('CREATE TABLE IF NOT EXISTS password_reset_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id),
expires_at INTEGER NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)');
}
function createPasswordResetTicket(PDO $pdo, int $userId): string
{
ensurePasswordResetTicketsTable($pdo);
$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)');
$stmt->execute([
'token_hash' => hash('sha256', $token),
'user_id' => $userId,
'expires_at' => time() + TICKET_LIFETIME_SECONDS,
]);
return $token;
}
// Validates signature/purpose/expiry only. Safe to call on GET to decide page
// state, since it never marks the ticket used.
function isPasswordResetTicketWellFormed(string $token): bool
{
return decodeTicket($token, 'password_reset') !== null;
}
// Atomically claims the ticket: only the first caller to reach this for a given
// token gets a user id back, so a leaked or replayed reset link can reset the
// password at most once.
function consumePasswordResetTicket(PDO $pdo, string $token): ?int
{
$userId = decodeTicket($token, 'password_reset');
if ($userId === null) {
return null;
}
ensurePasswordResetTicketsTable($pdo);
$stmt = $pdo->prepare('UPDATE password_reset_tickets SET used_at = CURRENT_TIMESTAMP WHERE token_hash = :token_hash AND user_id = :user_id AND used_at IS NULL AND expires_at > :now');
$stmt->execute([
'token_hash' => hash('sha256', $token),
'user_id' => $userId,
'now' => time(),
]);
return $stmt->rowCount() === 1 ? $userId : null;
}
+46 -13
View File
@@ -8,12 +8,26 @@ if (isset($_SESSION['username'])) {
exit;
}
const LOGIN_ATTEMPT_WINDOW_SECONDS = 900;
const MAX_LOGIN_ATTEMPTS_PER_IP = 20;
const MAX_LOGIN_ATTEMPTS_PER_EMAIL = 8;
function loginAttemptCount(PDO $pdo, string $identifier, int $windowSeconds): int
{
$stmt = $pdo->prepare("SELECT COUNT(*) FROM login_attempts WHERE identifier = :identifier AND created_at > datetime('now', :window)");
$stmt->execute(['identifier' => $identifier, 'window' => '-' . $windowSeconds . ' seconds']);
return (int) $stmt->fetchColumn();
}
function recordLoginAttempt(PDO $pdo, string $identifier): void
{
$pdo->prepare('INSERT INTO login_attempts (identifier) VALUES (:identifier)')->execute(['identifier' => $identifier]);
}
$errors = [];
$email = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
sleep(4);
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
@@ -27,20 +41,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($errors)) {
$pdo = getDbConnection();
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
$pdo->exec('CREATE TABLE IF NOT EXISTS login_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_login_attempts_identifier ON login_attempts (identifier)');
if (!$user || !password_verify($password, $user['password'])) {
$errors['login'] = 'Invalid email or password.';
$ipIdentifier = 'ip:' . ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$emailIdentifier = 'email:' . strtolower($email);
if (
loginAttemptCount($pdo, $ipIdentifier, LOGIN_ATTEMPT_WINDOW_SECONDS) >= MAX_LOGIN_ATTEMPTS_PER_IP
|| loginAttemptCount($pdo, $emailIdentifier, LOGIN_ATTEMPT_WINDOW_SECONDS) >= MAX_LOGIN_ATTEMPTS_PER_EMAIL
) {
$errors['login'] = 'Too many login attempts. Please wait a while before trying again.';
} else {
start_app_session();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
header('Location: /');
exit;
if (!$user || !password_verify($password, $user['password'])) {
recordLoginAttempt($pdo, $ipIdentifier);
recordLoginAttempt($pdo, $emailIdentifier);
$errors['login'] = 'Invalid email or password.';
} else {
start_app_session();
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
header('Location: /');
exit;
}
}
}
}
+19 -13
View File
@@ -10,30 +10,36 @@ if (isset($_SESSION['username'])) {
}
$token = $_GET['token'] ?? ($_POST['token'] ?? '');
$userId = is_string($token) && $token !== '' ? decodeTicket($token, 'password_reset') : null;
$tokenIsWellFormed = is_string($token) && $token !== '' && isPasswordResetTicketWellFormed($token);
$errors = [];
$success = false;
if ($userId !== null && $_SERVER['REQUEST_METHOD'] === 'POST') {
if ($tokenIsWellFormed && $_SERVER['REQUEST_METHOD'] === 'POST') {
$password = $_POST['password'] ?? '';
if ($password === '') {
$errors['password'] = 'Password is required.';
} elseif (strlen($password) > 100) {
$errors['password'] = 'Password cannot exceed 100 characters.';
} elseif (strlen($password) < 6 || strlen($password) > 12) {
$errors['password'] = 'Password must be between 6 and 12 characters long.';
} 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)) {
$pdo = getDbConnection();
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE users SET password = :password WHERE id = :id');
$stmt->execute(['password' => $hashedPassword, 'id' => $userId]);
$userId = consumePasswordResetTicket($pdo, $token);
header('Location: login.php?reset=success');
exit;
if ($userId === null) {
$errors['password'] = 'This reset link has already been used or has expired. Please request a new one.';
} else {
$hashedPassword = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE users SET password = :password WHERE id = :id');
$stmt->execute(['password' => $hashedPassword, 'id' => $userId]);
header('Location: login.php?reset=success');
exit;
}
}
}
@@ -46,7 +52,7 @@ $pageTitle = 'Reset Password';
<?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">
<?php if ($userId === null): ?>
<?php if (!$tokenIsWellFormed): ?>
<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>
@@ -72,7 +78,7 @@ $pageTitle = 'Reset Password';
<i data-lucide="lock" class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400"></i>
<input
type="password" id="password" name="password"
placeholder="At least 6 characters"
placeholder="At least 8 characters"
class="w-full rounded-xl border border-gray-300 pl-10 pr-10 py-2.5 focus:outline-none focus:ring-2 focus:ring-purple-400"
>
<button
+21
View File
@@ -1,6 +1,27 @@
<?php
function send_app_csp_header(): void
{
if (headers_sent()) {
return;
}
header('Content-Security-Policy: ' . implode(' ', [
"script-src 'self' 'unsafe-inline' https://unpkg.com https://cdn.paddle.com;",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;",
"font-src https://fonts.gstatic.com;",
"img-src 'self' data:;",
"connect-src 'self' https://cdn.paddle.com https://api.paddle.com https://sandbox-api.paddle.com;",
"frame-src https://buy.paddle.com https://sandbox-buy.paddle.com;",
"object-src 'none';",
"base-uri 'self';",
"frame-ancestors 'none';",
]), true, 0);
}
function start_app_session(): void
{
send_app_csp_header();
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}