diff --git a/asteroid.asd b/asteroid.asd index 18c3b9a..204db87 100644 --- a/asteroid.asd +++ b/asteroid.asd @@ -13,21 +13,22 @@ :radiance :i-log4cl :r-clip - :cl-json - :dexador :lass - :r-data-model - :cl-fad + :cl-json + :alexandria :local-time :taglib - (:interface :database) :r-data-model + :ironclad + :babel + :cl-fad + (:interface :database) (:interface :user)) - :pathname "./" :components ((:file "app-utils") (:file "module") (:file "database") (:file "stream-media") - (:file "users") + (:file "user-management") + (:file "auth-routes") (:file "asteroid"))) diff --git a/asteroid.lisp b/asteroid.lisp index d3f6624..481ebbf 100644 --- a/asteroid.lisp +++ b/asteroid.lisp @@ -21,9 +21,30 @@ (defparameter *supported-formats* '("mp3" "flac" "ogg" "wav")) +;; Authentication functions +(defun require-authentication () + "Require user to be authenticated" + (handler-case + (unless (session:field "user-id") + (radiance:redirect "/asteroid/login")) + (error (e) + (format t "Authentication error: ~a~%" e) + (radiance:redirect "/asteroid/login")))) + +(defun require-role (role) + "Require user to have a specific role" + (handler-case + (let ((current-user (get-current-user))) + (unless (and current-user (user-has-role-p current-user role)) + (radiance:redirect "/asteroid/login"))) + (error (e) + (format t "Role check error: ~a~%" e) + (radiance:redirect "/asteroid/login")))) + ;; API Routes (define-page admin-scan-library #@"/admin/scan-library" () "API endpoint to scan music library" + (require-role :admin) (handler-case (let ((tracks-added (scan-music-library))) (setf (radiance:header "Content-Type") "application/json") @@ -39,6 +60,7 @@ (define-page admin-tracks #@"/admin/tracks" () "API endpoint to view all tracks in database" + (require-authentication) (handler-case (let ((tracks (db:select "tracks" (db:query :all)))) (setf (radiance:header "Content-Type") "application/json") @@ -46,15 +68,13 @@ `(("status" . "success") ("tracks" . ,(mapcar (lambda (track) `(("id" . ,(gethash "_id" track)) - ("title" . ,(gethash "title" track)) - ("artist" . ,(gethash "artist" track)) - ("album" . ,(gethash "album" track)) - ("duration" . ,(gethash "duration" track)) - ("file-path" . ,(gethash "file-path" track)) - ("format" . ,(gethash "format" track)) - ("bitrate" . ,(gethash "bitrate" track)) - ("added-date" . ,(gethash "added-date" track)) - ("play-count" . ,(gethash "play-count" track)))) + ("title" . ,(first (gethash "title" track))) + ("artist" . ,(first (gethash "artist" track))) + ("album" . ,(first (gethash "album" track))) + ("duration" . ,(first (gethash "duration" track))) + ("format" . ,(first (gethash "format" track))) + ("bitrate" . ,(first (gethash "bitrate" track))) + ("play-count" . ,(first (gethash "play-count" track))))) tracks))))) (error (e) (setf (radiance:header "Content-Type") "application/json") @@ -220,13 +240,9 @@ `(("status" . "success") ("player" . ,(get-player-status))))) -;; Configure static file serving for other files -(define-page static #@"/static/(.*)" (:uri-groups (path)) - (serve-file (merge-pathnames (concatenate 'string "static/" path) - (asdf:system-source-directory :asteroid)))) - -;; RADIANCE route handlers -(define-page index #@"/" () +;; Front page +(define-page front-page #@"/" () + "Main front page" (let ((template-path (merge-pathnames "template/front-page.chtml" (asdf:system-source-directory :asteroid)))) (clip:process-to-string @@ -241,7 +257,15 @@ :now-playing-album "Startup Sounds" :now-playing-duration "∞"))) +;; Configure static file serving for other files +(define-page static #@"/static/(.*)" (:uri-groups (path)) + (serve-file (merge-pathnames (concatenate 'string "static/" path) + (asdf:system-source-directory :asteroid)))) + +;; Admin page (requires authentication) (define-page admin #@"/admin" () + "Admin dashboard" + (require-authentication) (let ((template-path (merge-pathnames "template/admin.chtml" (asdf:system-source-directory :asteroid))) (track-count (handler-case @@ -249,7 +273,7 @@ (error () 0)))) (clip:process-to-string (plump:parse (alexandria:read-file-into-string template-path)) - :title "Asteroid Radio - Admin Dashboard" + :title "🎡 ASTEROID RADIO - Admin Dashboard" :server-status "🟒 Running" :database-status (handler-case (if (db:connected-p) "🟒 Connected" "πŸ”΄ Disconnected") @@ -292,6 +316,11 @@ "Start the Asteroid Radio RADIANCE server" (format t "Starting Asteroid Radio RADIANCE server on port ~a~%" port) (compile-styles) ; Generate CSS file using LASS + + ;; Ensure RADIANCE environment is properly set before startup + (unless (radiance:environment) + (setf (radiance:environment) "default")) + (radiance:startup) (format t "Server started! Visit http://localhost:~a/asteroid/~%" port)) @@ -312,6 +341,17 @@ (format t "~%Received interrupt, stopping server...~%") (stop-server)))) +(defun ensure-radiance-environment () + "Ensure RADIANCE environment is properly configured for persistence" + (unless (radiance:environment) + (setf (radiance:environment) "default")) + + ;; Ensure the database directory exists + (let ((db-dir (merge-pathnames ".config/radiance/default/i-lambdalite/radiance.db/" + (user-homedir-pathname)))) + (ensure-directories-exist db-dir) + (format t "Database directory: ~a~%" db-dir))) + (defun -main (&optional args (debug t)) (declare (ignorable args)) (format t "~&args of asteroid: ~A~%" args) @@ -319,5 +359,12 @@ (format t "Starting RADIANCE web server...~%") (when debug (slynk:create-server :port 4009 :dont-close t)) + + ;; Ensure proper environment setup before starting + (ensure-radiance-environment) + + ;; Initialize user management before server starts + (initialize-user-system) + (run-server)) diff --git a/auth-routes.lisp b/auth-routes.lisp new file mode 100644 index 0000000..404f8d4 --- /dev/null +++ b/auth-routes.lisp @@ -0,0 +1,143 @@ +;;;; auth-routes.lisp - Authentication Routes for Asteroid Radio +;;;; Web routes for user authentication, registration, and management + +(in-package :asteroid) + +;; Login page (GET) +(define-page login #@"/login" () + "User login page" + (let ((username (radiance:post-var "username")) + (password (radiance:post-var "password"))) + (if (and username password) + ;; Handle login form submission + (let ((user (authenticate-user username password))) + (if user + (progn + ;; Login successful - store user ID in session + (format t "Login successful for user: ~a~%" (gethash "username" user)) + (handler-case + (progn + (let ((user-id (gethash "_id" user))) + (format t "User ID from DB: ~a~%" user-id) + (setf (session:field "user-id") (if (listp user-id) (first user-id) user-id))) + (radiance:redirect "/asteroid/admin")) + (error (e) + (format t "Session error: ~a~%" e) + "Login successful but session error occurred"))) + ;; Login failed - show form with error +" + + + Asteroid Radio - Login + + + +
+

🎡 ASTEROID RADIO - LOGIN

+
+
+

System Access

+
Invalid username or password
+
+
+ + +
+
+ + +
+
+ +
+
+
+ Default Admin Credentials:
+ Username: admin
+ Password: asteroid123 +
+
+
+
+ +")) + ;; Show login form (no POST data) +" + + + Asteroid Radio - Login + + + +
+

🎡 ASTEROID RADIO - LOGIN

+
+
+

System Access

+
+
+ + +
+
+ + +
+
+ +
+
+
+ Default Admin Credentials:
+ Username: admin
+ Password: asteroid123 +
+
+
+
+ +"))) + +;; Simple logout handler +(define-page logout #@"/logout" () + "Handle user logout" + (setf (session:field "user-id") nil) + (radiance:redirect "/asteroid/")) + +;; API: Get all users (admin only) +(define-page api-users #@"/api/users" () + "API endpoint to get all users" + (require-role :admin) + (setf (radiance:header "Content-Type") "application/json") + (handler-case + (let ((users (get-all-users))) + (cl-json:encode-json-to-string + `(("status" . "success") + ("users" . ,(mapcar (lambda (user) + `(("id" . ,(gethash "_id" user)) + ("username" . ,(gethash "username" user)) + ("email" . ,(gethash "email" user)) + ("role" . ,(gethash "role" user)) + ("active" . ,(gethash "active" user)) + ("created-date" . ,(gethash "created-date" user)) + ("last-login" . ,(gethash "last-login" user)))) + users))))) + (error (e) + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Error retrieving users: ~a" e))))))) + +;; API: Get user statistics (admin only) +(define-page api-user-stats #@"/api/user-stats" () + "API endpoint to get user statistics" + (require-role :admin) + (setf (radiance:header "Content-Type") "application/json") + (handler-case + (let ((stats (get-user-stats))) + (cl-json:encode-json-to-string + `(("status" . "success") + ("stats" . ,stats)))) + (error (e) + (cl-json:encode-json-to-string + `(("status" . "error") + ("message" . ,(format nil "Error retrieving user stats: ~a" e))))))) diff --git a/build-executable.lisp b/build-executable.lisp index 38a85c0..a4dd448 100755 --- a/build-executable.lisp +++ b/build-executable.lisp @@ -9,6 +9,10 @@ ;; Load RADIANCE first, then handle environment (ql:quickload :radiance) +;; Ensure RADIANCE environment is set before loading +(unless (radiance:environment) + (setf (radiance:environment) "default")) + ;; Load the system with RADIANCE environment handling (handler-bind ((radiance-core:environment-not-set (lambda (c) diff --git a/database.lisp b/database.lisp index 19d6abd..575a1c1 100644 --- a/database.lisp +++ b/database.lisp @@ -22,5 +22,14 @@ (created-date :integer) (track-ids :text)))) + (unless (db:collection-exists-p "USERS") + (db:create "USERS" '((username :text) + (email :text) + (password-hash :text) + (role :text) + (active :integer) + (created-date :integer) + (last-login :integer)))) + (format t "Database collections initialized~%")) diff --git a/static/asteroid.css b/static/asteroid.css index 66e57a6..834c22e 100644 --- a/static/asteroid.css +++ b/static/asteroid.css @@ -486,6 +486,294 @@ body input{ font-family: Courier New, monospace; } -body body.player-page{ +body .upload-interface{ + margin-top: 2rem; + padding: 1.5rem; + background-color: #1a1a1a; + border-radius: 8px; + border: 1px solid #333; +} + +body .upload-interface h3{ + color: #00ff00; + margin-bottom: 1rem; +} + +body .upload-interface .upload-area{ + border: 2px dashed #333; + border-radius: 8px; + padding: 2rem; text-align: center; + background-color: #0f0f0f; + -moz-transition: border-color 0.3s ease; + -o-transition: border-color 0.3s ease; + -webkit-transition: border-color 0.3s ease; + -ms-transition: border-color 0.3s ease; + transition: border-color 0.3s ease; +} + +body .upload-interface .upload-area &:hover{ + border-color: #00ff00; +} + +body .upload-interface .upload-area .upload-icon{ + font-size: 3rem; + color: #666; + margin-bottom: 1rem; +} + +body .upload-interface .upload-area p{ + color: #999; + margin-bottom: 1rem; +} + +body .upload-interface .upload-area .btn{ + margin-top: 1rem; +} + +body .auth-container{ + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; + padding: 2rem; +} + +body .auth-form{ + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 2rem; + width: 100%; + max-width: 400px; + -moz-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + -o-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + -webkit-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + -ms-box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); +} + +body .auth-form h2{ + color: #00ff00; + text-align: center; + margin-bottom: 1.5rem; + font-size: 1.5rem; +} + +body .auth-form h3{ + color: #00ff00; + margin-bottom: 1rem; + font-size: 1.2rem; +} + +body .form-group{ + margin-bottom: 1rem; +} + +body .form-group label{ + display: block; + color: #ccc; + margin-bottom: 0.5rem; + font-weight: bold; +} + +body .form-group input{ + width: 100%; + padding: 0.75rem; + background-color: #0f0f0f; + border: 1px solid #333; + border-radius: 4px; + color: #fff; + font-size: 1rem; + box-sizing: border-box; +} + +body .form-group input &:focus{ + border-color: #00ff00; + outline: none; + -moz-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + -o-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + -webkit-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + -ms-box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); + box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2); +} + +body .form-actions{ + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +body .message{ + padding: 0.75rem; + border-radius: 4px; + margin-top: 1rem; + text-align: center; + font-weight: bold; +} + +body .message &.success{ + background-color: rgba(0, 255, 0, 0.1); + border: 1px solid #00ff00; + color: #00ff00; +} + +body .message &.error{ + background-color: rgba(255, 0, 0, 0.1); + border: 1px solid #ff0000; + color: #ff0000; +} + +body .auth-link{ + text-align: center; + margin-top: 1.5rem; + color: #999; +} + +body .auth-link a{ + color: #00ff00; + text-decoration: none; +} + +body .auth-link a &:hover{ + text-decoration: underline; +} + +body .profile-container{ + max-width: 600px; + margin: 2rem auto; + padding: 0 1rem; +} + +body .profile-card{ + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 2rem; + margin-bottom: 2rem; +} + +body .profile-card h2{ + color: #00ff00; + margin-bottom: 1.5rem; + text-align: center; +} + +body .profile-info{ + margin-bottom: 2rem; +} + +body .info-group{ + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 0; + border-bottom: 1px solid #333; +} + +body .info-group &:last-child{ + border-bottom: none; +} + +body .info-group label{ + color: #ccc; + font-weight: bold; +} + +body .info-group span{ + color: #fff; +} + +body .role-badge{ + background-color: #00ff00; + color: #000; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.875rem; + font-weight: bold; +} + +body .profile-actions{ + display: flex; + gap: 1rem; + justify-content: center; +} + +body .user-management{ + margin-top: 2rem; +} + +body .users-table{ + width: 100%; + border-collapse: collapse; + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + overflow: hidden; +} + +body .users-table thead{ + background-color: #0f0f0f; +} + +body .users-table thead th{ + padding: 1rem; + text-align: left; + color: #00ff00; + font-weight: bold; + border-bottom: 1px solid #333; +} + + + +body .users-table tbody tr{ + border-bottom: 1px solid #333; +} + +body .users-table tbody tr &:hover{ + background-color: #222; +} + +body .users-table tbody tr td{ + padding: 1rem; + color: #fff; + vertical-align: middle; +} + +body .users-table tbody .user-actions{ + display: flex; + gap: 0.5rem; +} + +body .users-table tbody .user-actions .btn{ + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +body .user-stats{ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +body .stat-card{ + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + padding: 1rem; + text-align: center; +} + +body .stat-card .stat-number{ + font-size: 2rem; + font-weight: bold; + color: #00ff00; + display: block; +} + +body .stat-card .stat-label{ + color: #ccc; + font-size: 0.875rem; + margin-top: 0.5rem; } \ No newline at end of file diff --git a/static/asteroid.lass b/static/asteroid.lass index 165e090..0e9e9eb 100644 --- a/static/asteroid.lass +++ b/static/asteroid.lass @@ -384,6 +384,218 @@ :border-radius "3px" :font-family "Courier New, monospace") + (.upload-interface + :margin-top 2rem + :padding 1.5rem + :background-color "#1a1a1a" + :border-radius 8px + :border "1px solid #333" + + (h3 :color "#00ff00" + :margin-bottom 1rem) + + (.upload-area + :border "2px dashed #333" + :border-radius 8px + :padding 2rem + :text-align center + :background-color "#0f0f0f" + :transition "border-color 0.3s ease" + + ("&:hover" :border-color "#00ff00") + + (.upload-icon :font-size 3rem + :color "#666" + :margin-bottom 1rem) + + (p :color "#999" + :margin-bottom 1rem) + + (.btn :margin-top 1rem))) + + ;; Authentication Styles + (.auth-container + :display flex + :justify-content center + :align-items center + :min-height "60vh" + :padding 2rem) + + (.auth-form + :background-color "#1a1a1a" + :border "1px solid #333" + :border-radius 8px + :padding 2rem + :width "100%" + :max-width 400px + :box-shadow "0 4px 6px rgba(0, 0, 0, 0.3)" + + (h2 :color "#00ff00" + :text-align center + :margin-bottom 1.5rem + :font-size 1.5rem) + + (h3 :color "#00ff00" + :margin-bottom 1rem + :font-size 1.2rem)) + + (.form-group + :margin-bottom 1rem + + (label :display block + :color "#ccc" + :margin-bottom 0.5rem + :font-weight bold) + + (input :width "100%" + :padding 0.75rem + :background-color "#0f0f0f" + :border "1px solid #333" + :border-radius 4px + :color "#fff" + :font-size 1rem + :box-sizing border-box + + ("&:focus" :border-color "#00ff00" + :outline none + :box-shadow "0 0 0 2px rgba(0, 255, 0, 0.2)"))) + + (.form-actions + :display flex + :gap 1rem + :margin-top 1.5rem) + + (.message + :padding 0.75rem + :border-radius 4px + :margin-top 1rem + :text-align center + :font-weight bold + + ("&.success" :background-color "rgba(0, 255, 0, 0.1)" + :border "1px solid #00ff00" + :color "#00ff00") + + ("&.error" :background-color "rgba(255, 0, 0, 0.1)" + :border "1px solid #ff0000" + :color "#ff0000")) + + (.auth-link + :text-align center + :margin-top 1.5rem + :color "#999" + + (a :color "#00ff00" + :text-decoration none + + ("&:hover" :text-decoration underline))) + + ;; Profile Styles + (.profile-container + :max-width 600px + :margin "2rem auto" + :padding 0 1rem) + + (.profile-card + :background-color "#1a1a1a" + :border "1px solid #333" + :border-radius 8px + :padding 2rem + :margin-bottom 2rem + + (h2 :color "#00ff00" + :margin-bottom 1.5rem + :text-align center)) + + (.profile-info + :margin-bottom 2rem) + + (.info-group + :display flex + :justify-content space-between + :align-items center + :padding 0.75rem 0 + :border-bottom "1px solid #333" + + ("&:last-child" :border-bottom none) + + (label :color "#ccc" + :font-weight bold) + + (span :color "#fff")) + + (.role-badge + :background-color "#00ff00" + :color "#000" + :padding "0.25rem 0.5rem" + :border-radius 4px + :font-size 0.875rem + :font-weight bold) + + (.profile-actions + :display flex + :gap 1rem + :justify-content center) + + ;; User Management Styles + (.user-management + :margin-top 2rem) + + (.users-table + :width "100%" + :border-collapse collapse + :background-color "#1a1a1a" + :border "1px solid #333" + :border-radius 8px + :overflow hidden + + (thead + :background-color "#0f0f0f" + + (th :padding 1rem + :text-align left + :color "#00ff00" + :font-weight bold + :border-bottom "1px solid #333")) + + (tbody + (tr :border-bottom "1px solid #333" + + ("&:hover" :background-color "#222") + + (td :padding 1rem + :color "#fff" + :vertical-align middle)) + + (.user-actions + :display flex + :gap 0.5rem + + (.btn :padding "0.25rem 0.5rem" + :font-size 0.875rem)))) + + (.user-stats + :display grid + :grid-template-columns "repeat(auto-fit, minmax(150px, 1fr))" + :gap 1rem + :margin-bottom 2rem) + + (.stat-card + :background-color "#1a1a1a" + :border "1px solid #333" + :border-radius 8px + :padding 1rem + :text-align center + + (.stat-number :font-size 2rem + :font-weight bold + :color "#00ff00" + :display block) + + (.stat-label :color "#ccc" + :font-size 0.875rem + :margin-top 0.5rem))) + ;; Center alignment for player page (body.player-page :text-align center)) diff --git a/template/admin.chtml b/template/admin.chtml index 67ea272..ee844a5 100644 --- a/template/admin.chtml +++ b/template/admin.chtml @@ -91,17 +91,44 @@

Player Control

-
- - - - +
+

🎡 Player Control

+
+ + + + +
+
+ Status: Unknown
+ Current Track: None +
- -
-

Status: Stopped

-

Current Track: None

-

Position: 0s

+ +
+

πŸ‘₯ User Management

+
+
+ 0 + Total Users +
+
+ 0 + Active Users +
+
+ 0 + Admins +
+
+ 0 + DJs +
+
+
+ + +
@@ -325,8 +352,152 @@ alert('Copy your MP3 files to: /home/glenn/Projects/Code/asteroid/music/incoming/\n\nThen click "Copy Files to Library" to add them to your music collection.'); } + // User Management Functions + async function loadUserStats() { + try { + const response = await fetch('/asteroid/api/users/stats'); + const result = await response.json(); + + if (result.status === 'success') { + const stats = result.stats; + document.getElementById('total-users').textContent = stats.total; + document.getElementById('active-users').textContent = stats.active; + document.getElementById('admin-users').textContent = stats.admins; + document.getElementById('dj-users').textContent = stats.djs; + } + } catch (error) { + console.error('Error loading user stats:', error); + } + } + + async function loadUsers() { + try { + const response = await fetch('/asteroid/api/users'); + const result = await response.json(); + + if (result.status === 'success') { + showUsersTable(result.users); + } + } catch (error) { + console.error('Error loading users:', error); + alert('Error loading users. Please try again.'); + } + } + + function showUsersTable(users) { + const container = document.createElement('div'); + container.className = 'user-management'; + container.innerHTML = ` +

User Management

+ + + + + + + + + + + + + ${users.map(user => ` + + + + + + + + + `).join('')} + +
UsernameEmailRoleStatusLast LoginActions
${user.username}${user.email} + + ${user.active ? 'βœ… Active' : '❌ Inactive'}${user['last-login'] ? new Date(user['last-login'] * 1000).toLocaleString() : 'Never'}
+ + `; + + document.body.appendChild(container); + } + + function hideUsersTable() { + const userManagement = document.querySelector('.user-management'); + if (userManagement) { + userManagement.remove(); + } + } + + async function updateUserRole(userId, newRole) { + try { + const formData = new FormData(); + formData.append('role', newRole); + + const response = await fetch(`/asteroid/api/users/${userId}/role`, { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.status === 'success') { + loadUserStats(); + alert('User role updated successfully'); + } else { + alert('Error updating user role: ' + result.message); + } + } catch (error) { + console.error('Error updating user role:', error); + alert('Error updating user role. Please try again.'); + } + } + + async function deactivateUser(userId) { + if (!confirm('Are you sure you want to deactivate this user?')) { + return; + } + + try { + const response = await fetch(`/asteroid/api/users/${userId}/deactivate`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.status === 'success') { + loadUsers(); + loadUserStats(); + alert('User deactivated successfully'); + } else { + alert('Error deactivating user: ' + result.message); + } + } catch (error) { + console.error('Error deactivating user:', error); + alert('Error deactivating user. Please try again.'); + } + } + + function showCreateUser() { + window.location.href = '/asteroid/register'; + } + + // Load user stats on page load + loadUserStats(); + // Update player status every 5 seconds setInterval(updatePlayerStatus, 5000); + + // Update user stats every 30 seconds + setInterval(loadUserStats, 30000); diff --git a/template/front-page.chtml b/template/front-page.chtml index 84af182..4754e5f 100644 --- a/template/front-page.chtml +++ b/template/front-page.chtml @@ -4,41 +4,49 @@ 🎡 ASTEROID RADIO 🎡 - +
-

🎡 ASTEROID RADIO 🎡

-
-

Station Status

-

🟒 LIVE - Broadcasting asteroid music for hackers

-

Current listeners: 0

-

Stream quality: 128kbps MP3

-
- - -
-

πŸ”΄ LIVE STREAM

-

Stream URL: http://localhost:8000/asteroid.mp3

-

Format: MP3 128kbps Stereo

-

Status: ● BROADCASTING

- -
-
-

Now Playing

-

Artist: The Void

-

Track: Silence

-

Album: Startup Sounds

-

Duration: ∞

-
+
+

🎡 ASTEROID RADIO 🎡

+ +
+ +
+
+

Station Status

+

🟒 LIVE - Broadcasting asteroid music for hackers

+

Current listeners: 0

+

Stream quality: 128kbps MP3

+
+ +
+

πŸ”΄ LIVE STREAM

+

Stream URL: http://localhost:8000/asteroid.mp3

+

Format: MP3 128kbps Stereo

+

Status: ● BROADCASTING

+ +
+ +
+

Now Playing

+

Artist: The Void

+

Track: Silence

+

Album: Startup Sounds

+

Duration: ∞

+
+
diff --git a/user-management.lisp b/user-management.lisp new file mode 100644 index 0000000..10c6d04 --- /dev/null +++ b/user-management.lisp @@ -0,0 +1,218 @@ +;;;; user-management.lisp - User Management System for Asteroid Radio +;;;; Core user management functionality and database operations + +(in-package :asteroid) + +;; User roles and permissions +(defparameter *user-roles* '(:listener :dj :admin)) + +;; User management functions +(defun create-user (username email password &key (role :listener) (active t)) + "Create a new user account" + (let* ((password-hash (hash-password password)) + (user-data `(("username" ,username) + ("email" ,email) + ("password-hash" ,password-hash) + ("role" ,(string-downcase (symbol-name role))) + ("active" ,(if active 1 0)) + ("created-date" ,(local-time:timestamp-to-unix (local-time:now))) + ("last-login" nil)))) + (handler-case + (db:with-transaction () + (format t "Inserting user data: ~a~%" user-data) + (let ((result (db:insert "USERS" user-data))) + (format t "Insert result: ~a~%" result) + (format t "User created: ~a (~a)~%" username role) + t)) + (error (e) + (format t "Error creating user ~a: ~a~%" username e) + nil)))) + +(defun find-user-by-username (username) + "Find a user by username" + (format t "Searching for user: ~a~%" username) + (format t "Available collections: ~a~%" (db:collections)) + (format t "Trying to select from USERS collection...~%") + (let ((all-users-test (db:select "USERS" (db:query :all)))) + (format t "Total users in USERS collection: ~a~%" (length all-users-test)) + (dolist (user all-users-test) + (format t "User data: ~a~%" user) + (format t "Username field: ~a~%" (gethash "username" user)))) + (let ((all-users (db:select "USERS" (db:query :all))) + (users nil)) + (dolist (user all-users) + (format t "Comparing ~a with ~a~%" (gethash "username" user) username) + (when (equal (first (gethash "username" user)) username) + (push user users))) + (format t "Query returned ~a users~%" (length users)) + (when users + (format t "First user: ~a~%" (first users)) + (first users)))) + +(defun find-user-by-id (user-id) + "Find a user by ID" + (let ((users (db:select "USERS" (db:query (:= "_id" user-id))))) + (when users (first users)))) + +(defun authenticate-user (username password) + "Authenticate a user with username and password" + (format t "Attempting to authenticate user: ~a~%" username) + (let ((user (find-user-by-username username))) + (format t "User found: ~a~%" (if user "YES" "NO")) + (when user + (handler-case + (progn + (format t "User active: ~a~%" (gethash "active" user)) + (format t "Password hash from DB: ~a~%" (gethash "password-hash" user)) + (format t "Password verification: ~a~%" + (verify-password password (first (gethash "password-hash" user))))) + (error (e) + (format t "Error during user data access: ~a~%" e)))) + (when (and user + (= (first (gethash "active" user)) 1) + (verify-password password (first (gethash "password-hash" user)))) + ;; Update last login + (db:update "USERS" + (db:query (:= "_id" (gethash "_id" user))) + `(("last-login" ,(local-time:timestamp-to-unix (local-time:now))))) + user))) + +(defun hash-password (password) + "Hash a password using ironclad" + (let ((digest (ironclad:make-digest :sha256))) + (ironclad:update-digest digest (babel:string-to-octets password)) + (ironclad:byte-array-to-hex-string (ironclad:produce-digest digest)))) + +(defun verify-password (password hash) + "Verify a password against its hash" + (string= (hash-password password) hash)) + +(defun user-has-role-p (user role) + "Check if user has a specific role" + (when user + (let ((user-role (intern (string-upcase (gethash "role" user)) :keyword))) + (or (eq user-role role) + (and (eq role :listener) (member user-role '(:dj :admin))) + (and (eq role :dj) (eq user-role :admin)))))) + +(defun get-current-user () + "Get the currently authenticated user from session" + (handler-case + (let ((user-id (session:field "user-id"))) + (when user-id + (find-user-by-id user-id))) + (error (e) + (format t "Error getting current user: ~a~%" e) + nil))) + +(defun require-authentication () + "Require user to be authenticated" + (handler-case + (unless (session:field "user-id") + (radiance:redirect "/asteroid/login")) + (error (e) + (format t "Authentication error: ~a~%" e) + (radiance:redirect "/asteroid/login")))) + +(defun require-role (role) + "Require user to have a specific role" + (handler-case + (let ((current-user (get-current-user))) + (unless (and current-user (user-has-role-p current-user role)) + (radiance:redirect "/asteroid/login"))) + (error (e) + (format t "Role check error: ~a~%" e) + (radiance:redirect "/asteroid/login")))) + +(defun update-user-role (user-id new-role) + "Update a user's role" + (handler-case + (progn + (db:update "USERS" + (db:query (:= "_id" user-id)) + `(("role" ,(string-downcase (symbol-name new-role))))) + (format t "Updated user ~a role to ~a~%" user-id new-role) + t) + (error (e) + (format t "Error updating user role: ~a~%" e) + nil))) + +(defun deactivate-user (user-id) + "Deactivate a user account" + (handler-case + (progn + (db:update "USERS" + (db:query (:= "_id" user-id)) + `(("active" 0))) + (format t "Deactivated user ~a~%" user-id) + t) + (error (e) + (format t "Error deactivating user: ~a~%" e) + nil))) + +(defun activate-user (user-id) + "Activate a user account" + (handler-case + (progn + (db:update "USERS" + (db:query (:= "_id" user-id)) + `(("active" 1))) + (format t "Activated user ~a~%" user-id) + t) + (error (e) + (format t "Error activating user: ~a~%" e) + nil))) + +(defun get-all-users () + "Get all users from database" + (format t "Getting all users from database...~%") + (let ((all-users (db:select "USERS" (db:query :all)))) + (format t "Total users in database: ~a~%" (length all-users)) + (dolist (user all-users) + (format t "User: ~a~%" user)) + all-users)) + +(defun get-user-stats () + "Get user statistics" + (let ((all-users (get-all-users))) + `(("total-users" . ,(length all-users)) + ("active-users" . ,(count-if (lambda (user) (gethash "active" user)) all-users)) + ("listeners" . ,(count-if (lambda (user) (string= (gethash "role" user) "listener")) all-users)) + ("djs" . ,(count-if (lambda (user) (string= (gethash "role" user) "dj")) all-users)) + ("admins" . ,(count-if (lambda (user) (string= (gethash "role" user) "admin")) all-users))))) + +(defun create-default-admin () + "Create default admin user if no admin exists" + (let ((existing-admins (remove-if-not + (lambda (user) (string= (gethash "role" user) "admin")) + (get-all-users)))) + (unless existing-admins + (format t "~%Creating default admin user...~%") + (format t "Username: admin~%") + (format t "Password: asteroid123~%") + (format t "Please change this password after first login!~%~%") + (create-user "admin" "admin@asteroid.radio" "asteroid123" :role :admin :active t)))) + +(defun initialize-user-system () + "Initialize the user management system" + (format t "Initializing user management system...~%") + ;; Try immediate initialization first + (handler-case + (progn + (format t "Setting up user management...~%") + (create-default-admin) + (format t "User management initialization complete.~%")) + (error (e) + (format t "Database not ready, will retry in background: ~a~%" e) + ;; Fallback to delayed initialization + (bt:make-thread + (lambda () + (sleep 3) ; Give database more time to initialize + (handler-case + (progn + (format t "Retrying user management setup...~%") + (create-default-admin) + (format t "User management initialization complete.~%")) + (error (e) + (format t "Error initializing user system: ~a~%" e)))) + :name "user-init"))))