Compare commits
13 Commits
043c0d8610
...
ffd178c555
| Author | SHA1 | Date |
|---|---|---|
|
|
ffd178c555 | |
|
|
270c0ad679 | |
|
|
1665efe1e1 | |
|
|
40a49c1c25 | |
|
|
30b2d88f6a | |
|
|
3dd9c2d469 | |
|
|
b9b3feda6b | |
|
|
69b0b2ca9e | |
|
|
b80dea5a08 | |
|
|
5882141cfa | |
|
|
85881b8fb6 | |
|
|
4c99ded7f0 | |
|
|
c58c8a255c |
2
Makefile
2
Makefile
|
|
@ -6,7 +6,7 @@ ENTRY=-main
|
|||
|
||||
.PHONY: $(OUT)
|
||||
$(OUT): clean
|
||||
sbcl --load build-executable.lisp
|
||||
sbcl --load build-asteroid.lisp
|
||||
|
||||
quicklisp-manifest.txt: *.asd
|
||||
sbcl --non-interactive \
|
||||
|
|
|
|||
32
TODO.org
32
TODO.org
|
|
@ -9,7 +9,7 @@
|
|||
- [X] Set up DNS
|
||||
- [X] Create user accounts
|
||||
|
||||
* Deploy the system
|
||||
* Deploy the system [10/10]
|
||||
- [X] Install and configure HAproxy
|
||||
- [X] Create a user to contain asteroid
|
||||
- [X] Checkout asteroid in ~asteroid on b612
|
||||
|
|
@ -21,21 +21,27 @@
|
|||
- [X] Start asteroid, check the stream (Underworld:Juanita/Kiteless)
|
||||
- [X] Announce that the system is live in #asteroid.radio
|
||||
|
||||
** PROBLEMS
|
||||
1) Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
||||
2) icecast is also binding the external interface on b612, which it
|
||||
** PROBLEMS [3/12]
|
||||
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
||||
2) [X] icecast is also binding the external interface on b612, which it
|
||||
should not be. HAproxy is there to mediate this flow.
|
||||
3) We're still on the built in i-lambdalite database
|
||||
4) The templates still advertise the default administrator password,
|
||||
3) [ ] We're still on the built in i-lambdalite database
|
||||
4) [X] The templates still advertise the default administrator password,
|
||||
which is no bueno.
|
||||
5) We need to work out the TLS situation with letsencrypt, and
|
||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||
integrate it into HAproxy.
|
||||
6) the administrative interface should be beefed up.
|
||||
5.1) Deactivate users
|
||||
5.2) Change user access permissions
|
||||
7) User profile pages should probably be fleshed out.
|
||||
8) the stream management features aren't there for Admins or DJs.
|
||||
9) The "Scan Library" feature is not working in the main branch
|
||||
|
||||
6) [ ] The administrative interface should be beefed up.
|
||||
6.1) [ ] Deactivate users
|
||||
6.2) [ ] Change user access permissions
|
||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
||||
|
||||
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||
8) [ ] User profile pages should probably be fleshed out.
|
||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||
11) [ ] The player widget should be styled so it fits the site theme on systems running 'light' thmes.
|
||||
12) [ ] ensure each info field 'Listeners: ..' &c has only one instance per page.
|
||||
|
||||
* Server runtime configuration [0/1]
|
||||
- [ ] parameterize all configuration for runtime loading [0/2]
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
:pathname "./"
|
||||
:components ((:file "app-utils")
|
||||
(:file "module")
|
||||
(:module :config
|
||||
:components ((:file radiance-postgres)))
|
||||
(:file "conditions")
|
||||
(:file "database")
|
||||
(:file "template-utils")
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
(in-package :asteroid)
|
||||
|
||||
;; Define as RADIANCE module
|
||||
(define-module asteroid
|
||||
(:use #:cl #:radiance #:lass #:r-clip)
|
||||
(:domain "asteroid"))
|
||||
;; (define-module asteroid
|
||||
;; (:use #:cl #:radiance #:lass #:r-clip)
|
||||
;; (:domain "asteroid"))
|
||||
|
||||
;; Configuration -- this will be refactored to a dedicated
|
||||
;; configuration logic. Probably using 'ubiquity
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
;; -*-lisp-*-
|
||||
|
||||
(unless *load-pathname*
|
||||
(error "Please LOAD this file."))
|
||||
|
||||
(when (find-package :quicklisp)
|
||||
(error "Please run this file as a script or from the Makefile."))
|
||||
|
||||
(defpackage #:asteroid-bootstrap
|
||||
(:use #:cl)
|
||||
(:export #:*root* #:path))
|
||||
|
||||
;; we require quicklisp to load our transitive dependencies.
|
||||
(load "~/quicklisp/setup.lisp")
|
||||
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
(setf (config :database :connection)
|
||||
'(:type :postgres
|
||||
:host "localhost" ; Change to "asteroid-postgres" when running in Docker
|
||||
;; :host "asteroid-postgres"
|
||||
:port 5432
|
||||
:database "asteroid"
|
||||
:username "asteroid"
|
||||
|
|
|
|||
|
|
@ -31,5 +31,19 @@
|
|||
(created-date :integer)
|
||||
(last-login :integer))))
|
||||
|
||||
(format t "Database collections initialized~%"))
|
||||
(unless (db:collection-exists-p "playlist_tracks")
|
||||
(db:create "playlist_tracks" '((track_id :integer)
|
||||
(position :ingeger)
|
||||
(added_date :timestamp))))
|
||||
|
||||
;; TODO: the radiance db interface is too basic to contain anything
|
||||
;; but strings, integers, booleans, and maybe timestamps... we will
|
||||
;; need to rethink this. currently track/playlist relationships are
|
||||
;; defined in the SQL file 'init-db.sql' referenced in the docker
|
||||
;; config for postgresql, but our lisp code doesn't leverage it.
|
||||
|
||||
;; (unless (db:collection-exists-p "sessions")
|
||||
;; (db:create "sessions" '(())))
|
||||
|
||||
(l:info "~2&Database collections initialized~%"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,6 @@
|
|||
("r-simple-auth" :|1.0.0|) ("r-simple-sessions" :|1.0.1|)
|
||||
("r-ratify" :|1.0.0|) ("r-simple-rate" :|1.0.0|)
|
||||
("r-simple-profile" :|1.0.0|)])
|
||||
(:domains "radiance" "localhost")
|
||||
(:domains "asteroid" "radiance" "asteroid.radio" "localhost")
|
||||
(:startup :r-simple-errors :r-simple-sessions) (:routes)
|
||||
(:debugger . :if-swank-connected))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
(in-package :asteroid)
|
||||
^(in-package :asteroid)
|
||||
|
||||
(defun icecast-now-playing (icecast-base-url)
|
||||
(let* ((icecast-url (format nil "~a/admin/stats.xml" icecast-base-url))
|
||||
|
|
@ -46,15 +46,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 +58,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")))))
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
(in-package #:rad-user)
|
||||
|
||||
(define-module #:asteroid
|
||||
(:use #:cl #:radiance #:asteroid.app-utils)
|
||||
(:use #:cl #:radiance #:lass #:r-clip #:asteroid.app-utils)
|
||||
(:domain "asteroid")
|
||||
(:export #:-main))
|
||||
|
|
|
|||
|
|
@ -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,7 +182,7 @@
|
|||
(if (and (stringp docker-path)
|
||||
(>= (length docker-path) 11)
|
||||
(string= docker-path "/app/music/" :end1 11))
|
||||
(concatenate 'string
|
||||
(format nil "~a~a"
|
||||
(namestring *music-library-path*)
|
||||
(subseq docker-path 11))
|
||||
docker-path))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -37,11 +37,6 @@
|
|||
<button type="submit" class="btn btn-primary" style="width: 100%;">LOGIN</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="panel" style="margin-top: 20px; text-align: center;">
|
||||
<strong style="color: #ff6600;">Default Admin Credentials:</strong><br>
|
||||
Username: <br><code style="color: #00ff00;">admin</code><br>
|
||||
Password: <br><code style="color: #00ff00;">asteroid123</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(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 "Password reset for user: ~a~%" username)
|
||||
t))))
|
||||
`(("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