Add admin-only CRUD endpoints for user management under /api/v1/system/users with full frontend implementation including create, edit, change password, and delete operations with self-protection guards. - Extend AuthDomain interface with ListUsers, GetUser, UpdateUser, UpdateUserPassword, DeleteUser methods - Add List, Update, Delete methods to UserStore with shared scanUser helper - Create UserHandler with 5 endpoints protected by admin middleware - Complete Users.vue admin page with modals and error handling - Make CreateUser accept role parameter for atomic user creation - Fix auth middleware to use database role as source of truth instead of stale JWT claims, preventing privilege persistence after demotion - Fix pre-existing gofmt issues in yt_dlp.go and config.go
202 lines
5.9 KiB
Go
202 lines
5.9 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// Config holds the application configuration
|
|
type Config struct {
|
|
DatabaseURL string `mapstructure:"database_url"` // Primary database URL (used for writes, and reads if DatabaseReadURL not set)
|
|
DatabaseReadURL string `mapstructure:"database_read_url"` // Optional: read-only database URL (falls back to DatabaseURL if not set)
|
|
JWTSecret string `mapstructure:"jwt_secret"`
|
|
JWTExpiration time.Duration `mapstructure:"jwt_expiration"`
|
|
ServerPort int `mapstructure:"server_port"`
|
|
ServerHost string `mapstructure:"server_host"` // IP address or hostname to bind to (mutually exclusive with server_interface)
|
|
ServerInterface string `mapstructure:"server_interface"` // Network interface name to bind to (mutually exclusive with server_host)
|
|
ArchiveStoragePath string `mapstructure:"archive_storage_path"` // Path to store archived files
|
|
EnableAccessLog bool `mapstructure:"enable_access_log"` // Enable HTTP access logging middleware
|
|
}
|
|
|
|
// Load loads configuration from file or environment variables
|
|
func Load(configPath string) (*Config, error) {
|
|
v := viper.New()
|
|
|
|
// Set defaults
|
|
v.SetDefault("database_url", "sqlite://devdata/hako.db")
|
|
v.SetDefault("server_port", 8080)
|
|
v.SetDefault("jwt_expiration", "24h")
|
|
v.SetDefault("archive_storage_path", "devdata/archives")
|
|
v.SetDefault("enable_access_log", true)
|
|
|
|
// Environment variables
|
|
v.SetEnvPrefix("")
|
|
v.AutomaticEnv()
|
|
|
|
// Bind environment variables
|
|
_ = v.BindEnv("database_url", "DATABASE_URL")
|
|
_ = v.BindEnv("database_read_url", "DATABASE_READ_URL")
|
|
_ = v.BindEnv("jwt_secret", "JWT_SECRET")
|
|
_ = v.BindEnv("jwt_expiration", "JWT_EXPIRATION")
|
|
_ = v.BindEnv("server_port", "SERVER_PORT")
|
|
_ = v.BindEnv("server_host", "SERVER_HOST")
|
|
_ = v.BindEnv("server_interface", "SERVER_INTERFACE")
|
|
_ = v.BindEnv("archive_storage_path", "ARCHIVE_STORAGE_PATH")
|
|
_ = v.BindEnv("enable_access_log", "ENABLE_ACCESS_LOG")
|
|
|
|
// If config file is provided, read from it
|
|
if configPath != "" {
|
|
v.SetConfigFile(configPath)
|
|
v.SetConfigType("yaml") // Default to yaml, but viper can auto-detect
|
|
|
|
if err := v.ReadInConfig(); err != nil {
|
|
// Allow missing config file (configuration can come from env vars)
|
|
// but return error for other issues like permission denied
|
|
if !errors.Is(err, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
// Config file not found, continue with env vars and defaults
|
|
}
|
|
}
|
|
|
|
var cfg Config
|
|
if err := v.Unmarshal(&cfg); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
|
}
|
|
|
|
// Validate required fields
|
|
if cfg.DatabaseURL == "" {
|
|
return nil, fmt.Errorf("database_url is required")
|
|
}
|
|
// If DatabaseReadURL is not specified, use DatabaseURL for reads
|
|
if cfg.DatabaseReadURL == "" {
|
|
cfg.DatabaseReadURL = cfg.DatabaseURL
|
|
}
|
|
if cfg.JWTSecret == "" {
|
|
return nil, fmt.Errorf("jwt_secret is required")
|
|
}
|
|
|
|
// Validate that server_host and server_interface are not both set
|
|
if cfg.ServerHost != "" && cfg.ServerInterface != "" {
|
|
return nil, fmt.Errorf("server_host and server_interface cannot be defined at the same time")
|
|
}
|
|
|
|
// Parse JWT expiration if it's a string
|
|
if cfg.JWTExpiration == 0 {
|
|
expStr := v.GetString("jwt_expiration")
|
|
if expStr != "" {
|
|
duration, err := time.ParseDuration(expStr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid jwt_expiration format: %w", err)
|
|
}
|
|
cfg.JWTExpiration = duration
|
|
} else {
|
|
cfg.JWTExpiration = 24 * time.Hour // Default to 24 hours
|
|
}
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// GetBindAddress returns the address to bind the server to (host:port or interface IP:port)
|
|
func (c *Config) GetBindAddress() (string, error) {
|
|
port := c.ServerPort
|
|
if port == 0 {
|
|
port = 8080 // Default port
|
|
}
|
|
|
|
var host string
|
|
if c.ServerInterface != "" {
|
|
// Get IP address from interface name
|
|
iface, err := net.InterfaceByName(c.ServerInterface)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find interface %s: %w", c.ServerInterface, err)
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get addresses for interface %s: %w", c.ServerInterface, err)
|
|
}
|
|
|
|
// Find the first IPv4 address
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
|
|
if ip != nil && ip.To4() != nil {
|
|
host = ip.String()
|
|
break
|
|
}
|
|
}
|
|
|
|
if host == "" {
|
|
return "", fmt.Errorf("no IPv4 address found on interface %s", c.ServerInterface)
|
|
}
|
|
} else if c.ServerHost != "" {
|
|
host = c.ServerHost
|
|
} else {
|
|
// Default: bind to all interfaces
|
|
host = ""
|
|
}
|
|
|
|
return fmt.Sprintf("%s:%d", host, port), nil
|
|
}
|
|
|
|
// GetServerURL returns the URL to access the server
|
|
func (c *Config) GetServerURL() (string, error) {
|
|
port := c.ServerPort
|
|
if port == 0 {
|
|
port = 8080 // Default port
|
|
}
|
|
|
|
var host string
|
|
if c.ServerInterface != "" {
|
|
// Get IP address from interface name
|
|
iface, err := net.InterfaceByName(c.ServerInterface)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to find interface %s: %w", c.ServerInterface, err)
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get addresses for interface %s: %w", c.ServerInterface, err)
|
|
}
|
|
|
|
// Find the first IPv4 address
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
|
|
if ip != nil && ip.To4() != nil {
|
|
host = ip.String()
|
|
break
|
|
}
|
|
}
|
|
|
|
if host == "" {
|
|
return "", fmt.Errorf("no IPv4 address found on interface %s", c.ServerInterface)
|
|
}
|
|
} else if c.ServerHost != "" {
|
|
host = c.ServerHost
|
|
} else {
|
|
// Default: localhost
|
|
host = "localhost"
|
|
}
|
|
|
|
return fmt.Sprintf("http://%s:%d", host, port), nil
|
|
}
|