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:
parent
16f3592e97
commit
84d0bc4ce4
15
asteroid.asd
15
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")))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))))))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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~%"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))))
|
||||
Loading…
Reference in New Issue