Fixdd additonal issues identified by Davidson CI
This commit is contained in:
+15
-8
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user