From 2bd16f1e4e92b44f67820e2c697c4856651ea59a Mon Sep 17 00:00:00 2001 From: glenneth Date: Tue, 4 Nov 2025 16:23:51 +0300 Subject: [PATCH] feat: Add password management and fix listener count - Add user password change functionality in profile page - Add admin password reset functionality in admin dashboard - Fix listener count to show total from Icecast root tag - Replace concatenate with format - Add with-error-handling to API endpoints - Make Icecast port binding configurable via ICECAST_BIND env var - Add comprehensive docstrings to public functions Note: Password reset has known issue with LambdaLite db:update not updating password-hash field. Issue reported as an issue. --- auth-routes.lisp | 50 +++++++++++++++++++++++++++ docker/docker-compose.yml | 2 +- frontend-partials.lisp | 61 ++++++++++++++++----------------- static/js/profile.js | 51 ++++++++++++++++++++++++++++ stream-control.lisp | 9 +++-- template/admin.ctml | 71 +++++++++++++++++++++++++++++++++++++++ template/profile.ctml | 22 ++++++++++++ user-management.lisp | 48 +++++++++++++++++++++----- 8 files changed, 270 insertions(+), 44 deletions(-) diff --git a/auth-routes.lisp b/auth-routes.lisp index 9a9942a..f8db8db 100644 --- a/auth-routes.lisp +++ b/auth-routes.lisp @@ -105,3 +105,53 @@ (api-output `(("status" . "error") ("message" . ,(format nil "Error creating user: ~a" e))) :status 500)))) + +;; API: Change user's own password +(define-api asteroid/user/change-password (current-password new-password) () + "API endpoint for users to change their own password" + (with-error-handling + (unless (and current-password new-password) + (error 'validation-error :message "Missing required fields")) + + (unless (>= (length new-password) 8) + (error 'validation-error :message "New password must be at least 8 characters")) + + (let* ((user-id (session-field 'user-id)) + (username (when user-id + (let ((user (find-user-by-id user-id))) + (when user (gethash "username" user)))))) + + (unless username + (error 'authentication-error :message "Not authenticated")) + + ;; Verify current password + (unless (verify-user-credentials username current-password) + (error 'authentication-error :message "Current password is incorrect")) + + ;; Update password + (unless (reset-user-password username new-password) + (error 'database-error :message "Failed to update password")) + + (api-output `(("status" . "success") + ("message" . "Password changed successfully")))))) + +;; API: Reset user password (admin only) +(define-api asteroid/admin/reset-password (username new-password) () + "API endpoint for admins to reset any user's password" + (require-role :admin) + (with-error-handling + (unless (and username new-password) + (error 'validation-error :message "Missing required fields")) + + (unless (>= (length new-password) 8) + (error 'validation-error :message "New password must be at least 8 characters")) + + (let ((user (find-user-by-username username))) + (unless user + (error 'not-found-error :message (format nil "User not found: ~a" username))) + + (unless (reset-user-password username new-password) + (error 'database-error :message "Failed to reset password")) + + (api-output `(("status" . "success") + ("message" . ,(format nil "Password reset for user: ~a" username))))))) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2e9b29a..7ac49e0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,7 +3,7 @@ services: image: infiniteproject/icecast:latest container_name: asteroid-icecast ports: - - "127.0.0.1:8000:8000" + - "${ICECAST_BIND:-127.0.0.1}:8000:8000" volumes: - ./icecast.xml:/etc/icecast.xml environment: diff --git a/frontend-partials.lisp b/frontend-partials.lisp index 5d5e82b..cc6f976 100644 --- a/frontend-partials.lisp +++ b/frontend-partials.lisp @@ -1,6 +1,11 @@ (in-package :asteroid) (defun icecast-now-playing (icecast-base-url) + "Fetch now-playing information from Icecast server. + + ICECAST-BASE-URL - Base URL of the Icecast server (e.g. http://localhost:8000) + + Returns a plist with :listenurl, :title, and :listeners, or NIL on error." (let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url)) (response (drakma:http-request icecast-url :want-stream nil @@ -9,29 +14,32 @@ (let ((xml-string (if (stringp response) response (babel:octets-to-string response :encoding :utf-8)))) - ;; Simple XML parsing to extract source information - ;; Look for sections and extract title, listeners, etc. - (multiple-value-bind (match-start match-end) - (cl-ppcre:scan "" xml-string) - - (if match-start - (let* ((source-section (subseq xml-string match-start - (or (cl-ppcre:scan "" xml-string :start match-start) - (length xml-string)))) - (titlep (cl-ppcre:all-matches "" source-section)) - (listenersp (cl-ppcre:all-matches "<listeners>" source-section)) - (title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?).*" source-section "\\1") "Unknown")) - (listeners (if listenersp (cl-ppcre:regex-replace-all ".*(.*?).*" source-section "\\1") "0"))) - `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) - (:title . ,title) - (:listeners . ,(parse-integer listeners :junk-allowed t)))) - `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) - (:title . "Unknown") - (:listeners . "Unknown")))))))) + ;; Extract total listener count from root tag (sums all mount points) + ;; Extract title from asteroid.mp3 mount point + (let* ((total-listeners (multiple-value-bind (match groups) + (cl-ppcre:scan-to-strings "(\\d+)" xml-string) + (if (and match groups) + (parse-integer (aref groups 0) :junk-allowed t) + 0))) + ;; Get title from asteroid.mp3 mount point + (mount-start (cl-ppcre:scan "" xml-string)) + (title (if mount-start + (let* ((source-section (subseq xml-string mount-start + (or (cl-ppcre:scan "" xml-string :start mount-start) + (length xml-string))))) + (multiple-value-bind (match groups) + (cl-ppcre:scan-to-strings "(.*?)" source-section) + (if (and match groups) + (aref groups 0) + "Unknown"))) + "Unknown"))) + `((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*)) + (:title . ,title) + (:listeners . ,total-listeners))))))) (define-api asteroid/partial/now-playing () () "Get Partial HTML with live status from Icecast server" - (handler-case + (with-error-handling (let ((now-playing-stats (icecast-now-playing *stream-base-url*))) (if now-playing-stats (progn @@ -46,15 +54,11 @@ (clip:process-to-string (load-template "partial/now-playing") :connection-error t - :stats nil)))) - (error (e) - (api-output `(("status" . "error") - ("message" . ,(format nil "Error loading profile: ~a" e))) - :status 500)))) + :stats nil)))))) (define-api asteroid/partial/now-playing-inline () () "Get inline text with now playing info (for admin dashboard and widgets)" - (handler-case + (with-error-handling (let ((now-playing-stats (icecast-now-playing *stream-base-url*))) (if now-playing-stats (progn @@ -62,7 +66,4 @@ (cdr (assoc :title now-playing-stats))) (progn (setf (header "Content-Type") "text/plain") - "Stream Offline"))) - (error (e) - (setf (header "Content-Type") "text/plain") - "Error loading stream info"))) + "Stream Offline"))))) diff --git a/static/js/profile.js b/static/js/profile.js index 4f87bc1..5fc6a9e 100644 --- a/static/js/profile.js +++ b/static/js/profile.js @@ -296,3 +296,54 @@ function showMessage(message, type = 'info') { function showError(message) { showMessage(message, 'error'); } + +// Password change handler +function changePassword(event) { + event.preventDefault(); + + const currentPassword = document.getElementById('current-password').value; + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('confirm-password').value; + const messageDiv = document.getElementById('password-message'); + + // Client-side validation + if (newPassword.length < 8) { + messageDiv.textContent = 'New password must be at least 8 characters'; + messageDiv.className = 'message error'; + return false; + } + + if (newPassword !== confirmPassword) { + messageDiv.textContent = 'New passwords do not match'; + messageDiv.className = 'message error'; + return false; + } + + // Send request to API + const formData = new FormData(); + formData.append('current-password', currentPassword); + formData.append('new-password', newPassword); + + fetch('/api/asteroid/user/change-password', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.status === 'success' || (data.data && data.data.status === 'success')) { + messageDiv.textContent = 'Password changed successfully!'; + messageDiv.className = 'message success'; + document.getElementById('change-password-form').reset(); + } else { + messageDiv.textContent = data.message || data.data?.message || 'Failed to change password'; + messageDiv.className = 'message error'; + } + }) + .catch(error => { + console.error('Error changing password:', error); + messageDiv.textContent = 'Error changing password'; + messageDiv.className = 'message error'; + }); + + return false; +} diff --git a/stream-control.lisp b/stream-control.lisp index 2eef56d..bca504f 100644 --- a/stream-control.lisp +++ b/stream-control.lisp @@ -77,8 +77,7 @@ (if (and (stringp host-path) (>= (length host-path) (length library-prefix)) (string= host-path library-prefix :end1 (length library-prefix))) - (concatenate 'string "/app/music/" - (subseq host-path (length library-prefix))) + (format nil "/app/music/~a" (subseq host-path (length library-prefix))) host-path))) (defun generate-m3u-playlist (track-ids output-path) @@ -183,9 +182,9 @@ (if (and (stringp docker-path) (>= (length docker-path) 11) (string= docker-path "/app/music/" :end1 11)) - (concatenate 'string - (namestring *music-library-path*) - (subseq docker-path 11)) + (format nil "~a~a" + (namestring *music-library-path*) + (subseq docker-path 11)) docker-path)) (defun load-queue-from-m3u-file () diff --git a/template/admin.ctml b/template/admin.ctml index 06012f2..4ae4e2f 100644 --- a/template/admin.ctml +++ b/template/admin.ctml @@ -166,11 +166,82 @@ + + +
+

🔒 Reset User Password

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
diff --git a/template/profile.ctml b/template/profile.ctml index 7f8b327..158a26b 100644 --- a/template/profile.ctml +++ b/template/profile.ctml @@ -153,6 +153,28 @@

⚙️ Profile Settings

+ + +
+

🔒 Change Password

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
diff --git a/user-management.lisp b/user-management.lisp index 59ed4b6..d6c4985 100644 --- a/user-management.lisp +++ b/user-management.lisp @@ -104,14 +104,46 @@ (defun reset-user-password (username new-password) "Reset a user's password" (let ((user (find-user-by-username username))) - (when user - (let ((new-hash (hash-password new-password)) - (user-id (gethash "_id" user))) - (db:update "USERS" - (db:query (:= "_id" user-id)) - `(("password-hash" ,new-hash))) - (format t "Password reset for user: ~a~%" username) - t)))) + (if user + (handler-case + (let ((new-hash (hash-password new-password)) + (user-id (gethash "_id" user))) + (format t "Resetting password for user: ~a (ID: ~a, type: ~a)~%" username user-id (type-of user-id)) + (format t "New hash: ~a~%" new-hash) + (format t "User hash table keys: ") + (maphash (lambda (k v) (format t "~a " k)) user) + (format t "~%") + (format t "Query: ~a~%" (db:query (:= "_id" user-id))) + (format t "Update data: ~a~%" `(("password-hash" ,new-hash))) + ;; Try direct update with uppercase field name to match stored case + (format t "Attempting direct update with uppercase field name...~%") + (db:update "USERS" + (db:query (:= "_id" user-id)) + `(("PASSWORD-HASH" ,new-hash))) + (format t "Update complete, verifying...~%") + ;; Verify the update worked + (let ((updated-user (find-user-by-username username))) + (format t "Verification - fetching user again...~%") + (let ((updated-hash (gethash "PASSWORD-HASH" updated-user))) + (format t "Updated password hash in DB: ~a~%" updated-hash) + (format t "Expected hash: ~a~%" new-hash) + (let ((match (if (listp updated-hash) + (string= (first updated-hash) new-hash) + (string= updated-hash new-hash)))) + (format t "Match: ~a~%" match) + (if match + (progn + (format t "Password reset successful for user: ~a~%" username) + t) + (progn + (format t "Password reset FAILED - hash didn't update~%") + nil)))))) + (error (e) + (format t "Error resetting password for ~a: ~a~%" username e) + nil)) + (progn + (format t "User not found: ~a~%" username) + nil)))) (defun user-has-role-p (user role) "Check if user has the specified role"