From 4c2857b445007f80a9f3a79132463c565fe3907a Mon Sep 17 00:00:00 2001 From: Cody Cook Date: Mon, 17 Feb 2025 22:03:33 -0800 Subject: [PATCH] I'm in a rush to release so I am adding features that are needed to make it usable. --- classes/DJ.php | 17 +- classes/Mix.php | 20 +- classes/User.php | 110 +- composer.json | 3 +- dj.php | 14 +- forgot-password.php | 121 ++ genre.php | 14 +- includes/.htaccess | 4 + includes/lang_loader.php | 5 + includes/navbar.php | 7 +- locale/en-US/messages.php | 5 +- login.php | 171 +- mix.php | 62 +- mixshow.php | 13 +- password-reset.php | 124 ++ privacy.php | 4059 +++++++------------------------------ profile.php | 278 +++ register.php | 187 ++ update_email.php | 97 + update_name.php | 43 + update_password.php | 84 + update_username.php | 53 + upload.php | 149 ++ upload_details.php | 245 +++ verify_email.php | 65 + 25 files changed, 2475 insertions(+), 3475 deletions(-) create mode 100644 forgot-password.php create mode 100644 includes/.htaccess create mode 100644 password-reset.php create mode 100644 profile.php create mode 100644 register.php create mode 100644 update_email.php create mode 100644 update_name.php create mode 100644 update_password.php create mode 100644 update_username.php create mode 100644 upload.php create mode 100644 upload_details.php create mode 100644 verify_email.php diff --git a/classes/DJ.php b/classes/DJ.php index baefa4f..e131810 100644 --- a/classes/DJ.php +++ b/classes/DJ.php @@ -139,7 +139,19 @@ class DJ private function loadDJMixes(): void { - $stmt = $this->db->prepare("SELECT * FROM mix WHERE dj1 = ? OR dj2 = ? OR dj3 = ?"); + // Determine if the current user is an admin. + $isAdmin = false; + if (isset($_SESSION['user']) && isset($_SESSION['user']['role']) && $_SESSION['user']['role'] === 'admin') { + $isAdmin = true; + } + + if ($isAdmin) { + $stmt = $this->db->prepare("SELECT * FROM mix WHERE dj1 = ? OR dj2 = ? OR dj3 = ?"); + } else { + // Only return mixes that are approved (pending = 0) for non-admin users. + $stmt = $this->db->prepare("SELECT * FROM mix WHERE (dj1 = ? OR dj2 = ? OR dj3 = ?) AND pending = 0"); + } + $stmt->bind_param("iii", $this->id, $this->id, $this->id); $stmt->execute(); $result = $stmt->get_result(); @@ -149,10 +161,9 @@ class DJ } $stmt->close(); $this->mixes = $mixes; - - } + private function loadBySlug(): bool { $socials = []; diff --git a/classes/Mix.php b/classes/Mix.php index 7d92826..95fece0 100644 --- a/classes/Mix.php +++ b/classes/Mix.php @@ -57,7 +57,15 @@ class Mix private function get_mix_by_id() { - $stmt = $this->db->prepare("SELECT * FROM mix WHERE id = ?"); + // Check if current user is admin + $isAdmin = (isset($_SESSION['user']) && isset($_SESSION['user']['role']) && $_SESSION['user']['role'] === 'admin'); + + if ($isAdmin) { + $stmt = $this->db->prepare("SELECT * FROM mix WHERE id = ?"); + } else { + // Only return approved mixes (pending = 0) for non-admins + $stmt = $this->db->prepare("SELECT * FROM mix WHERE id = ? AND pending = 0"); + } $stmt->bind_param("i", $this->id); $stmt->execute(); $result = $stmt->get_result(); @@ -65,7 +73,6 @@ class Mix $stmt->close(); return $mix; } - /** * @param $mix * @return true @@ -213,7 +220,14 @@ class Mix private function get_mix_by_slug() { - $stmt = $this->db->prepare("SELECT * FROM mix WHERE slug = ?"); + // Check if current user is admin + $isAdmin = (isset($_SESSION['user']) && isset($_SESSION['user']['role']) && $_SESSION['user']['role'] === 'admin'); + + if ($isAdmin) { + $stmt = $this->db->prepare("SELECT * FROM mix WHERE slug = ?"); + } else { + $stmt = $this->db->prepare("SELECT * FROM mix WHERE slug = ? AND pending = 0"); + } $stmt->bind_param("s", $this->slug); $stmt->execute(); $result = $stmt->get_result(); diff --git a/classes/User.php b/classes/User.php index 1b0a6c2..67343b0 100644 --- a/classes/User.php +++ b/classes/User.php @@ -61,8 +61,116 @@ Class User{ return $user; } - public function login(mixed $username, mixed $password) + public function login($email, $password) { + // Retrieve user record by email + $stmt = $this->db->prepare("SELECT * FROM users WHERE email = ?"); + $stmt->bind_param("s", $email); + $stmt->execute(); + $result = $stmt->get_result(); + $user_data = $result->fetch_assoc(); + $stmt->close(); + + // Check login_attempts table for lockout status + $stmt = $this->db->prepare("SELECT * FROM login_attempts WHERE email = ?"); + $stmt->bind_param("s", $email); + $stmt->execute(); + $attempt_data = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + $current_time = new \DateTime(); + + if ($attempt_data && !empty($attempt_data['lockout_until'])) { + $lockout_until = new \DateTime($attempt_data['lockout_until']); + if ($current_time < $lockout_until) { + return "Account locked until " . $lockout_until->format('Y-m-d H:i:s') . ". Please try again later."; + } + } + + // If no user record found, still update login_attempts to mitigate enumeration issues + if (!$user_data) { + $this->updateFailedAttempt($email); + return "Invalid email or password."; + } + + // Verify the password using password_verify + if (password_verify($password, $user_data['password'])) { + // Successful login – clear login attempts and set session variables + $this->resetLoginAttempts($email); + $_SESSION['user'] = [ + 'id' => $user_data['id'], + 'email' => $user_data['email'], + 'username' => $user_data['username'], + 'role' => $user_data['isAdmin'] ? 'admin' : 'user' + ]; + return true; + } else { + $attempts = $this->updateFailedAttempt($email); + return "Invalid email or password. Attempt $attempts of 3."; + } + } + + /** + * Update (or create) a record in the login_attempts table for a failed attempt. + * If attempts reach 3, set a lockout that doubles each time. + * Returns the current number of attempts. + */ + private function updateFailedAttempt($email) + { + // Check for an existing record + $stmt = $this->db->prepare("SELECT * FROM login_attempts WHERE email = ?"); + $stmt->bind_param("s", $email); + $stmt->execute(); + $record = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + + $current_time = new \DateTime(); + + if ($record) { + $attempts = $record['attempts'] + 1; + $lockouts = $record['lockouts']; + if ($attempts >= 3) { + // Increment lockouts and calculate the new lockout duration: + // Duration in minutes = 30 * 2^(lockouts) + $lockouts++; + $duration = 30 * pow(2, $lockouts - 1); + $lockout_until = clone $current_time; + $lockout_until->modify("+{$duration} minutes"); + // Reset attempts to 0 on lockout + $attempts = 0; + $stmt = $this->db->prepare("UPDATE login_attempts SET attempts = ?, lockouts = ?, last_attempt = NOW(), lockout_until = ? WHERE email = ?"); + $lockout_until_str = $lockout_until->format('Y-m-d H:i:s'); + $stmt->bind_param("iiss", $attempts, $lockouts, $lockout_until_str, $email); + $stmt->execute(); + $stmt->close(); + } else { + $stmt = $this->db->prepare("UPDATE login_attempts SET attempts = ?, last_attempt = NOW() WHERE email = ?"); + $stmt->bind_param("is", $attempts, $email); + $stmt->execute(); + $stmt->close(); + } + return $attempts; + } else { + // Create a new record for this email + $attempts = 1; + $lockouts = 0; + $stmt = $this->db->prepare("INSERT INTO login_attempts (email, attempts, lockouts, last_attempt) VALUES (?, ?, ?, NOW())"); + $stmt->bind_param("sii", $email, $attempts, $lockouts); + $stmt->execute(); + $stmt->close(); + return $attempts; + } + } + + /** + * Reset the login_attempts record for the given email. + */ + private function resetLoginAttempts($email) + { + $stmt = $this->db->prepare("DELETE FROM login_attempts WHERE email = ?"); + $stmt->bind_param("s", $email); + $stmt->execute(); + $stmt->close(); } diff --git a/composer.json b/composer.json index 3eb48d8..7b6d363 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ "yosymfony/toml": "*", "ext-mysqli": "*", "ext-curl": "*", - "phpunit/phpunit": ">8.5.1.0" + "phpunit/phpunit": ">8.5.1.0", + "aws/aws-sdk-php": "*" }, "autoload": { "psr-4": { diff --git a/dj.php b/dj.php index dc048d0..d1b1854 100644 --- a/dj.php +++ b/dj.php @@ -233,15 +233,11 @@ require_once 'includes/header.php'; $utm_params = '?utm_source=website&utm_medium=share_modal&utm_campaign=sharing'; $share_url = urlencode($url . $utm_params); ?> - Copy URL + Share to - Facebook + target="_blank" class="btn btn-primary w-100 mb-2" onclick="hideModal()"> Share to - Twitter - Share to Instagram + target="_blank" class="btn btn-info w-100 mb-2" onclick="hideModal()"> @@ -260,10 +256,10 @@ require_once 'includes/header.php'; copyLinkBtn.onclick = function () { navigator.clipboard.writeText(urlToCopy).then(function () { - alert('URL copied to clipboard'); + alert(''); shareModal.hide(); }, function (err) { - alert('Failed to copy URL: ' + err); + alert(': ' + err); }); } diff --git a/forgot-password.php b/forgot-password.php new file mode 100644 index 0000000..71299ad --- /dev/null +++ b/forgot-password.php @@ -0,0 +1,121 @@ +prepare("SELECT id, username FROM users WHERE email = ?"); + $stmt->bind_param("s", $email); + $stmt->execute(); + $result = $stmt->get_result(); + $userData = $result->fetch_assoc(); + $stmt->close(); + + // Always show a success message (to avoid disclosing which emails are registered) + $_SESSION['success'] = "If the email exists in our system, a password reset link has been sent."; + + if ($userData) { + $user_id = $userData['id']; + // Generate a password reset verification code valid for 15 minutes + $verification_code = bin2hex(random_bytes(16)); + $expires_at = date("Y-m-d H:i:s", strtotime("+15 minutes")); + + // Insert a record with purpose 'password_reset' + $stmt = $db->prepare("REPLACE INTO email_verifications (user_id, email, verification_code, expires_at, purpose) VALUES (?, ?, ?, ?, 'password_reset')"); + $stmt->bind_param("isss", $user_id, $email, $verification_code, $expires_at); + $stmt->execute(); + $stmt->close(); + + // Send password reset email via AWS SES + $sesClient = new SesClient([ + 'version' => 'latest', + 'region' => $config['aws']['ses']['region'], + 'credentials' => [ + 'key' => $config['aws']['ses']['access_key'], + 'secret' => $config['aws']['ses']['secret_key'], + ] + ]); + + $sender_email = $config['aws']['ses']['sender_email']; + $recipient_email = $email; + $subject = "Password Reset Request"; + $reset_link = $config['app']['url'] . "/password-reset.php?code={$verification_code}"; + $body_text = "You have requested to reset your password. Please click the link below to reset your password:\n\n"; + $body_text .= "{$reset_link}\n\nIf you did not request this, please ignore this email. This link will expire in 15 minutes."; + + try { + $result = $sesClient->sendEmail([ + 'Destination' => [ + 'ToAddresses' => [$recipient_email], + ], + 'ReplyToAddresses' => [$sender_email], + 'Source' => $sender_email, + 'Message' => [ + 'Body' => [ + 'Text' => [ + 'Charset' => 'UTF-8', + 'Data' => $body_text, + ], + ], + 'Subject' => [ + 'Charset' => 'UTF-8', + 'Data' => $subject, + ], + ], + ]); + } catch (AwsException $e) { + // Optionally log the error without disclosing details to the user. + } + } + header("Location: forgot-password.php"); + exit; +} + +require_once 'includes/header.php'; +?> + +
+
+
+
+ ' . htmlspecialchars($_SESSION['error']) . '
'; + unset($_SESSION['error']); + } + if(isset($_SESSION['success'])) { + echo ''; + unset($_SESSION['success']); + } + ?> +
+
+

