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.
This commit is contained in:
glenneth 2025-11-04 16:23:51 +03:00 committed by Brian O'Reilly
parent c58c8a255c
commit 4c99ded7f0
8 changed files with 270 additions and 44 deletions

View File

@ -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)))))))

View File

@ -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:

View File

@ -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 <source mount="/asteroid.mp3"> sections and extract title, listeners, etc.
(multiple-value-bind (match-start match-end)
(cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)
(if match-start
(let* ((source-section (subseq xml-string match-start
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
(length xml-string))))
(titlep (cl-ppcre:all-matches "<title>" source-section))
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" 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 <listeners> 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 "<listeners>(\\d+)</listeners>" 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 "<source mount=\"/asteroid\\.mp3\">" xml-string))
(title (if mount-start
(let* ((source-section (subseq xml-string mount-start
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
(length xml-string)))))
(multiple-value-bind (match groups)
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" 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")))))

View File

@ -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;
}

View File

@ -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 ()

View File

@ -166,11 +166,82 @@
<div class="controls">
<a href="/asteroid/admin/users" class="btn btn-primary">👥 Manage Users</a>
</div>
<!-- Admin Password Reset Form -->
<div class="form-section" style="margin-top: 20px;">
<h4>🔒 Reset User Password</h4>
<form id="admin-reset-password-form" onsubmit="return resetUserPassword(event)">
<div class="form-group">
<label for="reset-username">Username:</label>
<input type="text" id="reset-username" name="username" required>
</div>
<div class="form-group">
<label for="reset-new-password">New Password:</label>
<input type="password" id="reset-new-password" name="new-password" required minlength="8">
</div>
<div class="form-group">
<label for="reset-confirm-password">Confirm Password:</label>
<input type="password" id="reset-confirm-password" name="confirm-password" required minlength="8">
</div>
<div id="reset-password-message" class="message"></div>
<button type="submit" class="btn btn-primary">Reset Password</button>
</form>
</div>
</div>
</div>
</div>
<script>
// Admin password reset handler
function resetUserPassword(event) {
event.preventDefault();
const username = document.getElementById('reset-username').value;
const newPassword = document.getElementById('reset-new-password').value;
const confirmPassword = document.getElementById('reset-confirm-password').value;
const messageDiv = document.getElementById('reset-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 = 'Passwords do not match';
messageDiv.className = 'message error';
return false;
}
// Send request to API
const formData = new FormData();
formData.append('username', username);
formData.append('new-password', newPassword);
fetch('/api/asteroid/admin/reset-password', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success' || (data.data && data.data.status === 'success')) {
messageDiv.textContent = 'Password reset successfully for user: ' + username;
messageDiv.className = 'message success';
document.getElementById('admin-reset-password-form').reset();
} else {
messageDiv.textContent = data.message || data.data?.message || 'Failed to reset password';
messageDiv.className = 'message error';
}
})
.catch(error => {
console.error('Error resetting password:', error);
messageDiv.textContent = 'Error resetting password';
messageDiv.className = 'message error';
});
return false;
}
</script>
</body>
</html>

View File

@ -153,6 +153,28 @@
<!-- Profile Actions -->
<div class="admin-section">
<h2>⚙️ Profile Settings</h2>
<!-- Change Password Form -->
<div class="form-section">
<h3>🔒 Change Password</h3>
<form id="change-password-form" onsubmit="return changePassword(event)">
<div class="form-group">
<label for="current-password">Current Password:</label>
<input type="password" id="current-password" name="current-password" required minlength="8">
</div>
<div class="form-group">
<label for="new-password">New Password:</label>
<input type="password" id="new-password" name="new-password" required minlength="8">
</div>
<div class="form-group">
<label for="confirm-password">Confirm New Password:</label>
<input type="password" id="confirm-password" name="confirm-password" required minlength="8">
</div>
<div id="password-message" class="message"></div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
<div class="profile-actions">
<button class="btn btn-primary" onclick="editProfile()">✏️ Edit Profile</button>
<button class="btn btn-secondary" onclick="exportListeningData()">📊 Export Data</button>

View File

@ -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"