Fix Asteroid Radio authentication system

- Fix database query syntax for RADIANCE hash table returns
- Handle RADIANCE field storage format (lists instead of strings)
- Configure r-simple-sessions module for session management
- Update login page styling to match main site theme
- Implement working authentication with admin/asteroid123
- Add proper error handling and debug logging
- Ensure session persistence and redirects work correctly
This commit is contained in:
Glenn Thompson 2025-09-16 19:46:23 +03:00 committed by Brian O'Reilly
parent 16f3592e97
commit 84d0bc4ce4
10 changed files with 1168 additions and 67 deletions

View File

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

View File

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

143
auth-routes.lisp Normal file
View File

@ -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
"<!DOCTYPE html>
<html>
<head>
<title>Asteroid Radio - Login</title>
<link rel='stylesheet' href='/static/asteroid.css'>
</head>
<body>
<div class='container'>
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
<div class='auth-container'>
<div class='auth-form'>
<h2>System Access</h2>
<div class='message error'>Invalid username or password</div>
<form method='post' action='/asteroid/login'>
<div class='form-group'>
<label>Username:</label>
<input type='text' name='username' required>
</div>
<div class='form-group'>
<label>Password:</label>
<input type='password' name='password' required>
</div>
<div class='form-actions'>
<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: <code style='color: #00ff00;'>admin</code><br>
Password: <code style='color: #00ff00;'>asteroid123</code>
</div>
</div>
</div>
</div>
</body>
</html>"))
;; Show login form (no POST data)
"<!DOCTYPE html>
<html>
<head>
<title>Asteroid Radio - Login</title>
<link rel='stylesheet' href='/static/asteroid.css'>
</head>
<body>
<div class='container'>
<h1>🎵 ASTEROID RADIO - LOGIN</h1>
<div class='auth-container'>
<div class='auth-form'>
<h2>System Access</h2>
<form method='post' action='/asteroid/login'>
<div class='form-group'>
<label>Username:</label>
<input type='text' name='username' required>
</div>
<div class='form-group'>
<label>Password:</label>
<input type='password' name='password' required>
</div>
<div class='form-actions'>
<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: <code style='color: #00ff00;'>admin</code><br>
Password: <code style='color: #00ff00;'>asteroid123</code>
</div>
</div>
</div>
</div>
</body>
</html>")))
;; 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)))))))

View File

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

View File

@ -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~%"))

View File

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

View File

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

View File

