Files
zfs/internal/server/admin.go
2026-02-13 19:44:00 +01:00

1213 lines
42 KiB
Go

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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZFS Backup Admin Panel</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: #f5f5f5; min-height: 100vh; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.header { background: #2c3e50; color: white; padding: 20px; margin-bottom: 20px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 24px; }
.header button { background: #e74c3c; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
.header button:hover { background: #c0392b; }
.login-container { max-width: 400px; margin: 100px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.login-container h2 { text-align: center; margin-bottom: 30px; color: #2c3e50; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; color: #666; }
.form-group input, .form-group select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.btn { background: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn:hover { background: #2980b9; }
.btn-danger { background: #e74c3c; }
.btn-danger:hover { background: #c0392b; }
.btn-success { background: #27ae60; }
.btn-success:hover { background: #229954; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
.error { color: #e74c3c; margin-bottom: 20px; text-align: center; }
.hidden { display: none; }
.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; }
.card-body { padding: 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #eee; }
th { background: #f8f9fa; font-weight: 600; color: #666; }
tr:hover { background: #f8f9fa; }
.badge { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
.badge-success { background: #d4edda; color: #155724; }
.badge-danger { background: #f8d7da; color: #721c24; }
.badge-info { background: #d1ecf1; color: #0c5460; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 20px; }
.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-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; }
.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; }
.progress { background: #ecf0f1; border-radius: 4px; height: 8px; overflow: hidden; }
.progress-bar { background: #3498db; height: 100%; transition: width 0.3s; }
</style>
</head>
<body>
<div id="login-page" class="login-container">
<h2>🔐 Admin Login</h2>
<div id="login-error" class="error hidden"></div>
<form id="login-form">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
</form>
</div>
<div id="admin-page" class="container hidden">
<div class="header">
<h1>📦 ZFS Backup Admin Panel</h1>
<div>
<span id="admin-user"></span>
<button onclick="logout()">Logout</button>
</div>
</div>
<div class="stats-grid" id="stats-grid"></div>
<div class="tabs">
<button class="tab active" onclick="showTab('clients')">Clients</button>
<button class="tab" onclick="showTab('snapshots')">Snapshots</button>
<button class="tab" onclick="showTab('admins')">Admins</button>
</div>
<div id="clients-tab">
<div class="card">
<div class="card-header">
<h3>Clients</h3>
<button class="btn btn-success" onclick="showAddClientModal()">+ Add Client</button>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Client ID</th>
<th>Storage Type</th>
<th>Quota</th>
<th>Used</th>
<th>Snapshots</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="clients-table"></tbody>
</table>
</div>
</div>
</div>
<div id="snapshots-tab" class="hidden">
<div class="card">
<div class="card-header">
<h3>Snapshots</h3>
<select id="snapshot-client-filter" onchange="loadSnapshots()">
<option value="">All Clients</option>
</select>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>Client</th>
<th>Snapshot ID</th>
<th>Timestamp</th>
<th>Size</th>
<th>Type</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="snapshots-table"></tbody>
</table>
</div>
</div>
</div>
<div id="admins-tab" class="hidden">
<div class="card">
<div class="card-header">
<h3>Admin Users</h3>
<button class="btn btn-success" onclick="showAddAdminModal()">+ Add Admin</button>
</div>
<div class="card-body">
<table>
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="admins-table"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Add Client Modal -->
<div id="add-client-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add New Client</h3>
<button class="close-btn" onclick="closeModal('add-client-modal')">&times;</button>
</div>
<form id="add-client-form">
<div class="form-group">
<label>Client ID</label>
<input type="text" id="new-client-id" required>
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" id="new-client-apikey" required>
</div>
<div class="form-group">
<label>Storage Type</label>
<select id="new-client-storage">
<option value="s3">S3</option>
<option value="local">Local ZFS</option>
</select>
</div>
<div class="form-group">
<label>Target Dataset (for local storage)</label>
<input type="text" id="new-client-dataset" placeholder="backup/client1">
</div>
<div class="form-group">
<label>Quota (GB)</label>
<input type="number" id="new-client-quota" value="100">
</div>
<div class="form-group">
<label>Enabled</label>
<input type="checkbox" id="new-client-enabled" checked>
</div>
<h4 style="margin: 20px 0 10px; color: #666;">Rotation Policy</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div class="form-group">
<label>Keep Hourly</label>
<input type="number" id="new-client-hourly" value="24">
</div>
<div class="form-group">
<label>Keep Daily</label>
<input type="number" id="new-client-daily" value="7">
</div>
<div class="form-group">
<label>Keep Weekly</label>
<input type="number" id="new-client-weekly" value="4">
</div>
<div class="form-group">
<label>Keep Monthly</label>
<input type="number" id="new-client-monthly" value="12">
</div>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; margin-top: 20px;">Create Client</button>
</form>
</div>
</div>
<!-- Add Admin Modal -->
<div id="add-admin-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Add New Admin</h3>
<button class="close-btn" onclick="closeModal('add-admin-modal')">&times;</button>
</div>
<form id="add-admin-form">
<div class="form-group">
<label>Username</label>
<input type="text" id="new-admin-username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="new-admin-password" required>
</div>
<div class="form-group">
<label>Role</label>
<select id="new-admin-role">
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="btn btn-success" style="width: 100%; margin-top: 20px;">Create Admin</button>
</form>
</div>
</div>
<script>
let currentTab = 'clients';
// Check authentication on load
async function checkAuth() {
try {
const res = await fetch('/admin/check');
const data = await res.json();
if (data.authenticated) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('admin-page').classList.remove('hidden');
document.getElementById('admin-user').textContent = data.username;
loadStats();
loadClients();
}
} catch (e) {
console.error('Auth check failed:', e);
}
}
// Login
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const res = await fetch('/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (data.success) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('admin-page').classList.remove('hidden');
document.getElementById('admin-user').textContent = username;
loadStats();
loadClients();
} else {
document.getElementById('login-error').textContent = data.message || 'Login failed';
document.getElementById('login-error').classList.remove('hidden');
}
} catch (e) {
document.getElementById('login-error').textContent = 'Connection error';
document.getElementById('login-error').classList.remove('hidden');
}
});
// Logout
async function logout() {
await fetch('/admin/logout', { method: 'POST' });
location.reload();
}
// Load stats
async function loadStats() {
try {
const res = await fetch('/admin/stats');
const data = await res.json();
document.getElementById('stats-grid').innerHTML =
'<div class="stat-card"><h4>Clients</h4><div class="value">' + data.client_count + '</div></div>' +
'<div class="stat-card"><h4>Total Snapshots</h4><div class="value">' + data.total_snapshots + '</div></div>' +
'<div class="stat-card"><h4>Total Storage</h4><div class="value">' + data.total_storage_gb.toFixed(2) + ' GB</div></div>';
} catch (e) {
console.error('Failed to load stats:', e);
}
}
// Load clients
async function loadClients() {
try {
const res = await fetch('/admin/clients');
const clients = await res.json();
const tbody = document.getElementById('clients-table');
tbody.innerHTML = clients.map(c => {
const usedPercent = (c.current_usage / c.max_size_bytes * 100).toFixed(1);
const usedGB = (c.current_usage / (1024*1024*1024)).toFixed(2);
const maxGB = (c.max_size_bytes / (1024*1024*1024)).toFixed(0);
return '<tr>' +
'<td><strong>' + c.client_id + '</strong></td>' +
'<td><span class="badge badge-info">' + c.storage_type + '</span></td>' +
'<td>' + maxGB + ' GB</td>' +
'<td>' +
'<div>' + usedGB + ' GB (' + usedPercent + '%)</div>' +
'<div class="progress" style="width: 100px; margin-top: 4px;"><div class="progress-bar" style="width: ' + Math.min(usedPercent, 100) + '%;"></div></div>' +
'</td>' +
'<td>' + c.snapshot_count + '</td>' +
'<td>' + (c.enabled ? '<span class="badge badge-success">Enabled</span>' : '<span class="badge badge-danger">Disabled</span>') + '</td>' +
'<td>' +
'<button class="btn btn-sm btn-danger" onclick="deleteClient(\'' + c.client_id + '\')">Delete</button>' +
'</td>' +
'</tr>';
}).join('');
// Update snapshot filter
const filter = document.getElementById('snapshot-client-filter');
filter.innerHTML = '<option value="">All Clients</option>' +
clients.map(c => '<option value="' + c.client_id + '">' + c.client_id + '</option>').join('');
} catch (e) {
console.error('Failed to load clients:', e);
}
}
// Load snapshots
async function loadSnapshots() {
const clientId = document.getElementById('snapshot-client-filter').value;
const url = '/admin/snapshots' + (clientId ? '?client_id=' + clientId : '');
try {
const res = await fetch(url);
const snapshots = await res.json();
const tbody = document.getElementById('snapshots-table');
tbody.innerHTML = snapshots.map(s => {
const sizeGB = (s.size_bytes / (1024*1024*1024)).toFixed(2);
return '<tr>' +
'<td>' + s.client_id + '</td>' +
'<td>' + s.snapshot_id + '</td>' +
'<td>' + new Date(s.timestamp).toLocaleString() + '</td>' +
'<td>' + sizeGB + ' GB</td>' +
'<td>' +
(s.incremental ? '<span class="badge badge-info">Incremental</span>' : '<span class="badge badge-success">Full</span>') +
(s.compressed ? ' <span class="badge badge-info">Compressed</span>' : '') +
'</td>' +
'<td><button class="btn btn-sm btn-danger" onclick="deleteSnapshot(\'' + s.client_id + '\', \'' + s.snapshot_id + '\')">Delete</button></td>' +
'</tr>';
}).join('');
} catch (e) {
console.error('Failed to load snapshots:', e);
}
}
// Load admins
async function loadAdmins() {
try {
const res = await fetch('/admin/admins');
const admins = await res.json();
const tbody = document.getElementById('admins-table');
tbody.innerHTML = admins.map(a =>
'<tr>' +
'<td>' + a.id + '</td>' +
'<td><strong>' + a.username + '</strong></td>' +
'<td><span class="badge badge-info">' + a.role + '</span></td>' +
'<td>' + new Date(a.created_at).toLocaleDateString() + '</td>' +
'<td><button class="btn btn-sm btn-danger" onclick="deleteAdmin(' + a.id + ')">Delete</button></td>' +
'</tr>'
).join('');
} catch (e) {
console.error('Failed to load admins:', e);
}
}
// Tab switching
function showTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('clients-tab').classList.add('hidden');
document.getElementById('snapshots-tab').classList.add('hidden');
document.getElementById('admins-tab').classList.add('hidden');
document.getElementById(tab + '-tab').classList.remove('hidden');
if (tab === 'snapshots') loadSnapshots();
if (tab === 'admins') loadAdmins();
}
// Modal functions
function showAddClientModal() {
document.getElementById('add-client-modal').classList.remove('hidden');
}
function showAddAdminModal() {
document.getElementById('add-admin-modal').classList.remove('hidden');
}
function closeModal(id) {
document.getElementById(id).classList.add('hidden');
}
// Add client
document.getElementById('add-client-form').addEventListener('submit', async (e) => {
e.preventDefault();
const client = {
client_id: document.getElementById('new-client-id').value,
api_key: document.getElementById('new-client-apikey').value,
storage_type: document.getElementById('new-client-storage').value,
dataset: document.getElementById('new-client-dataset').value,
max_size_bytes: parseInt(document.getElementById('new-client-quota').value) * 1024 * 1024 * 1024,
enabled: document.getElementById('new-client-enabled').checked,
rotation_policy: {
keep_hourly: parseInt(document.getElementById('new-client-hourly').value),
keep_daily: parseInt(document.getElementById('new-client-daily').value),
keep_weekly: parseInt(document.getElementById('new-client-weekly').value),
keep_monthly: parseInt(document.getElementById('new-client-monthly').value)
}
};
try {
const res = await fetch('/admin/client/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(client)
});
const data = await res.json();
if (data.success) {
closeModal('add-client-modal');
loadClients();
loadStats();
} else {
alert(data.message || 'Failed to create client');
}
} catch (e) {
alert('Failed to create client');
}
});
// Add admin
document.getElementById('add-admin-form').addEventListener('submit', async (e) => {
e.preventDefault();
const admin = {
username: document.getElementById('new-admin-username').value,
password: document.getElementById('new-admin-password').value,
role: document.getElementById('new-admin-role').value
};
try {
const res = await fetch('/admin/admin/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(admin)
});
const data = await res.json();
if (data.success) {
closeModal('add-admin-modal');
loadAdmins();
} else {
alert(data.message || 'Failed to create admin');
}
} catch (e) {
alert('Failed to create admin');
}
});
// Delete functions
async function deleteClient(clientId) {
if (!confirm('Are you sure you want to delete client "' + clientId + '" and all its snapshots?')) return;
try {
const res = await fetch('/admin/client/delete?client_id=' + clientId, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadClients();
loadStats();
} else {
alert(data.message || 'Failed to delete client');
}
} catch (e) {
alert('Failed to delete client');
}
}
async function deleteSnapshot(clientId, snapshotId) {
if (!confirm('Are you sure you want to delete snapshot "' + snapshotId + '"?')) return;
try {
const res = await fetch('/admin/snapshot/delete?client_id=' + clientId + '&snapshot_id=' + snapshotId, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadSnapshots();
loadStats();
} else {
alert(data.message || 'Failed to delete snapshot');
}
} catch (e) {
alert('Failed to delete snapshot');
}
}
async function deleteAdmin(id) {
if (!confirm('Are you sure you want to delete this admin?')) return;
try {
const res = await fetch('/admin/admin/delete?id=' + id, { method: 'POST' });
const data = await res.json();
if (data.success) {
loadAdmins();
} else {
alert(data.message || 'Failed to delete admin');
}
} catch (e) {
alert('Failed to delete admin');
}
}
// Initialize
checkAuth();
</script>
</body>
</html>`
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
if admin != nil {
var adminID int
fmt.Sscanf(id, "%d", &adminID)
if adminID == admin.ID {
http.Error(w, "Cannot delete yourself", http.StatusBadRequest)
return
}
}
if err := s.db.DeleteAdmin(0); err != nil {
// Parse id as int
var adminID int
fmt.Sscanf(id, "%d", &adminID)
if err := s.db.DeleteAdmin(adminID); 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",
})
}