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:
parent
c58c8a255c
commit
2bd16f1e4e
|
|
@ -105,3 +105,53 @@
|
||||||
(api-output `(("status" . "error")
|
(api-output `(("status" . "error")
|
||||||
("message" . ,(format nil "Error creating user: ~a" e)))
|
("message" . ,(format nil "Error creating user: ~a" e)))
|
||||||
:status 500))))
|
: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)))))))
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ services:
|
||||||
image: infiniteproject/icecast:latest
|
image: infiniteproject/icecast:latest
|
||||||
container_name: asteroid-icecast
|
container_name: asteroid-icecast
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8000:8000"
|
- "${ICECAST_BIND:-127.0.0.1}:8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./icecast.xml:/etc/icecast.xml
|
- ./icecast.xml:/etc/icecast.xml
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
(in-package :asteroid)
|
(in-package :asteroid)
|
||||||
|
|
||||||
(defun icecast-now-playing (icecast-base-url)
|
(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))
|
(let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url))
|
||||||
(response (drakma:http-request icecast-url
|
(response (drakma:http-request icecast-url
|
||||||
:want-stream nil
|
:want-stream nil
|
||||||
|
|
@ -9,29 +14,32 @@
|
||||||
(let ((xml-string (if (stringp response)
|
(let ((xml-string (if (stringp response)
|
||||||
response
|
response
|
||||||
(babel:octets-to-string response :encoding :utf-8))))
|
(babel:octets-to-string response :encoding :utf-8))))
|
||||||
;; Simple XML parsing to extract source information
|
;; Extract total listener count from root <listeners> tag (sums all mount points)
|
||||||
;; Look for <source mount="/asteroid.mp3"> sections and extract title, listeners, etc.
|
;; Extract title from asteroid.mp3 mount point
|
||||||
(multiple-value-bind (match-start match-end)
|
(let* ((total-listeners (multiple-value-bind (match groups)
|
||||||
(cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string)
|
(cl-ppcre:scan-to-strings "<listeners>(\\d+)</listeners>" xml-string)
|
||||||
|
(if (and match groups)
|
||||||
(if match-start
|
(parse-integer (aref groups 0) :junk-allowed t)
|
||||||
(let* ((source-section (subseq xml-string match-start
|
0)))
|
||||||
(or (cl-ppcre:scan "</source>" xml-string :start match-start)
|
;; Get title from asteroid.mp3 mount point
|
||||||
(length xml-string))))
|
(mount-start (cl-ppcre:scan "<source mount=\"/asteroid\\.mp3\">" xml-string))
|
||||||
(titlep (cl-ppcre:all-matches "<title>" source-section))
|
(title (if mount-start
|
||||||
(listenersp (cl-ppcre:all-matches "<listeners>" source-section))
|
(let* ((source-section (subseq xml-string mount-start
|
||||||
(title (if titlep (cl-ppcre:regex-replace-all ".*<title>(.*?)</title>.*" source-section "\\1") "Unknown"))
|
(or (cl-ppcre:scan "</source>" xml-string :start mount-start)
|
||||||
(listeners (if listenersp (cl-ppcre:regex-replace-all ".*<listeners>(.*?)</listeners>.*" source-section "\\1") "0")))
|
(length xml-string)))))
|
||||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
(multiple-value-bind (match groups)
|
||||||
(:title . ,title)
|
(cl-ppcre:scan-to-strings "<title>(.*?)</title>" source-section)
|
||||||
(:listeners . ,(parse-integer listeners :junk-allowed t))))
|
(if (and match groups)
|
||||||
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
(aref groups 0)
|
||||||
(:title . "Unknown")
|
"Unknown")))
|
||||||
(:listeners . "Unknown"))))))))
|
"Unknown")))
|
||||||
|
`((:listenurl . ,(format nil "~a/asteroid.mp3" *stream-base-url*))
|
||||||
|
(:title . ,title)
|
||||||
|
(:listeners . ,total-listeners)))))))
|
||||||
|
|
||||||
(define-api asteroid/partial/now-playing () ()
|
(define-api asteroid/partial/now-playing () ()
|
||||||
"Get Partial HTML with live status from Icecast server"
|
"Get Partial HTML with live status from Icecast server"
|
||||||
(handler-case
|
(with-error-handling
|
||||||
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
||||||
(if now-playing-stats
|
(if now-playing-stats
|
||||||
(progn
|
(progn
|
||||||
|
|
@ -46,15 +54,11 @@
|
||||||
(clip:process-to-string
|
(clip:process-to-string
|
||||||
(load-template "partial/now-playing")
|
(load-template "partial/now-playing")
|
||||||
:connection-error t
|
:connection-error t
|
||||||
:stats nil))))
|
:stats nil))))))
|
||||||
(error (e)
|
|
||||||
(api-output `(("status" . "error")
|
|
||||||
("message" . ,(format nil "Error loading profile: ~a" e)))
|
|
||||||
:status 500))))
|
|
||||||
|
|
||||||
(define-api asteroid/partial/now-playing-inline () ()
|
(define-api asteroid/partial/now-playing-inline () ()
|
||||||
"Get inline text with now playing info (for admin dashboard and widgets)"
|
"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*)))
|
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
|
||||||
(if now-playing-stats
|
(if now-playing-stats
|
||||||
(progn
|
(progn
|
||||||
|
|
@ -62,7 +66,4 @@
|
||||||
(cdr (assoc :title now-playing-stats)))
|
(cdr (assoc :title now-playing-stats)))
|
||||||
(progn
|
(progn
|
||||||
(setf (header "Content-Type") "text/plain")
|
(setf (header "Content-Type") "text/plain")
|
||||||
"Stream Offline")))
|
"Stream Offline")))))
|
||||||
(error (e)
|
|
||||||
(setf (header "Content-Type") "text/plain")
|
|
||||||
"Error loading stream info")))
|
|
||||||
|
|
|
||||||
|
|
@ -296,3 +296,54 @@ function showMessage(message, type = 'info') {
|
||||||
function showError(message) {
|
function showError(message) {
|
||||||
showMessage(message, 'error');
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,7 @@
|
||||||
(if (and (stringp host-path)
|
(if (and (stringp host-path)
|
||||||
(>= (length host-path) (length library-prefix))
|
(>= (length host-path) (length library-prefix))
|
||||||
(string= host-path library-prefix :end1 (length library-prefix)))
|
(string= host-path library-prefix :end1 (length library-prefix)))
|
||||||
(concatenate 'string "/app/music/"
|
(format nil "/app/music/~a" (subseq host-path (length library-prefix)))
|
||||||
(subseq host-path (length library-prefix)))
|
|
||||||
host-path)))
|
host-path)))
|
||||||
|
|
||||||
(defun generate-m3u-playlist (track-ids output-path)
|
(defun generate-m3u-playlist (track-ids output-path)
|
||||||
|
|
@ -183,9 +182,9 @@
|
||||||
(if (and (stringp docker-path)
|
(if (and (stringp docker-path)
|
||||||
(>= (length docker-path) 11)
|
(>= (length docker-path) 11)
|
||||||
(string= docker-path "/app/music/" :end1 11))
|
(string= docker-path "/app/music/" :end1 11))
|
||||||
(concatenate 'string
|
(format nil "~a~a"
|
||||||
(namestring *music-library-path*)
|
(namestring *music-library-path*)
|
||||||
(subseq docker-path 11))
|
(subseq docker-path 11))
|
||||||
docker-path))
|
docker-path))
|
||||||
|
|
||||||
(defun load-queue-from-m3u-file ()
|
(defun load-queue-from-m3u-file ()
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,82 @@
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<a href="/asteroid/admin/users" class="btn btn-primary">👥 Manage Users</a>
|
<a href="/asteroid/admin/users" class="btn btn-primary">👥 Manage Users</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,28 @@
|
||||||
<!-- Profile Actions -->
|
<!-- Profile Actions -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>⚙️ Profile Settings</h2>
|
<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">
|
<div class="profile-actions">
|
||||||
<button class="btn btn-primary" onclick="editProfile()">✏️ Edit Profile</button>
|
<button class="btn btn-primary" onclick="editProfile()">✏️ Edit Profile</button>
|
||||||
<button class="btn btn-secondary" onclick="exportListeningData()">📊 Export Data</button>
|
<button class="btn btn-secondary" onclick="exportListeningData()">📊 Export Data</button>
|
||||||
|
|
|
||||||
|
|
@ -104,14 +104,46 @@
|
||||||
(defun reset-user-password (username new-password)
|
(defun reset-user-password (username new-password)
|
||||||
"Reset a user's password"
|
"Reset a user's password"
|
||||||
(let ((user (find-user-by-username username)))
|
(let ((user (find-user-by-username username)))
|
||||||
(when user
|
(if user
|
||||||
(let ((new-hash (hash-password new-password))
|
(handler-case
|
||||||
(user-id (gethash "_id" user)))
|
(let ((new-hash (hash-password new-password))
|
||||||
(db:update "USERS"
|
(user-id (gethash "_id" user)))
|
||||||
(db:query (:= "_id" user-id))
|
(format t "Resetting password for user: ~a (ID: ~a, type: ~a)~%" username user-id (type-of user-id))
|
||||||
`(("password-hash" ,new-hash)))
|
(format t "New hash: ~a~%" new-hash)
|
||||||
(format t "Password reset for user: ~a~%" username)
|
(format t "User hash table keys: ")
|
||||||
t))))
|
(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)
|
(defun user-has-role-p (user role)
|
||||||
"Check if user has the specified role"
|
"Check if user has the specified role"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue