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
4c99ded7f0
|
|
@ -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)))))))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")))))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue