diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/internal/server/admin_handlers.go b/internal/server/admin_handlers.go new file mode 100644 index 0000000..0dca159 --- /dev/null +++ b/internal/server/admin_handlers.go @@ -0,0 +1,685 @@ +package server + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Admin authentication and management handlers + +// generateToken generates a secure random token +func generateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// AdminLoginRequest represents a login request +type AdminLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// AdminLoginResponse represents a login response +type AdminLoginResponse struct { + Success bool `json:"success"` + Token string `json:"token,omitempty"` + Message string `json:"message,omitempty"` +} + +// handleAdminLogin handles admin login +func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req AdminLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + admin, err := s.db.GetAdminByUsername(req.Username) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + if admin == nil { + json.NewEncoder(w).Encode(AdminLoginResponse{ + Success: false, + Message: "Invalid credentials", + }) + return + } + + // Verify password + if admin.PasswordHash != hashAPIKey(req.Password) { + json.NewEncoder(w).Encode(AdminLoginResponse{ + Success: false, + Message: "Invalid credentials", + }) + return + } + + // Generate session token + token, err := generateToken() + if err != nil { + http.Error(w, "Failed to generate token", http.StatusInternalServerError) + return + } + + // Create session (valid for 24 hours) + expiresAt := time.Now().Add(24 * time.Hour) + if err := s.db.CreateSession(admin.ID, token, expiresAt); err != nil { + http.Error(w, "Failed to create session", http.StatusInternalServerError) + return + } + + // Set cookie + http.SetCookie(w, &http.Cookie{ + Name: "admin_token", + Value: token, + Path: "/", + Expires: expiresAt, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(AdminLoginResponse{ + Success: true, + Token: token, + Message: "Login successful", + }) +} + +// handleAdminLogout handles admin logout +func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("admin_token") + if err == nil { + s.db.DeleteSession(cookie.Value) + } + + http.SetCookie(w, &http.Cookie{ + Name: "admin_token", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Logged out successfully", + }) +} + +// authenticateAdmin checks if the request has a valid admin session +func (s *Server) authenticateAdmin(r *http.Request) (*Admin, error) { + cookie, err := r.Cookie("admin_token") + if err != nil { + return nil, err + } + + session, err := s.db.GetSessionByToken(cookie.Value) + if err != nil { + return nil, err + } + + if session == nil { + return nil, nil + } + + if session.ExpiresAt.Before(time.Now()) { + s.db.DeleteSession(cookie.Value) + return nil, nil + } + + admin, err := s.db.GetAdminByID(session.AdminID) + if err != nil { + return nil, err + } + + return admin, nil +} + +// handleAdminCheck checks if admin is authenticated +func (s *Server) handleAdminCheck(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "authenticated": false, + }) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "authenticated": true, + "username": admin.Username, + "role": admin.Role, + }) +} + +// Client management handlers + +// handleAdminGetClients returns all clients +func (s *Server) handleAdminGetClients(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clients, err := s.db.GetAllClients() + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + // Add usage info for each client + type ClientWithUsage struct { + *ClientConfig + CurrentUsage int64 `json:"current_usage"` + SnapshotCount int `json:"snapshot_count"` + } + + var result []ClientWithUsage + for _, c := range clients { + usage, _ := s.db.GetClientUsage(c.ClientID) + snapshots, _ := s.db.GetSnapshotsByClient(c.ClientID) + result = append(result, ClientWithUsage{ + ClientConfig: c, + CurrentUsage: usage, + SnapshotCount: len(snapshots), + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleAdminGetClient returns a specific client +func (s *Server) handleAdminGetClient(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clientID := r.URL.Query().Get("client_id") + if clientID == "" { + http.Error(w, "client_id required", http.StatusBadRequest) + return + } + + client, err := s.db.GetClient(clientID) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + if client == nil { + http.Error(w, "Client not found", http.StatusNotFound) + return + } + + usage, _ := s.db.GetClientUsage(clientID) + snapshots, _ := s.db.GetSnapshotsByClient(clientID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "client": client, + "current_usage": usage, + "snapshot_count": len(snapshots), + }) +} + +// handleAdminCreateClient creates a new client +func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientID string `json:"client_id"` + APIKey string `json:"api_key"` + MaxSizeBytes int64 `json:"max_size_bytes"` + Dataset string `json:"dataset"` + StorageType string `json:"storage_type"` + Enabled bool `json:"enabled"` + RotationPolicy *RotationPolicy `json:"rotation_policy"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ClientID == "" || req.APIKey == "" { + http.Error(w, "client_id and api_key required", http.StatusBadRequest) + return + } + + if req.StorageType == "" { + req.StorageType = "s3" + } + + client := &ClientConfig{ + ClientID: req.ClientID, + APIKey: hashAPIKey(req.APIKey), + MaxSizeBytes: req.MaxSizeBytes, + Dataset: req.Dataset, + StorageType: req.StorageType, + Enabled: req.Enabled, + RotationPolicy: req.RotationPolicy, + } + + if err := s.db.SaveClient(client); err != nil { + http.Error(w, "Failed to create client", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Client created successfully", + }) +} + +// handleAdminUpdateClient updates an existing client +func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPut && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ClientID string `json:"client_id"` + APIKey string `json:"api_key"` + MaxSizeBytes int64 `json:"max_size_bytes"` + Dataset string `json:"dataset"` + StorageType string `json:"storage_type"` + Enabled bool `json:"enabled"` + RotationPolicy *RotationPolicy `json:"rotation_policy"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.ClientID == "" { + http.Error(w, "client_id required", http.StatusBadRequest) + return + } + + // Get existing client + existing, err := s.db.GetClient(req.ClientID) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + if existing == nil { + http.Error(w, "Client not found", http.StatusNotFound) + return + } + + // Update fields + if req.APIKey != "" { + existing.APIKey = hashAPIKey(req.APIKey) + } + if req.MaxSizeBytes > 0 { + existing.MaxSizeBytes = req.MaxSizeBytes + } + if req.Dataset != "" { + existing.Dataset = req.Dataset + } + if req.StorageType != "" { + existing.StorageType = req.StorageType + } + existing.Enabled = req.Enabled + if req.RotationPolicy != nil { + existing.RotationPolicy = req.RotationPolicy + } + + if err := s.db.SaveClient(existing); err != nil { + http.Error(w, "Failed to update client", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Client updated successfully", + }) +} + +// handleAdminDeleteClient deletes a client +func (s *Server) handleAdminDeleteClient(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodDelete && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + clientID := r.URL.Query().Get("client_id") + if clientID == "" { + http.Error(w, "client_id required", http.StatusBadRequest) + return + } + + // Delete snapshots first (handled by foreign key cascade) + // Get snapshots to delete from storage + snapshots, _ := s.db.GetSnapshotsByClient(clientID) + for _, snap := range snapshots { + if snap.StorageType == "s3" && s.s3Backend != nil { + s.s3Backend.Delete(context.Background(), snap.StorageKey) + } + } + + if err := s.db.DeleteClient(clientID); err != nil { + http.Error(w, "Failed to delete client", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Client deleted successfully", + }) +} + +// handleAdminGetSnapshots returns all snapshots for a client +func (s *Server) handleAdminGetSnapshots(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clientID := r.URL.Query().Get("client_id") + var snapshots []*SnapshotMetadata + + if clientID != "" { + snapshots, err = s.db.GetSnapshotsByClient(clientID) + } else { + snapshots, err = s.db.GetAllSnapshots() + } + + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(snapshots) +} + +// handleAdminDeleteSnapshot deletes a specific snapshot +func (s *Server) handleAdminDeleteSnapshot(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodDelete && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + clientID := r.URL.Query().Get("client_id") + snapshotID := r.URL.Query().Get("snapshot_id") + + if clientID == "" || snapshotID == "" { + http.Error(w, "client_id and snapshot_id required", http.StatusBadRequest) + return + } + + // Get snapshot to delete from storage + snap, err := s.db.GetSnapshotByID(clientID, snapshotID) + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + if snap != nil { + if snap.StorageType == "s3" && s.s3Backend != nil { + s.s3Backend.Delete(context.Background(), snap.StorageKey) + } + } + + if err := s.db.DeleteSnapshot(clientID, snapshotID); err != nil { + http.Error(w, "Failed to delete snapshot", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Snapshot deleted successfully", + }) +} + +// handleAdminGetStats returns server statistics +func (s *Server) handleAdminGetStats(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clients, _ := s.db.GetAllClients() + totalSnapshots, _ := s.db.GetTotalSnapshotCount() + totalStorage, _ := s.db.GetTotalStorageUsed() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "client_count": len(clients), + "total_snapshots": totalSnapshots, + "total_storage": totalStorage, + "total_storage_gb": float64(totalStorage) / (1024 * 1024 * 1024), + }) +} + +// Admin management handlers + +// handleAdminGetAdmins returns all admins +func (s *Server) handleAdminGetAdmins(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + admins, err := s.db.GetAllAdmins() + if err != nil { + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + + // Remove password hashes from response + type AdminResponse struct { + ID int `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + var result []AdminResponse + for _, a := range admins { + result = append(result, AdminResponse{ + ID: a.ID, + Username: a.Username, + Role: a.Role, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleAdminCreateAdmin creates a new admin +func (s *Server) handleAdminCreateAdmin(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Username == "" || req.Password == "" { + http.Error(w, "username and password required", http.StatusBadRequest) + return + } + + if req.Role == "" { + req.Role = "admin" + } + + if err := s.db.CreateAdmin(req.Username, hashAPIKey(req.Password), req.Role); err != nil { + http.Error(w, "Failed to create admin (username may already exist)", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Admin created successfully", + }) +} + +// handleAdminDeleteAdmin deletes an admin +func (s *Server) handleAdminDeleteAdmin(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodDelete && r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + // Prevent deleting yourself + var targetAdminID int + fmt.Sscanf(id, "%d", &targetAdminID) + if admin != nil && targetAdminID == admin.ID { + http.Error(w, "Cannot delete yourself", http.StatusBadRequest) + return + } + + if err := s.db.DeleteAdmin(targetAdminID); err != nil { + http.Error(w, "Failed to delete admin", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Admin deleted successfully", + }) +} + +// handleAdminChangePassword handles admin password change +func (s *Server) handleAdminChangePassword(w http.ResponseWriter, r *http.Request) { + admin, err := s.authenticateAdmin(r) + if err != nil || admin == nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ID int `json:"id"` + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Password == "" { + http.Error(w, "Password required", http.StatusBadRequest) + return + } + + if err := s.db.UpdateAdminPassword(req.ID, hashAPIKey(req.Password)); err != nil { + http.Error(w, "Failed to update password", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Password changed successfully", + }) +} + +// handleAdminUI serves the admin panel UI +func (s *Server) handleAdminUI(w http.ResponseWriter, r *http.Request) { + // Serve the embedded admin UI HTML + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(adminPanelHTML)) +} diff --git a/internal/server/admin.go b/internal/server/admin_html.go similarity index 55% rename from internal/server/admin.go rename to internal/server/admin_html.go index 1c989ec..a70de27 100644 --- a/internal/server/admin.go +++ b/internal/server/admin_html.go @@ -1,565 +1,5 @@ package server -import ( - "context" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "net/http" - "time" -) - -// Admin authentication and management handlers - -// generateToken generates a secure random token -func generateToken() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return hex.EncodeToString(bytes), nil -} - -// AdminLoginRequest represents a login request -type AdminLoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// AdminLoginResponse represents a login response -type AdminLoginResponse struct { - Success bool `json:"success"` - Token string `json:"token,omitempty"` - Message string `json:"message,omitempty"` -} - -// handleAdminLogin handles admin login -func (s *Server) handleAdminLogin(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req AdminLoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - admin, err := s.db.GetAdminByUsername(req.Username) - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - if admin == nil { - json.NewEncoder(w).Encode(AdminLoginResponse{ - Success: false, - Message: "Invalid credentials", - }) - return - } - - // Verify password - if admin.PasswordHash != hashAPIKey(req.Password) { - json.NewEncoder(w).Encode(AdminLoginResponse{ - Success: false, - Message: "Invalid credentials", - }) - return - } - - // Generate session token - token, err := generateToken() - if err != nil { - http.Error(w, "Failed to generate token", http.StatusInternalServerError) - return - } - - // Create session (valid for 24 hours) - expiresAt := time.Now().Add(24 * time.Hour) - if err := s.db.CreateSession(admin.ID, token, expiresAt); err != nil { - http.Error(w, "Failed to create session", http.StatusInternalServerError) - return - } - - // Set cookie - http.SetCookie(w, &http.Cookie{ - Name: "admin_token", - Value: token, - Path: "/", - Expires: expiresAt, - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(AdminLoginResponse{ - Success: true, - Token: token, - Message: "Login successful", - }) -} - -// handleAdminLogout handles admin logout -func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie("admin_token") - if err == nil { - s.db.DeleteSession(cookie.Value) - } - - http.SetCookie(w, &http.Cookie{ - Name: "admin_token", - Value: "", - Path: "/", - MaxAge: -1, - HttpOnly: true, - }) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Logged out successfully", - }) -} - -// authenticateAdmin checks if the request has a valid admin session -func (s *Server) authenticateAdmin(r *http.Request) (*Admin, error) { - cookie, err := r.Cookie("admin_token") - if err != nil { - return nil, err - } - - session, err := s.db.GetSessionByToken(cookie.Value) - if err != nil { - return nil, err - } - - if session == nil { - return nil, nil - } - - if session.ExpiresAt.Before(time.Now()) { - s.db.DeleteSession(cookie.Value) - return nil, nil - } - - admin, err := s.db.GetAdminByID(session.AdminID) - if err != nil { - return nil, err - } - - return admin, nil -} - -// handleAdminCheck checks if admin is authenticated -func (s *Server) handleAdminCheck(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "authenticated": false, - }) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "authenticated": true, - "username": admin.Username, - "role": admin.Role, - }) -} - -// Client management handlers - -// handleAdminGetClients returns all clients -func (s *Server) handleAdminGetClients(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - clients, err := s.db.GetAllClients() - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - // Add usage info for each client - type ClientWithUsage struct { - *ClientConfig - CurrentUsage int64 `json:"current_usage"` - SnapshotCount int `json:"snapshot_count"` - } - - var result []ClientWithUsage - for _, c := range clients { - usage, _ := s.db.GetClientUsage(c.ClientID) - snapshots, _ := s.db.GetSnapshotsByClient(c.ClientID) - result = append(result, ClientWithUsage{ - ClientConfig: c, - CurrentUsage: usage, - SnapshotCount: len(snapshots), - }) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// handleAdminGetClient returns a specific client -func (s *Server) handleAdminGetClient(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - clientID := r.URL.Query().Get("client_id") - if clientID == "" { - http.Error(w, "client_id required", http.StatusBadRequest) - return - } - - client, err := s.db.GetClient(clientID) - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - if client == nil { - http.Error(w, "Client not found", http.StatusNotFound) - return - } - - usage, _ := s.db.GetClientUsage(clientID) - snapshots, _ := s.db.GetSnapshotsByClient(clientID) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "client": client, - "current_usage": usage, - "snapshot_count": len(snapshots), - }) -} - -// handleAdminCreateClient creates a new client -func (s *Server) handleAdminCreateClient(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - ClientID string `json:"client_id"` - APIKey string `json:"api_key"` - MaxSizeBytes int64 `json:"max_size_bytes"` - Dataset string `json:"dataset"` - StorageType string `json:"storage_type"` - Enabled bool `json:"enabled"` - RotationPolicy *RotationPolicy `json:"rotation_policy"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.ClientID == "" || req.APIKey == "" { - http.Error(w, "client_id and api_key required", http.StatusBadRequest) - return - } - - if req.StorageType == "" { - req.StorageType = "s3" - } - - client := &ClientConfig{ - ClientID: req.ClientID, - APIKey: hashAPIKey(req.APIKey), - MaxSizeBytes: req.MaxSizeBytes, - Dataset: req.Dataset, - StorageType: req.StorageType, - Enabled: req.Enabled, - RotationPolicy: req.RotationPolicy, - } - - if err := s.db.SaveClient(client); err != nil { - http.Error(w, "Failed to create client", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Client created successfully", - }) -} - -// handleAdminUpdateClient updates an existing client -func (s *Server) handleAdminUpdateClient(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if r.Method != http.MethodPut && r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - var req struct { - ClientID string `json:"client_id"` - APIKey string `json:"api_key"` - MaxSizeBytes int64 `json:"max_size_bytes"` - Dataset string `json:"dataset"` - StorageType string `json:"storage_type"` - Enabled bool `json:"enabled"` - RotationPolicy *RotationPolicy `json:"rotation_policy"` - } - - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) - return - } - - if req.ClientID == "" { - http.Error(w, "client_id required", http.StatusBadRequest) - return - } - - // Get existing client - existing, err := s.db.GetClient(req.ClientID) - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - if existing == nil { - http.Error(w, "Client not found", http.StatusNotFound) - return - } - - // Update fields - if req.APIKey != "" { - existing.APIKey = hashAPIKey(req.APIKey) - } - if req.MaxSizeBytes > 0 { - existing.MaxSizeBytes = req.MaxSizeBytes - } - if req.Dataset != "" { - existing.Dataset = req.Dataset - } - if req.StorageType != "" { - existing.StorageType = req.StorageType - } - existing.Enabled = req.Enabled - if req.RotationPolicy != nil { - existing.RotationPolicy = req.RotationPolicy - } - - if err := s.db.SaveClient(existing); err != nil { - http.Error(w, "Failed to update client", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Client updated successfully", - }) -} - -// handleAdminDeleteClient deletes a client -func (s *Server) handleAdminDeleteClient(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if r.Method != http.MethodDelete && r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - clientID := r.URL.Query().Get("client_id") - if clientID == "" { - http.Error(w, "client_id required", http.StatusBadRequest) - return - } - - // Delete snapshots first (handled by foreign key cascade) - // Get snapshots to delete from storage - snapshots, _ := s.db.GetSnapshotsByClient(clientID) - for _, snap := range snapshots { - if snap.StorageType == "s3" && s.s3Backend != nil { - s.s3Backend.Delete(context.Background(), snap.StorageKey) - } - } - - if err := s.db.DeleteClient(clientID); err != nil { - http.Error(w, "Failed to delete client", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Client deleted successfully", - }) -} - -// handleAdminGetSnapshots returns all snapshots for a client -func (s *Server) handleAdminGetSnapshots(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - clientID := r.URL.Query().Get("client_id") - var snapshots []*SnapshotMetadata - - if clientID != "" { - snapshots, err = s.db.GetSnapshotsByClient(clientID) - } else { - snapshots, err = s.db.GetAllSnapshots() - } - - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(snapshots) -} - -// handleAdminDeleteSnapshot deletes a specific snapshot -func (s *Server) handleAdminDeleteSnapshot(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if r.Method != http.MethodDelete && r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - clientID := r.URL.Query().Get("client_id") - snapshotID := r.URL.Query().Get("snapshot_id") - - if clientID == "" || snapshotID == "" { - http.Error(w, "client_id and snapshot_id required", http.StatusBadRequest) - return - } - - // Get snapshot to delete from storage - snap, err := s.db.GetSnapshotByID(clientID, snapshotID) - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - if snap != nil { - if snap.StorageType == "s3" && s.s3Backend != nil { - s.s3Backend.Delete(context.Background(), snap.StorageKey) - } - } - - if err := s.db.DeleteSnapshot(clientID, snapshotID); err != nil { - http.Error(w, "Failed to delete snapshot", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "success": true, - "message": "Snapshot deleted successfully", - }) -} - -// handleAdminGetStats returns server statistics -func (s *Server) handleAdminGetStats(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - clients, _ := s.db.GetAllClients() - totalSnapshots, _ := s.db.GetTotalSnapshotCount() - totalStorage, _ := s.db.GetTotalStorageUsed() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "client_count": len(clients), - "total_snapshots": totalSnapshots, - "total_storage": totalStorage, - "total_storage_gb": float64(totalStorage) / (1024 * 1024 * 1024), - }) -} - -// Admin management handlers - -// handleAdminGetAdmins returns all admins -func (s *Server) handleAdminGetAdmins(w http.ResponseWriter, r *http.Request) { - admin, err := s.authenticateAdmin(r) - if err != nil || admin == nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - admins, err := s.db.GetAllAdmins() - if err != nil { - http.Error(w, "Database error", http.StatusInternalServerError) - return - } - - // Remove password hashes from response - type AdminResponse struct { - ID int `json:"id"` - Username string `json:"username"` - Role string `json:"role"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - } - - var result []AdminResponse - for _, a := range admins { - result = append(result, AdminResponse{ - ID: a.ID, - Username: a.Username, - Role: a.Role, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, - }) - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) -} - -// handleAdminUI serves the admin panel UI -func (s *Server) handleAdminUI(w http.ResponseWriter, r *http.Request) { - // Serve the embedded admin UI HTML - w.Header().Set("Content-Type", "text/html") - w.Write([]byte(adminPanelHTML)) -} - // adminPanelHTML is the embedded admin panel UI const adminPanelHTML = ` @@ -586,9 +26,12 @@ const adminPanelHTML = ` .btn-danger:hover { background: #c0392b; } .btn-success { background: #27ae60; } .btn-success:hover { background: #229954; } - .btn-sm { padding: 6px 12px; font-size: 12px; } + .btn-warning { background: #f39c12; } + .btn-warning:hover { background: #d68910; } + .btn-sm { padding: 6px 12px; font-size: 12px; margin: 2px; } .error { color: #e74c3c; margin-bottom: 20px; text-align: center; } - .hidden { display: none; } + .success { color: #27ae60; margin-bottom: 20px; text-align: center; } + .hidden { display: none !important; } .card { background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); margin-bottom: 20px; } .card-header { padding: 20px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; } .card-header h3 { color: #2c3e50; } @@ -605,11 +48,12 @@ const adminPanelHTML = ` .stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; } .stat-card h4 { color: #666; font-size: 14px; margin-bottom: 10px; } .stat-card .value { font-size: 32px; font-weight: bold; color: #2c3e50; } - .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } - .modal-content { background: white; padding: 30px; border-radius: 8px; max-width: 500px; width: 90%; } + .modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; } + .modal-content { background: white; padding: 30px; border-radius: 8px; max-width: 500px; width: 90%; max-height: 90vh; overflow-y: auto; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; } .modal-header h3 { color: #2c3e50; } - .close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: #666; } + .close-btn { background: none; border: none; font-size: 28px; cursor: pointer; color: #666; line-height: 1; } + .close-btn:hover { color: #333; } .tabs { display: flex; gap: 10px; margin-bottom: 20px; } .tab { padding: 10px 20px; background: white; border: none; border-radius: 4px; cursor: pointer; color: #666; } .tab.active { background: #3498db; color: white; } @@ -619,26 +63,27 @@ const adminPanelHTML = `
-

🔐 Admin Login

+

Admin Login

- +
- +
+

Default: admin / admin123