@ -91,17 +91,44 @@
<!-- Player Control -->
<div class="admin-section">
<h2>Player Control</h2>
<div class="player-controls">
<button id="player-play" class="btn btn-success">▶️ Play</button>
<button id="player-pause" class="btn btn-warning">⏸️ Pause</button>
<button id="player-stop" class="btn btn-danger">⏹️ Stop</button>
<button id="player-resume" class="btn btn-info">▶️ Resume</button>
<div class="card">
<h3>🎵 Player Control</h3>
<div class="player-controls">
<button class="btn btn-primary" onclick="playTrack()">▶️ Play</button>
<button class="btn btn-secondary" onclick="pausePlayer()">⏸️ Pause</button>
<button class="btn btn-secondary" onclick="stopPlayer()">⏹️ Stop</button>
<button class="btn btn-secondary" onclick="resumePlayer()">▶️ Resume</button>
</div>
<div id="player-status" class="status-info">
Status: <span id="status-text">Unknown</span><br>
Current Track: <span id="current-track">None</span>
</div>
</div>
<div class="player-status">
<p>Status: <span id="player-state">Stopped</span></p>
<p>Current Track: <span id="current-track">None</span></p>
<p>Position: <span id="current-position">0</span>s</p>
<div class="card">
<h3>👥 User Management</h3>
<div class="user-stats" id="user-stats">
<div class="stat-card">
<span class="stat-number" id="total-users">0</span>
<span class="stat-label">Total Users</span>
</div>
<div class="stat-card">
<span class="stat-number" id="active-users">0</span>
<span class="stat-label">Active Users</span>
</div>
<div class="stat-card">
<span class="stat-number" id="admin-users">0</span>
<span class="stat-label">Admins</span>
</div>
<div class="stat-card">
<span class="stat-number" id="dj-users">0</span>
<span class="stat-label">DJs</span>
</div>
</div>
<div class="controls">
<button class="btn btn-primary" onclick="loadUsers()">👥 Manage Users</button>
<button class="btn btn-secondary" onclick="showCreateUser()"> Create User</button>
</div>
</div>
</div>
</div>
@ -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 = `
<h3>User Management</h3>
<table class="users-table">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td>${user.username}</td>
<td>${user.email}</td>
<td>
<select onchange="updateUserRole('${user.id}', this.value)">
<option value="listener" ${user.role === 'listener' ? 'selected' : ''}>Listener</option>
<option value="dj" ${user.role === 'dj' ? 'selected' : ''}>DJ</option>
<option value="admin" ${user.role === 'admin' ? 'selected' : ''}>Admin</option>
</select>
</td>
<td>${user.active ? '✅ Active' : '❌ Inactive'}</td>
<td>${user['last-login'] ? new Date(user['last-login'] * 1000).toLocaleString() : 'Never'}</td>
<td class="user-actions">
${user.active ?
`<button class="btn btn-danger" onclick="deactivateUser('${user.id}')">Deactivate</button>` :
`<button class="btn btn-success" onclick="activateUser('${user.id}')">Activate</button>`
}
</td>
</tr>
`).join('')}
</tbody>
</table>
<button class="btn btn-secondary" onclick="hideUsersTable()">Close</button>
`;
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);
</script>
</body>
</html>

View File

@ -4,41 +4,49 @@
<title data-text="title">🎵 ASTEROID RADIO 🎵</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="/static/asteroid.css">
<link rel="stylesheet" type="text/css" href="/asteroid/static/asteroid.css">
</head>
<body>
<div class="container">
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
<div class="status">
<h2>Station Status</h2>
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
<p>Current listeners: <span data-text="listeners">0</span></p>
<p>Stream quality: <span data-text="stream-quality">128kbps MP3</span></p>
</div>
<div class="nav">
<a href="/admin">Admin Dashboard</a>
<a href="/player/">Web Player</a>
<a href="http://localhost:8000/asteroid.mp3" target="_blank">🎵 Live Stream</a>
<a href="/status">API Status</a>
</div>
<div class="live-stream">
<h2>🔴 LIVE STREAM</h2>
<p><strong>Stream URL:</strong> <code>http://localhost:8000/asteroid.mp3</code></p>
<p><strong>Format:</strong> MP3 128kbps Stereo</p>
<p><strong>Status:</strong> <span style="color: #00ff00;">● BROADCASTING</span></p>
<audio controls style="width: 100%; margin: 10px 0;">
<source src="http://localhost:8000/asteroid.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<div>
<h2>Now Playing</h2>
<p>Artist: <span data-text="now-playing-artist">The Void</span></p>
<p>Track: <span data-text="now-playing-track">Silence</span></p>
<p>Album: <span data-text="now-playing-album">Startup Sounds</span></p>
<p>Duration: <span data-text="now-playing-duration">∞</span></p>
</div>
<header>
<h1 data-text="station-name">🎵 ASTEROID RADIO 🎵</h1>
<nav>
<a href="/asteroid/">Home</a>
<a href="/asteroid/player">Player</a>
<a href="/asteroid/admin">Admin</a>
<a href="/asteroid/status">Status</a>
<a href="/asteroid/login">Login</a>
<a href="/asteroid/register">Register</a>
</nav>
</header>
<main>
<div class="status">
<h2>Station Status</h2>
<p data-text="status-message">🟢 LIVE - Broadcasting asteroid music for hackers</p>
<p>Current listeners: <span data-text="listeners">0</span></p>
<p>Stream quality: <span data-text="stream-quality">128kbps MP3</span></p>
</div>
<div class="live-stream">
<h2>🔴 LIVE STREAM</h2>
<p><strong>Stream URL:</strong> <code>http://localhost:8000/asteroid.mp3</code></p>
<p><strong>Format:</strong> MP3 128kbps Stereo</p>
<p><strong>Status:</strong> <span style="color: #00ff00;">● BROADCASTING</span></p>
<audio controls style="width: 100%; margin: 10px 0;">
<source src="http://localhost:8000/asteroid.mp3" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
</div>
<div class="now-playing">
<h2>Now Playing</h2>
<p>Artist: <span data-text="now-playing-artist">The Void</span></p>
<p>Track: <span data-text="now-playing-track">Silence</span></p>
<p>Album: <span data-text="now-playing-album">Startup Sounds</span></p>
<p>Duration: <span data-text="now-playing-duration">∞</span></p>
</div>
</main>
</div>
</body>
</html>

218
user-management.lisp Normal file
View File

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