mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-14 11:53:42 +08:00
207 lines
5.8 KiB
Go
207 lines
5.8 KiB
Go
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const maxConfigBytes = 1 << 20
|
|
|
|
// Config contains the process-level settings shared by the HTTP and storage
|
|
// layers. Environment variables override values loaded from VOCAT_CONFIG.
|
|
type Config struct {
|
|
Address string
|
|
DatabasePath string
|
|
SessionTTL time.Duration
|
|
SecureCookies bool
|
|
ShutdownTimeout time.Duration
|
|
MaxRequestBodyBytes int64
|
|
}
|
|
|
|
type fileConfig struct {
|
|
Address *string `json:"address"`
|
|
DatabasePath *string `json:"database_path"`
|
|
// Retain the legacy keys only so upgrades do not reject an existing config
|
|
// file. They are deliberately ignored: administrator credentials are read
|
|
// exclusively from SQLite.
|
|
LegacyAdminUsername *string `json:"admin_username"`
|
|
LegacyAdminPassword *string `json:"admin_password"`
|
|
SessionTTL *string `json:"session_ttl"`
|
|
SecureCookies *bool `json:"secure_cookies"`
|
|
ShutdownTimeout *string `json:"shutdown_timeout"`
|
|
MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"`
|
|
}
|
|
|
|
// Default returns the non-secret process configuration. Administrator
|
|
// credentials are initialized separately and stored only in SQLite.
|
|
func Default() Config {
|
|
return Config{
|
|
Address: "0.0.0.0:7575",
|
|
DatabasePath: "./data/vocat.db",
|
|
SessionTTL: 24 * time.Hour,
|
|
SecureCookies: false,
|
|
ShutdownTimeout: 10 * time.Second,
|
|
MaxRequestBodyBytes: 1 << 20,
|
|
}
|
|
}
|
|
|
|
// Load reads an optional strict JSON file selected by VOCAT_CONFIG and then
|
|
// applies VOCAT_* environment overrides.
|
|
func Load() (Config, error) {
|
|
cfg := Default()
|
|
|
|
if path := strings.TrimSpace(os.Getenv("VOCAT_CONFIG")); path != "" {
|
|
fileValues, err := loadFile(path)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := applyFile(&cfg, fileValues); err != nil {
|
|
return Config{}, fmt.Errorf("load config %q: %w", path, err)
|
|
}
|
|
}
|
|
|
|
if err := applyEnvironment(&cfg); err != nil {
|
|
return Config{}, err
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func loadFile(path string) (fileConfig, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return fileConfig{}, fmt.Errorf("open config %q: %w", path, err)
|
|
}
|
|
defer file.Close()
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
return fileConfig{}, fmt.Errorf("stat config %q: %w", path, err)
|
|
}
|
|
if info.Size() > maxConfigBytes {
|
|
return fileConfig{}, fmt.Errorf("config %q exceeds %d bytes", path, maxConfigBytes)
|
|
}
|
|
|
|
decoder := json.NewDecoder(io.LimitReader(file, maxConfigBytes))
|
|
decoder.DisallowUnknownFields()
|
|
|
|
var values fileConfig
|
|
if err := decoder.Decode(&values); err != nil {
|
|
return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
if err == nil {
|
|
err = errors.New("multiple JSON values")
|
|
}
|
|
return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func applyFile(cfg *Config, values fileConfig) error {
|
|
if values.Address != nil {
|
|
cfg.Address = *values.Address
|
|
}
|
|
if values.DatabasePath != nil {
|
|
cfg.DatabasePath = *values.DatabasePath
|
|
}
|
|
if values.SessionTTL != nil {
|
|
duration, err := time.ParseDuration(*values.SessionTTL)
|
|
if err != nil {
|
|
return fmt.Errorf("session_ttl: %w", err)
|
|
}
|
|
cfg.SessionTTL = duration
|
|
}
|
|
if values.SecureCookies != nil {
|
|
cfg.SecureCookies = *values.SecureCookies
|
|
}
|
|
if values.ShutdownTimeout != nil {
|
|
duration, err := time.ParseDuration(*values.ShutdownTimeout)
|
|
if err != nil {
|
|
return fmt.Errorf("shutdown_timeout: %w", err)
|
|
}
|
|
cfg.ShutdownTimeout = duration
|
|
}
|
|
if values.MaxRequestBodyBytes != nil {
|
|
cfg.MaxRequestBodyBytes = *values.MaxRequestBodyBytes
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyEnvironment(cfg *Config) error {
|
|
applyString := func(name string, target *string) {
|
|
if value, ok := os.LookupEnv(name); ok {
|
|
*target = value
|
|
}
|
|
}
|
|
|
|
applyString("VOCAT_ADDR", &cfg.Address)
|
|
applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath)
|
|
|
|
if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return fmt.Errorf("VOCAT_SESSION_TTL: %w", err)
|
|
}
|
|
cfg.SessionTTL = duration
|
|
}
|
|
if value, ok := os.LookupEnv("VOCAT_SECURE_COOKIES"); ok {
|
|
secure, err := strconv.ParseBool(value)
|
|
if err != nil {
|
|
return fmt.Errorf("VOCAT_SECURE_COOKIES: %w", err)
|
|
}
|
|
cfg.SecureCookies = secure
|
|
}
|
|
if value, ok := os.LookupEnv("VOCAT_SHUTDOWN_TIMEOUT"); ok {
|
|
duration, err := time.ParseDuration(value)
|
|
if err != nil {
|
|
return fmt.Errorf("VOCAT_SHUTDOWN_TIMEOUT: %w", err)
|
|
}
|
|
cfg.ShutdownTimeout = duration
|
|
}
|
|
if value, ok := os.LookupEnv("VOCAT_MAX_REQUEST_BODY_BYTES"); ok {
|
|
size, err := strconv.ParseInt(value, 10, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("VOCAT_MAX_REQUEST_BODY_BYTES: %w", err)
|
|
}
|
|
cfg.MaxRequestBodyBytes = size
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate rejects settings that would make the server unusable or weaken its
|
|
// basic request limits.
|
|
func (cfg Config) Validate() error {
|
|
host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address))
|
|
if err != nil {
|
|
return fmt.Errorf("address: %w", err)
|
|
}
|
|
_ = host
|
|
port, err := strconv.Atoi(portText)
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return fmt.Errorf("address: invalid TCP port %q", portText)
|
|
}
|
|
if strings.TrimSpace(cfg.DatabasePath) == "" {
|
|
return errors.New("database_path must not be empty")
|
|
}
|
|
if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour {
|
|
return errors.New("session_ttl must be between 5m and 720h")
|
|
}
|
|
if cfg.ShutdownTimeout <= 0 || cfg.ShutdownTimeout > 5*time.Minute {
|
|
return errors.New("shutdown_timeout must be between 1ns and 5m")
|
|
}
|
|
if cfg.MaxRequestBodyBytes < 1024 || cfg.MaxRequestBodyBytes > 10<<20 {
|
|
return errors.New("max_request_body_bytes must be between 1024 and 10485760")
|
|
}
|
|
return nil
|
|
}
|