Forgot Password

+
+
+ + +
+ +
+
+
+
+
+ +
+ + diff --git a/genre.php b/genre.php index 60a5d9a..28d225f 100644 --- a/genre.php +++ b/genre.php @@ -156,15 +156,11 @@ require_once 'includes/header.php'; $utm_params = '?utm_source=website&utm_medium=share_modal&utm_campaign=sharing'; $share_url = urlencode($url . $utm_params); ?> - Copy URL + Share to - Facebook + target="_blank" class="btn btn-primary w-100 mb-2" onclick="hideModal()"> Share to - Twitter - Share to Instagram + target="_blank" class="btn btn-dark w-100 mb-2" onclick="hideModal()"> @@ -183,10 +179,10 @@ require_once 'includes/header.php'; copyLinkBtn.onclick = function () { navigator.clipboard.writeText(urlToCopy).then(function () { - alert('URL copied to clipboard'); + alert(''); shareModal.hide(); }, function (err) { - alert('Failed to copy URL: ' + err); + alert(': ' + err); }); } diff --git a/includes/.htaccess b/includes/.htaccess new file mode 100644 index 0000000..f3afe9e --- /dev/null +++ b/includes/.htaccess @@ -0,0 +1,4 @@ + + Order allow,deny + Deny from all + diff --git a/includes/lang_loader.php b/includes/lang_loader.php index cf9410a..9de5556 100644 --- a/includes/lang_loader.php +++ b/includes/lang_loader.php @@ -11,3 +11,8 @@ if (isset($_GET['lang'])) { } $locale = loadLocale($lang); + +function langPrintString($string, $lang) +{ + echo $lang[$string]; +} \ No newline at end of file diff --git a/includes/navbar.php b/includes/navbar.php index 87819d9..74e3e2c 100644 --- a/includes/navbar.php +++ b/includes/navbar.php @@ -65,7 +65,12 @@ $current_lang = $_SESSION['lang'] ?? $config['app']['locale']; +require_once 'includes/header.php'; +?> +
+
+
+
+ + ' . htmlspecialchars($_SESSION['error']) . ' + +
'; + unset($_SESSION['error']); + } + ?> +
+
+

Login

+
+
+ + +
+
+ +
+ +
-
- - -
- - -
+
+
+ + +
+ + + +
+
+

Don't have an account? Sign up

+
+ +
- + + + + diff --git a/mix.php b/mix.php index 47ad348..976c2a1 100644 --- a/mix.php +++ b/mix.php @@ -39,7 +39,7 @@ require_once 'includes/header.php'; ?>