202 lines
7.1 KiB
Go
202 lines
7.1 KiB
Go
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Environment string
|
|
|
|
const (
|
|
Development Environment = "development"
|
|
Test Environment = "test"
|
|
Production Environment = "production"
|
|
)
|
|
|
|
type Config struct {
|
|
Environment Environment
|
|
DBDSN string
|
|
ListenAddress string
|
|
StorageRoot string
|
|
SessionKey string
|
|
MasterKey string
|
|
SessionTTL time.Duration
|
|
LoginAttempts int
|
|
LoginWindow time.Duration
|
|
MaxPromptBytes int
|
|
MaxImages int
|
|
MaxImageBytes int64
|
|
MaxUploadBytes int64
|
|
MaxImagePixels uint64
|
|
HistoryLimit int
|
|
WorkerLeaseDuration time.Duration
|
|
WorkerPollInterval time.Duration
|
|
ProviderHTTPTimeout time.Duration
|
|
ProviderMaxResponseBytes int64
|
|
TestDisableWorker bool
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
return LoadFromLookup(os.LookupEnv)
|
|
}
|
|
|
|
func LoadFromLookup(lookup func(string) (string, bool)) (Config, error) {
|
|
read := func(name string) string {
|
|
value, _ := lookup(name)
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
environment := Environment(read("CHORUS_ENV"))
|
|
if environment == "" {
|
|
environment = Development
|
|
}
|
|
if environment != Development && environment != Test && environment != Production {
|
|
return Config{}, fmt.Errorf("CHORUS_ENV must be development, test, or production")
|
|
}
|
|
|
|
cfg := Config{
|
|
Environment: environment,
|
|
DBDSN: read("CHORUS_DSN"),
|
|
ListenAddress: read("CHORUS_LISTEN_ADDRESS"),
|
|
StorageRoot: read("CHORUS_STORAGE_ROOT"),
|
|
SessionKey: read("CHORUS_SESSION_KEY"),
|
|
MasterKey: read("CHORUS_MASTER_KEY"),
|
|
}
|
|
if environment != Production {
|
|
if cfg.StorageRoot == "" {
|
|
cfg.StorageRoot = "var/storage"
|
|
}
|
|
if cfg.SessionKey == "" {
|
|
generated := make([]byte, 32)
|
|
if _, err := rand.Read(generated); err != nil {
|
|
return Config{}, fmt.Errorf("generate development session key")
|
|
}
|
|
cfg.SessionKey = base64.RawURLEncoding.EncodeToString(generated)
|
|
}
|
|
}
|
|
if cfg.ListenAddress == "" {
|
|
cfg.ListenAddress = "127.0.0.1:8080"
|
|
}
|
|
|
|
var missing []string
|
|
if cfg.DBDSN == "" {
|
|
missing = append(missing, "CHORUS_DSN")
|
|
}
|
|
if environment == Production {
|
|
for name, value := range map[string]string{
|
|
"CHORUS_MASTER_KEY": cfg.MasterKey,
|
|
"CHORUS_SESSION_KEY": cfg.SessionKey,
|
|
"CHORUS_STORAGE_ROOT": cfg.StorageRoot,
|
|
} {
|
|
if value == "" {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
for _, name := range []string{"CHORUS_SESSION_TTL_MINUTES", "CHORUS_LOGIN_ATTEMPTS", "CHORUS_LOGIN_WINDOW_SECONDS", "CHORUS_MAX_PROMPT_BYTES", "CHORUS_MAX_IMAGES", "CHORUS_MAX_IMAGE_BYTES", "CHORUS_MAX_UPLOAD_BYTES", "CHORUS_MAX_IMAGE_PIXELS", "CHORUS_HISTORY_LIMIT", "CHORUS_WORKER_LEASE_SECONDS", "CHORUS_WORKER_POLL_MILLISECONDS", "CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS", "CHORUS_PROVIDER_MAX_RESPONSE_BYTES"} {
|
|
if read(name) == "" {
|
|
missing = append(missing, name)
|
|
}
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return Config{}, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", "))
|
|
}
|
|
var err error
|
|
if cfg.SessionTTL, err = durationConfig(read("CHORUS_SESSION_TTL_MINUTES"), 480, time.Minute); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_SESSION_TTL_MINUTES is invalid")
|
|
}
|
|
if cfg.LoginWindow, err = durationConfig(read("CHORUS_LOGIN_WINDOW_SECONDS"), 60, time.Second); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_LOGIN_WINDOW_SECONDS is invalid")
|
|
}
|
|
if cfg.LoginAttempts, err = intConfig(read("CHORUS_LOGIN_ATTEMPTS"), 5); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_LOGIN_ATTEMPTS is invalid")
|
|
}
|
|
if cfg.MaxPromptBytes, err = intConfig(read("CHORUS_MAX_PROMPT_BYTES"), 8000); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_MAX_PROMPT_BYTES is invalid")
|
|
}
|
|
if cfg.MaxImages, err = intConfig(read("CHORUS_MAX_IMAGES"), 8); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGES is invalid")
|
|
}
|
|
maxImageBytes, err := intConfig(read("CHORUS_MAX_IMAGE_BYTES"), 10<<20)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_BYTES is invalid")
|
|
}
|
|
cfg.MaxImageBytes = int64(maxImageBytes)
|
|
maxUploadBytes, err := intConfig(read("CHORUS_MAX_UPLOAD_BYTES"), 32<<20)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_MAX_UPLOAD_BYTES is invalid")
|
|
}
|
|
cfg.MaxUploadBytes = int64(maxUploadBytes)
|
|
maxPixels, err := intConfig(read("CHORUS_MAX_IMAGE_PIXELS"), 40_000_000)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_MAX_IMAGE_PIXELS is invalid")
|
|
}
|
|
cfg.MaxImagePixels = uint64(maxPixels)
|
|
if cfg.HistoryLimit, err = intConfig(read("CHORUS_HISTORY_LIMIT"), 50); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_HISTORY_LIMIT is invalid")
|
|
}
|
|
if cfg.WorkerLeaseDuration, err = durationConfig(read("CHORUS_WORKER_LEASE_SECONDS"), 60, time.Second); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_WORKER_LEASE_SECONDS is invalid")
|
|
}
|
|
if cfg.WorkerPollInterval, err = durationConfig(read("CHORUS_WORKER_POLL_MILLISECONDS"), 250, time.Millisecond); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_WORKER_POLL_MILLISECONDS is invalid")
|
|
}
|
|
if cfg.ProviderHTTPTimeout, err = durationConfig(read("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS"), 45, time.Second); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_PROVIDER_HTTP_TIMEOUT_SECONDS is invalid")
|
|
}
|
|
providerMaxResponseBytes, err := intConfig(read("CHORUS_PROVIDER_MAX_RESPONSE_BYTES"), 32<<20)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_PROVIDER_MAX_RESPONSE_BYTES is invalid")
|
|
}
|
|
cfg.ProviderMaxResponseBytes = int64(providerMaxResponseBytes)
|
|
if value := read("CHORUS_TEST_DISABLE_WORKER"); value != "" {
|
|
if cfg.TestDisableWorker, err = strconv.ParseBool(value); err != nil {
|
|
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is invalid")
|
|
}
|
|
if cfg.TestDisableWorker && environment != Test {
|
|
return Config{}, fmt.Errorf("CHORUS_TEST_DISABLE_WORKER is only allowed in test")
|
|
}
|
|
}
|
|
if cfg.MaxUploadBytes < cfg.MaxImageBytes || cfg.MaxImages > 32 || cfg.HistoryLimit > 200 {
|
|
return Config{}, fmt.Errorf("portal limits are inconsistent")
|
|
}
|
|
if cfg.WorkerLeaseDuration <= cfg.ProviderHTTPTimeout+5*time.Second {
|
|
return Config{}, fmt.Errorf("worker lease must exceed provider HTTP timeout by more than 5 seconds")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c Config) ValidateProduction() error {
|
|
if c.Environment != Production {
|
|
return nil
|
|
}
|
|
if c.DBDSN == "" || c.MasterKey == "" || len(c.SessionKey) < 32 || c.StorageRoot == "" || c.SessionTTL <= 0 || c.MaxImageBytes <= 0 || c.MaxUploadBytes < c.MaxImageBytes || c.WorkerLeaseDuration <= c.ProviderHTTPTimeout+5*time.Second || c.ProviderMaxResponseBytes <= 0 {
|
|
return errors.New("production configuration is incomplete")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func intConfig(value string, fallback int) (int, error) {
|
|
if value == "" {
|
|
return fallback, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil || parsed <= 0 {
|
|
return 0, errors.New("invalid positive integer")
|
|
}
|
|
return parsed, nil
|
|
}
|
|
func durationConfig(value string, fallback int, unit time.Duration) (time.Duration, error) {
|
|
parsed, err := intConfig(value, fallback)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return time.Duration(parsed) * unit, nil
|
|
}
|