hako/internal/config/config.go
Felipe M. 484b225af7
feat: add user administration API and UI
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
2026-04-02 10:33:36 +02:00

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
}