Compare commits

..

No commits in common. "ffd178c555eaec006216df70cd9cb3db77ef8c6e" and "043c0d8610fc794719951a73ebc7b98bb6191463" have entirely different histories.

18 changed files with 63 additions and 310 deletions

View File

@ -6,7 +6,7 @@ ENTRY=-main
.PHONY: $(OUT)
$(OUT): clean
sbcl --load build-asteroid.lisp
sbcl --load build-executable.lisp
quicklisp-manifest.txt: *.asd
sbcl --non-interactive \

View File

@ -9,7 +9,7 @@
- [X] Set up DNS
- [X] Create user accounts
* Deploy the system [10/10]
* Deploy the system
- [X] Install and configure HAproxy
- [X] Create a user to contain asteroid
- [X] Checkout asteroid in ~asteroid on b612
@ -21,27 +21,21 @@
- [X] Start asteroid, check the stream (Underworld:Juanita/Kiteless)
- [X] Announce that the system is live in #asteroid.radio
** 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
** 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
should not be. HAproxy is there to mediate this flow.
3) [ ] We're still on the built in i-lambdalite database
4) [X] The templates still advertise the default administrator password,
3) We're still on the built in i-lambdalite database
4) 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.
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.
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
* Server runtime configuration [0/1]
- [ ] parameterize all configuration for runtime loading [0/2]

View File

@ -33,8 +33,6 @@
:pathname "./"
:components ((:file "app-utils")
(:file "module")
(:module :config
:components ((:file radiance-postgres)))
(:file "conditions")
(:file "database")
(:file "template-utils")

View File

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

View File

@ -105,53 +105,3 @@
(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

@ -1,15 +1,5 @@
;; -*-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")

View File

@ -7,7 +7,6 @@
(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"

View File

@ -31,19 +31,5 @@
(created-date :integer)
(last-login :integer))))
(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~%"))
(format t "Database collections initialized~%"))

View File

@ -3,7 +3,7 @@ services:
image: infiniteproject/icecast:latest
container_name: asteroid-icecast
ports:
- "${ICECAST_BIND:-127.0.0.1}:8000:8000"
- "127.0.0.1:8000:8000"
volumes:
- ./icecast.xml:/etc/icecast.xml
environment:

View File

@ -1,20 +1,20 @@
; meta (:version 1.0 :package "RADIANCE-CORE")
((:interfaces (:admin . "r-simple-admin") (:auth . "r-simple-auth")
(:ban . "r-simple-ban") (:cache . "r-simple-cache")
(:data-model . "r-simple-model") (:database . "i-lambdalite")
(:relational-database . "i-sqlite") (:logger . "i-verbose")
(:mail . "i-smtp") (:profile . "r-simple-profile") (:rate . "r-simple-rate")
(:server . "i-hunchentoot") (:session . "r-simple-sessions")
(:user . "r-simple-users"))
(:ban . "r-simple-ban") (:cache . "r-simple-cache")
(:data-model . "r-simple-model") (:database . "i-lambdalite")
(:relational-database . "i-sqlite") (:logger . "i-verbose")
(:mail . "i-smtp") (:profile . "r-simple-profile") (:rate . "r-simple-rate")
(:server . "i-hunchentoot") (:session . "r-simple-sessions")
(:user . "r-simple-users"))
(:versions
. [hash-table equal ("radiance-core" :|2.2.0|) ("i-hunchentoot" :|1.1.0|)
("asteroid" :|0.0.0|) ("i-log4cl" :|1.0.0|) ("r-clip" :|1.0.0|)
("r-data-model" :|1.0.1|) ("i-lambdalite" :|1.0.0|)
("r-simple-users" :|1.0.1|)
("r-simple-errors" :|1.0.0|) ("i-verbose" :|1.0.0|)
("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 "asteroid" "radiance" "asteroid.radio" "localhost")
("asteroid" :|0.0.0|) ("i-log4cl" :|1.0.0|) ("r-clip" :|1.0.0|)
("r-data-model" :|1.0.1|) ("i-lambdalite" :|1.0.0|)
("r-simple-users" :|1.0.1|)
("r-simple-errors" :|1.0.0|) ("i-verbose" :|1.0.0|)
("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")
(:startup :r-simple-errors :r-simple-sessions) (:routes)
(:debugger . :if-swank-connected))

View File

@ -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,11 +46,15 @@
(clip:process-to-string
(load-template "partial/now-playing")
: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 () ()
"Get inline text with now playing info (for admin dashboard and widgets)"
(with-error-handling
(handler-case
(let ((now-playing-stats (icecast-now-playing *stream-base-url*)))
(if now-playing-stats
(progn
@ -58,4 +62,7 @@
(cdr (assoc :title now-playing-stats)))
(progn
(setf (header "Content-Type") "text/plain")
"Stream Offline")))))
"Stream Offline")))
(error (e)
(setf (header "Content-Type") "text/plain")
"Error loading stream info")))

View File

@ -1,6 +1,5 @@
(in-package #:rad-user)
(define-module #:asteroid
(:use #:cl #:radiance #:lass #:r-clip #:asteroid.app-utils)
(:domain "asteroid")
(:use #:cl #:radiance #:asteroid.app-utils)
(:export #:-main))

View File

@ -296,54 +296,3 @@ 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,7 +77,8 @@
(if (and (stringp host-path)
(>= (length host-path) (length library-prefix))
(string= host-path library-prefix :end1 (length library-prefix)))
(format nil "/app/music/~a" (subseq host-path (length library-prefix)))
(concatenate 'string "/app/music/"
(subseq host-path (length library-prefix)))
host-path)))
(defun generate-m3u-playlist (track-ids output-path)
@ -182,9 +183,9 @@
(if (and (stringp docker-path)
(>= (length docker-path) 11)
(string= docker-path "/app/music/" :end1 11))
(format nil "~a~a"
(namestring *music-library-path*)
(subseq docker-path 11))
(concatenate 'string
(namestring *music-library-path*)
(subseq docker-path 11))
docker-path))
(defun load-queue-from-m3u-file ()

View File

@ -166,82 +166,11 @@
<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

@ -37,6 +37,11 @@
<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>

View File

@ -153,28 +153,6 @@
<!-- 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,46 +104,14 @@
(defun reset-user-password (username new-password)
"Reset a user's password"
(let ((user (find-user-by-username username)))
(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))))
(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))))
(defun user-has-role-p (user role)
"Check if user has the specified role"