add admin panel
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// Config holds client-side configuration for connecting to the backup server.
|
||||
// Note: Storage type is determined by the server, not the client.
|
||||
type Config struct {
|
||||
// ClientID is the unique identifier for this client
|
||||
ClientID string `json:"client_id"`
|
||||
@@ -20,8 +21,6 @@ type Config struct {
|
||||
LocalDataset string `json:"local_dataset"`
|
||||
// Compress enables gzip compression for transfers
|
||||
Compress bool `json:"compress"`
|
||||
// StorageType specifies the storage backend ("s3" or "local")
|
||||
StorageType string `json:"storage_type"`
|
||||
}
|
||||
|
||||
// LoadConfig loads client configuration from environment variables and .env file.
|
||||
@@ -36,7 +35,6 @@ func LoadConfig() *Config {
|
||||
ServerURL: getEnv("SERVER_URL", "http://backup-server:8080"),
|
||||
LocalDataset: getEnv("LOCAL_DATASET", "tank/data"),
|
||||
Compress: getEnv("COMPRESS", "true") == "true",
|
||||
StorageType: getEnv("STORAGE_TYPE", "s3"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1212
internal/server/admin.go
Normal file
1212
internal/server/admin.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,7 @@ type Config struct {
|
||||
S3UseSSL bool
|
||||
S3Enabled bool // Enable/disable S3 backend
|
||||
BaseDataset string
|
||||
ConfigFile string
|
||||
MetadataFile string
|
||||
DatabasePath string // Path to SQLite database
|
||||
Port string
|
||||
}
|
||||
|
||||
@@ -42,8 +41,7 @@ func LoadConfig() *Config {
|
||||
S3UseSSL: getEnv("S3_USE_SSL", "true") != "false",
|
||||
S3Enabled: s3Enabled,
|
||||
BaseDataset: getEnv("ZFS_BASE_DATASET", "backup"),
|
||||
ConfigFile: getEnv("CONFIG_FILE", "clients.json"),
|
||||
MetadataFile: getEnv("METADATA_FILE", "metadata.json"),
|
||||
DatabasePath: getEnv("DATABASE_PATH", "zfs-backup.db"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
}
|
||||
}
|
||||
|
||||
654
internal/server/database.go
Normal file
654
internal/server/database.go
Normal file
@@ -0,0 +1,654 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Database handles SQLite operations for the server
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewDatabase creates a new database connection and initializes tables
|
||||
func NewDatabase(dbPath string) (*Database, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
// Enable foreign keys
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
database := &Database{db: db}
|
||||
if err := database.initTables(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to initialize tables: %v", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
// initTables creates the database tables if they don't exist
|
||||
func (d *Database) initTables() error {
|
||||
// Admins table
|
||||
_, err := d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create admins table: %v", err)
|
||||
}
|
||||
|
||||
// Admin sessions table
|
||||
_, err = d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS admin_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id INTEGER NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create admin_sessions table: %v", err)
|
||||
}
|
||||
|
||||
// Clients table
|
||||
_, err = d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id TEXT PRIMARY KEY,
|
||||
api_key TEXT NOT NULL,
|
||||
max_size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dataset TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
storage_type TEXT NOT NULL DEFAULT 's3',
|
||||
keep_hourly INTEGER DEFAULT 24,
|
||||
keep_daily INTEGER DEFAULT 7,
|
||||
keep_weekly INTEGER DEFAULT 4,
|
||||
keep_monthly INTEGER DEFAULT 12,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create clients table: %v", err)
|
||||
}
|
||||
|
||||
// Snapshots table
|
||||
_, err = d.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
client_id TEXT NOT NULL,
|
||||
snapshot_id TEXT NOT NULL,
|
||||
timestamp DATETIME NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
dataset_name TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
storage_type TEXT NOT NULL,
|
||||
compressed INTEGER NOT NULL DEFAULT 0,
|
||||
incremental INTEGER NOT NULL DEFAULT 0,
|
||||
base_snapshot TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (client_id) REFERENCES clients(client_id) ON DELETE CASCADE,
|
||||
UNIQUE(client_id, snapshot_id)
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create snapshots table: %v", err)
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
_, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_snapshots_client_id ON snapshots(client_id)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create index: %v", err)
|
||||
}
|
||||
|
||||
_, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_snapshots_timestamp ON snapshots(timestamp)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create timestamp index: %v", err)
|
||||
}
|
||||
|
||||
_, err = d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_admin_sessions_token ON admin_sessions(token)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create admin_sessions index: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// Client operations
|
||||
|
||||
// GetClient retrieves a client by ID
|
||||
func (d *Database) GetClient(clientID string) (*ClientConfig, error) {
|
||||
client := &ClientConfig{}
|
||||
var enabled int
|
||||
var keepHourly, keepDaily, keepWeekly, keepMonthly sql.NullInt64
|
||||
|
||||
query := `SELECT client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
|
||||
keep_hourly, keep_daily, keep_weekly, keep_monthly
|
||||
FROM clients WHERE client_id = ?`
|
||||
|
||||
err := d.db.QueryRow(query, clientID).Scan(
|
||||
&client.ClientID, &client.APIKey, &client.MaxSizeBytes, &client.Dataset,
|
||||
&enabled, &client.StorageType,
|
||||
&keepHourly, &keepDaily, &keepWeekly, &keepMonthly,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Enabled = enabled == 1
|
||||
|
||||
// Set rotation policy if any values are set
|
||||
if keepHourly.Valid || keepDaily.Valid || keepWeekly.Valid || keepMonthly.Valid {
|
||||
client.RotationPolicy = &RotationPolicy{
|
||||
KeepHourly: int(keepHourly.Int64),
|
||||
KeepDaily: int(keepDaily.Int64),
|
||||
KeepWeekly: int(keepWeekly.Int64),
|
||||
KeepMonthly: int(keepMonthly.Int64),
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetAllClients retrieves all clients
|
||||
func (d *Database) GetAllClients() ([]*ClientConfig, error) {
|
||||
query := `SELECT client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
|
||||
keep_hourly, keep_daily, keep_weekly, keep_monthly
|
||||
FROM clients`
|
||||
|
||||
rows, err := d.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []*ClientConfig
|
||||
for rows.Next() {
|
||||
client := &ClientConfig{}
|
||||
var enabled int
|
||||
var keepHourly, keepDaily, keepWeekly, keepMonthly sql.NullInt64
|
||||
|
||||
err := rows.Scan(
|
||||
&client.ClientID, &client.APIKey, &client.MaxSizeBytes, &client.Dataset,
|
||||
&enabled, &client.StorageType,
|
||||
&keepHourly, &keepDaily, &keepWeekly, &keepMonthly,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Enabled = enabled == 1
|
||||
|
||||
if keepHourly.Valid || keepDaily.Valid || keepWeekly.Valid || keepMonthly.Valid {
|
||||
client.RotationPolicy = &RotationPolicy{
|
||||
KeepHourly: int(keepHourly.Int64),
|
||||
KeepDaily: int(keepDaily.Int64),
|
||||
KeepWeekly: int(keepWeekly.Int64),
|
||||
KeepMonthly: int(keepMonthly.Int64),
|
||||
}
|
||||
}
|
||||
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// SaveClient saves or updates a client
|
||||
func (d *Database) SaveClient(client *ClientConfig) error {
|
||||
var keepHourly, keepDaily, keepWeekly, keepMonthly interface{}
|
||||
if client.RotationPolicy != nil {
|
||||
keepHourly = client.RotationPolicy.KeepHourly
|
||||
keepDaily = client.RotationPolicy.KeepDaily
|
||||
keepWeekly = client.RotationPolicy.KeepWeekly
|
||||
keepMonthly = client.RotationPolicy.KeepMonthly
|
||||
}
|
||||
|
||||
enabled := 0
|
||||
if client.Enabled {
|
||||
enabled = 1
|
||||
}
|
||||
|
||||
query := `INSERT OR REPLACE INTO clients
|
||||
(client_id, api_key, max_size_bytes, dataset, enabled, storage_type,
|
||||
keep_hourly, keep_daily, keep_weekly, keep_monthly, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`
|
||||
|
||||
_, err := d.db.Exec(query,
|
||||
client.ClientID, client.APIKey, client.MaxSizeBytes, client.Dataset,
|
||||
enabled, client.StorageType,
|
||||
keepHourly, keepDaily, keepWeekly, keepMonthly,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// Snapshot operations
|
||||
|
||||
// SaveSnapshot saves a new snapshot record
|
||||
func (d *Database) SaveSnapshot(metadata *SnapshotMetadata) error {
|
||||
compressed := 0
|
||||
if metadata.Compressed {
|
||||
compressed = 1
|
||||
}
|
||||
incremental := 0
|
||||
if metadata.Incremental {
|
||||
incremental = 1
|
||||
}
|
||||
|
||||
query := `INSERT INTO snapshots
|
||||
(client_id, snapshot_id, timestamp, size_bytes, dataset_name,
|
||||
storage_key, storage_type, compressed, incremental, base_snapshot)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := d.db.Exec(query,
|
||||
metadata.ClientID, metadata.SnapshotID, metadata.Timestamp, metadata.SizeBytes,
|
||||
metadata.DatasetName, metadata.StorageKey, metadata.StorageType,
|
||||
compressed, incremental, metadata.BaseSnapshot,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSnapshotsByClient retrieves all snapshots for a client
|
||||
func (d *Database) GetSnapshotsByClient(clientID string) ([]*SnapshotMetadata, error) {
|
||||
query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
|
||||
storage_key, storage_type, compressed, incremental, base_snapshot
|
||||
FROM snapshots WHERE client_id = ? ORDER BY timestamp DESC`
|
||||
|
||||
rows, err := d.db.Query(query, clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snapshots []*SnapshotMetadata
|
||||
for rows.Next() {
|
||||
snap := &SnapshotMetadata{}
|
||||
var compressed, incremental int
|
||||
var baseSnapshot sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
|
||||
&snap.DatasetName, &snap.StorageKey, &snap.StorageType,
|
||||
&compressed, &incremental, &baseSnapshot,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap.Compressed = compressed == 1
|
||||
snap.Incremental = incremental == 1
|
||||
if baseSnapshot.Valid {
|
||||
snap.BaseSnapshot = baseSnapshot.String
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// GetClientUsage calculates total storage used by a client
|
||||
func (d *Database) GetClientUsage(clientID string) (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := d.db.QueryRow(`SELECT SUM(size_bytes) FROM snapshots WHERE client_id = ?`, clientID).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !total.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
// DeleteSnapshot deletes a snapshot record
|
||||
func (d *Database) DeleteSnapshot(clientID, snapshotID string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM snapshots WHERE client_id = ? AND snapshot_id = ?`, clientID, snapshotID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetOldestSnapshots gets the oldest snapshots for a client (for rotation)
|
||||
func (d *Database) GetOldestSnapshots(clientID string, limit int) ([]*SnapshotMetadata, error) {
|
||||
query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
|
||||
storage_key, storage_type, compressed, incremental, base_snapshot
|
||||
FROM snapshots WHERE client_id = ? ORDER BY timestamp ASC LIMIT ?`
|
||||
|
||||
rows, err := d.db.Query(query, clientID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snapshots []*SnapshotMetadata
|
||||
for rows.Next() {
|
||||
snap := &SnapshotMetadata{}
|
||||
var compressed, incremental int
|
||||
var baseSnapshot sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
|
||||
&snap.DatasetName, &snap.StorageKey, &snap.StorageType,
|
||||
&compressed, &incremental, &baseSnapshot,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap.Compressed = compressed == 1
|
||||
snap.Incremental = incremental == 1
|
||||
if baseSnapshot.Valid {
|
||||
snap.BaseSnapshot = baseSnapshot.String
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// CreateDefaultClient creates a default client if none exists
|
||||
func (d *Database) CreateDefaultClient() error {
|
||||
clients, err := d.GetAllClients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(clients) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("No clients found, creating default client 'client1'")
|
||||
defaultClient := &ClientConfig{
|
||||
ClientID: "client1",
|
||||
APIKey: hashAPIKey("secret123"),
|
||||
MaxSizeBytes: 100 * 1024 * 1024 * 1024, // 100GB
|
||||
Dataset: "backup/client1",
|
||||
Enabled: true,
|
||||
StorageType: "s3",
|
||||
RotationPolicy: &RotationPolicy{
|
||||
KeepHourly: 24,
|
||||
KeepDaily: 7,
|
||||
KeepWeekly: 4,
|
||||
KeepMonthly: 12,
|
||||
},
|
||||
}
|
||||
|
||||
return d.SaveClient(defaultClient)
|
||||
}
|
||||
|
||||
// GetSnapshotByID retrieves a specific snapshot
|
||||
func (d *Database) GetSnapshotByID(clientID, snapshotID string) (*SnapshotMetadata, error) {
|
||||
snap := &SnapshotMetadata{}
|
||||
var compressed, incremental int
|
||||
var baseSnapshot sql.NullString
|
||||
|
||||
query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
|
||||
storage_key, storage_type, compressed, incremental, base_snapshot
|
||||
FROM snapshots WHERE client_id = ? AND snapshot_id = ?`
|
||||
|
||||
err := d.db.QueryRow(query, clientID, snapshotID).Scan(
|
||||
&snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
|
||||
&snap.DatasetName, &snap.StorageKey, &snap.StorageType,
|
||||
&compressed, &incremental, &baseSnapshot,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap.Compressed = compressed == 1
|
||||
snap.Incremental = incremental == 1
|
||||
if baseSnapshot.Valid {
|
||||
snap.BaseSnapshot = baseSnapshot.String
|
||||
}
|
||||
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// GetAllSnapshots retrieves all snapshots (for admin purposes)
|
||||
func (d *Database) GetAllSnapshots() ([]*SnapshotMetadata, error) {
|
||||
query := `SELECT client_id, snapshot_id, timestamp, size_bytes, dataset_name,
|
||||
storage_key, storage_type, compressed, incremental, base_snapshot
|
||||
FROM snapshots ORDER BY timestamp DESC`
|
||||
|
||||
rows, err := d.db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var snapshots []*SnapshotMetadata
|
||||
for rows.Next() {
|
||||
snap := &SnapshotMetadata{}
|
||||
var compressed, incremental int
|
||||
var baseSnapshot sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&snap.ClientID, &snap.SnapshotID, &snap.Timestamp, &snap.SizeBytes,
|
||||
&snap.DatasetName, &snap.StorageKey, &snap.StorageType,
|
||||
&compressed, &incremental, &baseSnapshot,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snap.Compressed = compressed == 1
|
||||
snap.Incremental = incremental == 1
|
||||
if baseSnapshot.Valid {
|
||||
snap.BaseSnapshot = baseSnapshot.String
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snap)
|
||||
}
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// GetTotalSnapshotCount returns the total number of snapshots
|
||||
func (d *Database) GetTotalSnapshotCount() (int, error) {
|
||||
var count int
|
||||
err := d.db.QueryRow(`SELECT COUNT(*) FROM snapshots`).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetTotalStorageUsed returns the total storage used across all clients
|
||||
func (d *Database) GetTotalStorageUsed() (int64, error) {
|
||||
var total sql.NullInt64
|
||||
err := d.db.QueryRow(`SELECT SUM(size_bytes) FROM snapshots`).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !total.Valid {
|
||||
return 0, nil
|
||||
}
|
||||
return total.Int64, nil
|
||||
}
|
||||
|
||||
// UpdateSnapshotTimestamp updates the timestamp of a snapshot (for rotation tracking)
|
||||
func (d *Database) UpdateSnapshotTimestamp(clientID, snapshotID string, timestamp time.Time) error {
|
||||
_, err := d.db.Exec(`UPDATE snapshots SET timestamp = ? WHERE client_id = ? AND snapshot_id = ?`,
|
||||
timestamp, clientID, snapshotID)
|
||||
return err
|
||||
}
|
||||
|
||||
// Admin operations
|
||||
|
||||
// Admin represents an admin user
|
||||
type Admin struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AdminSession represents an admin session
|
||||
type AdminSession struct {
|
||||
ID int
|
||||
AdminID int
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateAdmin creates a new admin user
|
||||
func (d *Database) CreateAdmin(username, passwordHash, role string) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO admins (username, password_hash, role) VALUES (?, ?, ?)
|
||||
`, username, passwordHash, role)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAdminByUsername retrieves an admin by username
|
||||
func (d *Database) GetAdminByUsername(username string) (*Admin, error) {
|
||||
admin := &Admin{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, username, password_hash, role, created_at, updated_at
|
||||
FROM admins WHERE username = ?
|
||||
`, username).Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
// GetAdminByID retrieves an admin by ID
|
||||
func (d *Database) GetAdminByID(id int) (*Admin, error) {
|
||||
admin := &Admin{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, username, password_hash, role, created_at, updated_at
|
||||
FROM admins WHERE id = ?
|
||||
`, id).Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return admin, nil
|
||||
}
|
||||
|
||||
// CreateSession creates a new admin session
|
||||
func (d *Database) CreateSession(adminID int, token string, expiresAt time.Time) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO admin_sessions (admin_id, token, expires_at) VALUES (?, ?, ?)
|
||||
`, adminID, token, expiresAt)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSessionByToken retrieves a session by token
|
||||
func (d *Database) GetSessionByToken(token string) (*AdminSession, error) {
|
||||
session := &AdminSession{}
|
||||
err := d.db.QueryRow(`
|
||||
SELECT id, admin_id, token, expires_at, created_at
|
||||
FROM admin_sessions WHERE token = ?
|
||||
`, token).Scan(&session.ID, &session.AdminID, &session.Token, &session.ExpiresAt, &session.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// DeleteSession deletes a session (logout)
|
||||
func (d *Database) DeleteSession(token string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM admin_sessions WHERE token = ?`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// CleanExpiredSessions removes all expired sessions
|
||||
func (d *Database) CleanExpiredSessions() error {
|
||||
_, err := d.db.Exec(`DELETE FROM admin_sessions WHERE expires_at < CURRENT_TIMESTAMP`)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateDefaultAdmin creates a default admin if none exists
|
||||
func (d *Database) CreateDefaultAdmin() error {
|
||||
var count int
|
||||
err := d.db.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&count)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("No admins found, creating default admin 'admin'")
|
||||
// Default password: admin123
|
||||
defaultPasswordHash := hashAPIKey("admin123")
|
||||
return d.CreateAdmin("admin", defaultPasswordHash, "admin")
|
||||
}
|
||||
|
||||
// DeleteClient deletes a client and all its snapshots
|
||||
func (d *Database) DeleteClient(clientID string) error {
|
||||
_, err := d.db.Exec(`DELETE FROM clients WHERE client_id = ?`, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAllAdmins retrieves all admins
|
||||
func (d *Database) GetAllAdmins() ([]*Admin, error) {
|
||||
rows, err := d.db.Query(`
|
||||
SELECT id, username, password_hash, role, created_at, updated_at
|
||||
FROM admins ORDER BY id
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var admins []*Admin
|
||||
for rows.Next() {
|
||||
admin := &Admin{}
|
||||
err := rows.Scan(&admin.ID, &admin.Username, &admin.PasswordHash, &admin.Role, &admin.CreatedAt, &admin.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admins = append(admins, admin)
|
||||
}
|
||||
return admins, nil
|
||||
}
|
||||
|
||||
// DeleteAdmin deletes an admin by ID
|
||||
func (d *Database) DeleteAdmin(id int) error {
|
||||
_, err := d.db.Exec(`DELETE FROM admins WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateAdminPassword updates an admin's password
|
||||
func (d *Database) UpdateAdminPassword(id int, passwordHash string) error {
|
||||
_, err := d.db.Exec(`UPDATE admins SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, passwordHash, id)
|
||||
return err
|
||||
}
|
||||
@@ -9,131 +9,60 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server manages snapshots from multiple clients with S3 support
|
||||
type Server struct {
|
||||
clients map[string]*ClientConfig
|
||||
snapshots map[string][]*SnapshotMetadata
|
||||
mu sync.RWMutex
|
||||
db *Database
|
||||
s3Backend *S3Backend
|
||||
localBackend *LocalBackend
|
||||
metadataFile string
|
||||
configFile string
|
||||
}
|
||||
|
||||
// New creates a new snapshot server
|
||||
func New(configFile, metadataFile string, s3Backend *S3Backend, localBackend *LocalBackend) *Server {
|
||||
// New creates a new snapshot server with SQLite database
|
||||
func New(dbPath string, s3Backend *S3Backend, localBackend *LocalBackend) (*Server, error) {
|
||||
db, err := NewDatabase(dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize database: %v", err)
|
||||
}
|
||||
|
||||
// Create default client if none exists
|
||||
if err := db.CreateDefaultClient(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to create default client: %v", err)
|
||||
}
|
||||
|
||||
// Create default admin if none exists
|
||||
if err := db.CreateDefaultAdmin(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("failed to create default admin: %v", err)
|
||||
}
|
||||
|
||||
// Clean expired sessions
|
||||
db.CleanExpiredSessions()
|
||||
|
||||
s := &Server{
|
||||
clients: make(map[string]*ClientConfig),
|
||||
snapshots: make(map[string][]*SnapshotMetadata),
|
||||
db: db,
|
||||
s3Backend: s3Backend,
|
||||
localBackend: localBackend,
|
||||
metadataFile: metadataFile,
|
||||
configFile: configFile,
|
||||
}
|
||||
|
||||
s.loadConfig()
|
||||
s.loadMetadata()
|
||||
|
||||
return s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) loadConfig() {
|
||||
data, err := os.ReadFile(s.configFile)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Could not read config file: %v", err)
|
||||
// Create default config
|
||||
s.clients["client1"] = &ClientConfig{
|
||||
ClientID: "client1",
|
||||
APIKey: hashAPIKey("secret123"),
|
||||
MaxSizeBytes: 100 * 1024 * 1024 * 1024,
|
||||
Dataset: "backup/client1",
|
||||
Enabled: true,
|
||||
StorageType: "s3",
|
||||
}
|
||||
s.saveConfig()
|
||||
return
|
||||
}
|
||||
|
||||
var clients []*ClientConfig
|
||||
if err := json.Unmarshal(data, &clients); err != nil {
|
||||
log.Printf("Error parsing config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
s.clients[client.ClientID] = client
|
||||
}
|
||||
|
||||
log.Printf("Loaded %d client configurations", len(s.clients))
|
||||
}
|
||||
|
||||
func (s *Server) saveConfig() {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var clients []*ClientConfig
|
||||
for _, client := range s.clients {
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(clients, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.configFile, data, 0600); err != nil {
|
||||
log.Printf("Error writing config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) loadMetadata() {
|
||||
data, err := os.ReadFile(s.metadataFile)
|
||||
if err != nil {
|
||||
log.Printf("No existing metadata file, starting fresh")
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &s.snapshots); err != nil {
|
||||
log.Printf("Error parsing metadata: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
totalSnapshots := 0
|
||||
for _, snaps := range s.snapshots {
|
||||
totalSnapshots += len(snaps)
|
||||
}
|
||||
log.Printf("Loaded metadata for %d snapshots", totalSnapshots)
|
||||
}
|
||||
|
||||
func (s *Server) saveMetadata() {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
data, err := json.MarshalIndent(s.snapshots, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Error marshaling metadata: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(s.metadataFile, data, 0600); err != nil {
|
||||
log.Printf("Error writing metadata: %v", err)
|
||||
}
|
||||
// Close closes the database connection
|
||||
func (s *Server) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(clientID, apiKey string) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client, exists := s.clients[clientID]
|
||||
if !exists || !client.Enabled {
|
||||
if !client.Enabled {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -141,22 +70,13 @@ func (s *Server) authenticate(clientID, apiKey string) bool {
|
||||
}
|
||||
|
||||
func (s *Server) getClientUsage(clientID string) int64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var total int64
|
||||
for _, snap := range s.snapshots[clientID] {
|
||||
total += snap.SizeBytes
|
||||
}
|
||||
return total
|
||||
usage, _ := s.db.GetClientUsage(clientID)
|
||||
return usage
|
||||
}
|
||||
|
||||
func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
client, exists := s.clients[clientID]
|
||||
if !exists {
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
return false, "Client not found"
|
||||
}
|
||||
|
||||
@@ -171,74 +91,69 @@ func (s *Server) canAcceptSnapshot(clientID string, estimatedSize int64) (bool,
|
||||
}
|
||||
|
||||
func (s *Server) rotateSnapshots(clientID string) (int, int64) {
|
||||
// First pass: collect snapshots to delete while holding lock
|
||||
s.mu.Lock()
|
||||
client, exists := s.clients[clientID]
|
||||
if !exists {
|
||||
s.mu.Unlock()
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
snapshots := s.snapshots[clientID]
|
||||
if len(snapshots) == 0 {
|
||||
s.mu.Unlock()
|
||||
currentUsage := s.getClientUsage(clientID)
|
||||
if currentUsage <= client.MaxSizeBytes {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Sort by timestamp (oldest first)
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
return snapshots[i].Timestamp.Before(snapshots[j].Timestamp)
|
||||
})
|
||||
// Calculate how many bytes we need to free
|
||||
bytesToFree := currentUsage - client.MaxSizeBytes
|
||||
var deletedCount int
|
||||
var reclaimedBytes int64
|
||||
|
||||
currentUsage := int64(0)
|
||||
for _, snap := range snapshots {
|
||||
currentUsage += snap.SizeBytes
|
||||
// Get oldest snapshots and delete until we're under quota
|
||||
snapshots, err := s.db.GetOldestSnapshots(clientID, 100) // Get up to 100 oldest
|
||||
if err != nil {
|
||||
log.Printf("Error getting oldest snapshots: %v", err)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Collect snapshots to delete
|
||||
var toDelete []*SnapshotMetadata
|
||||
for currentUsage > client.MaxSizeBytes && len(snapshots) > 1 {
|
||||
oldest := snapshots[0]
|
||||
toDelete = append(toDelete, oldest)
|
||||
currentUsage -= oldest.SizeBytes
|
||||
snapshots = snapshots[1:]
|
||||
for _, snap := range snapshots {
|
||||
if reclaimedBytes >= bytesToFree {
|
||||
break
|
||||
}
|
||||
toDelete = append(toDelete, snap)
|
||||
reclaimedBytes += snap.SizeBytes
|
||||
}
|
||||
|
||||
// Update state before I/O
|
||||
s.snapshots[clientID] = snapshots
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Select appropriate backend
|
||||
var backend StorageBackend
|
||||
if client.StorageType == "s3" {
|
||||
if s.s3Backend != nil {
|
||||
backend = s.s3Backend
|
||||
} else {
|
||||
} else if s.localBackend != nil {
|
||||
backend = s.localBackend
|
||||
} else {
|
||||
log.Printf("No storage backend available for rotation")
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Second pass: delete without holding lock
|
||||
deletedCount := 0
|
||||
reclaimedBytes := int64(0)
|
||||
// Delete snapshots
|
||||
ctx := context.Background()
|
||||
|
||||
for _, snap := range toDelete {
|
||||
if err := backend.Delete(ctx, snap.StorageKey); err != nil {
|
||||
log.Printf("Error deleting snapshot %s: %v", snap.StorageKey, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.db.DeleteSnapshot(clientID, snap.SnapshotID); err != nil {
|
||||
log.Printf("Error deleting snapshot record %s: %v", snap.SnapshotID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("Rotated out snapshot: %s (freed %d bytes)", snap.StorageKey, snap.SizeBytes)
|
||||
reclaimedBytes += snap.SizeBytes
|
||||
deletedCount++
|
||||
}
|
||||
|
||||
// Save metadata after deletions
|
||||
s.saveMetadata()
|
||||
|
||||
return deletedCount, reclaimedBytes
|
||||
}
|
||||
|
||||
@@ -283,9 +198,14 @@ func (s *Server) HandleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
client := s.clients[req.ClientID]
|
||||
s.mu.RUnlock()
|
||||
client, err := s.db.GetClient(req.ClientID)
|
||||
if err != nil || client == nil {
|
||||
respondJSON(w, http.StatusInternalServerError, UploadResponse{
|
||||
Success: false,
|
||||
Message: "Failed to get client configuration",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02_15:04:05")
|
||||
|
||||
@@ -379,8 +299,7 @@ func (s *Server) HandleUploadStream(w http.ResponseWriter, r *http.Request) {
|
||||
actualSize = size
|
||||
}
|
||||
|
||||
// Save metadata
|
||||
s.mu.Lock()
|
||||
// Save metadata to database
|
||||
metadata := &SnapshotMetadata{
|
||||
ClientID: clientID,
|
||||
SnapshotID: storageKey,
|
||||
@@ -393,10 +312,10 @@ func (s *Server) HandleUploadStream(w http.ResponseWriter, r *http.Request) {
|
||||
Incremental: incrementalStr == "true",
|
||||
BaseSnapshot: baseSnapshot,
|
||||
}
|
||||
s.snapshots[clientID] = append(s.snapshots[clientID], metadata)
|
||||
s.mu.Unlock()
|
||||
|
||||
s.saveMetadata()
|
||||
if err := s.db.SaveSnapshot(metadata); err != nil {
|
||||
log.Printf("Error saving snapshot metadata: %v", err)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
@@ -415,10 +334,17 @@ func (s *Server) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
client := s.clients[clientID]
|
||||
snapshots := s.snapshots[clientID]
|
||||
s.mu.RUnlock()
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
respondJSON(w, http.StatusInternalServerError, StatusResponse{Success: false})
|
||||
return
|
||||
}
|
||||
|
||||
snapshots, err := s.db.GetSnapshotsByClient(clientID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting snapshots: %v", err)
|
||||
snapshots = []*SnapshotMetadata{}
|
||||
}
|
||||
|
||||
usedBytes := s.getClientUsage(clientID)
|
||||
percentUsed := float64(usedBytes) / float64(client.MaxSizeBytes) * 100
|
||||
@@ -477,18 +403,14 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Find snapshot metadata
|
||||
s.mu.RLock()
|
||||
client := s.clients[clientID]
|
||||
var targetSnapshot *SnapshotMetadata
|
||||
for _, snap := range s.snapshots[clientID] {
|
||||
if snap.SnapshotID == snapshotID {
|
||||
targetSnapshot = snap
|
||||
break
|
||||
}
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
http.Error(w, "Client not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
if targetSnapshot == nil {
|
||||
targetSnapshot, err := s.db.GetSnapshotByID(clientID, snapshotID)
|
||||
if err != nil || targetSnapshot == nil {
|
||||
http.Error(w, "Snapshot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -496,10 +418,13 @@ func (s *Server) HandleDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.Background()
|
||||
var backend StorageBackend
|
||||
|
||||
if client.StorageType == "s3" {
|
||||
if client.StorageType == "s3" && s.s3Backend != nil {
|
||||
backend = s.s3Backend
|
||||
} else {
|
||||
} else if s.localBackend != nil {
|
||||
backend = s.localBackend
|
||||
} else {
|
||||
http.Error(w, "No storage backend available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Download from storage
|
||||
@@ -543,11 +468,8 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
client, exists := s.clients[clientID]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
client, err := s.db.GetClient(clientID)
|
||||
if err != nil || client == nil {
|
||||
respondJSON(w, http.StatusNotFound, RotationPolicyResponse{
|
||||
Success: false,
|
||||
Message: "Client not found",
|
||||
@@ -577,6 +499,7 @@ func (s *Server) HandleRotationPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RegisterRoutes registers all HTTP routes
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Client API routes
|
||||
mux.HandleFunc("/upload", s.HandleUpload)
|
||||
mux.HandleFunc("/upload-stream/", s.HandleUploadStream)
|
||||
mux.HandleFunc("/status", s.HandleStatus)
|
||||
@@ -584,6 +507,25 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/download", s.HandleDownload)
|
||||
mux.HandleFunc("/health", s.HandleHealth)
|
||||
mux.HandleFunc("/rotation-policy", s.HandleRotationPolicy)
|
||||
|
||||
// Admin API routes
|
||||
mux.HandleFunc("/admin/login", s.handleAdminLogin)
|
||||
mux.HandleFunc("/admin/logout", s.handleAdminLogout)
|
||||
mux.HandleFunc("/admin/check", s.handleAdminCheck)
|
||||
mux.HandleFunc("/admin/clients", s.handleAdminGetClients)
|
||||
mux.HandleFunc("/admin/client", s.handleAdminGetClient)
|
||||
mux.HandleFunc("/admin/client/create", s.handleAdminCreateClient)
|
||||
mux.HandleFunc("/admin/client/update", s.handleAdminUpdateClient)
|
||||
mux.HandleFunc("/admin/client/delete", s.handleAdminDeleteClient)
|
||||
mux.HandleFunc("/admin/snapshots", s.handleAdminGetSnapshots)
|
||||
mux.HandleFunc("/admin/snapshot/delete", s.handleAdminDeleteSnapshot)
|
||||
mux.HandleFunc("/admin/stats", s.handleAdminGetStats)
|
||||
mux.HandleFunc("/admin/admins", s.handleAdminGetAdmins)
|
||||
mux.HandleFunc("/admin/admin/create", s.handleAdminCreateAdmin)
|
||||
mux.HandleFunc("/admin/admin/delete", s.handleAdminDeleteAdmin)
|
||||
|
||||
// Admin UI (static files served from /admin/)
|
||||
mux.HandleFunc("/admin/", s.handleAdminUI)
|
||||
}
|
||||
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
|
||||
Reference in New Issue
Block a user