// Package config loads runtime settings from the environment. Every setting has // a working default so the service starts without configuration in development. package config import ( "fmt" "os" "strconv" "strings" "time" ) // Config is the fully resolved runtime configuration. type Config struct { // Addr is the listen address for the HTTP server. Addr string // DatabaseURL points at the SQLite file backing the store. DatabaseURL string // MediaDir is the root directory that uploaded pictures are written to. MediaDir string // PublicBaseURL is prefixed to stored media paths to build absolute URLs. PublicBaseURL string // AuthMode selects the token validator: "jwt" or "dev". AuthMode string // JWTSecret is the shared secret used to verify go-loose issued tokens. JWTSecret string // JWTAudience is the expected "aud" claim; empty disables the check. JWTAudience string // DefaultLinkRadiusMeters is how close two pictures must be to be linked. DefaultLinkRadiusMeters float64 // MaxLinkRadiusMeters caps what a client may request per place. MaxLinkRadiusMeters float64 // MaxUploadBytes is the largest accepted upload request. MaxUploadBytes int64 // AllowedImageTypes are the content types accepted for pictures. AllowedImageTypes []string // AnalyzerMode is "local" for the built-in heuristics or "remote" to // delegate to an external inference service. AnalyzerMode string // AnalyzerURL is the base URL of the external inference service. AnalyzerURL string // AnalyzerKey authenticates requests to the inference service. AnalyzerKey string // AnalyzerTimeout bounds a single analysis request. AnalyzerTimeout time.Duration // AMQPURL enables the event publisher when non-empty. AMQPURL string // EventsExchange is the exchange domain events are published to. EventsExchange string // EnableREST if true, adds REST endpoints alongside GraphQL. EnableREST bool } // Default returns the configuration used when nothing is set. func Default() Config { return Config{ Addr: ":8080", DatabaseURL: "file:go-places.db?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", MediaDir: "./data/media", PublicBaseURL: "", AuthMode: "dev", JWTSecret: "", JWTAudience: "", DefaultLinkRadiusMeters: 150, MaxLinkRadiusMeters: 5000, MaxUploadBytes: 20 << 20, AllowedImageTypes: []string{"image/jpeg", "image/png", "image/webp"}, AnalyzerMode: "local", AnalyzerURL: "", AnalyzerKey: "", AnalyzerTimeout: 10 * time.Second, AMQPURL: "", EventsExchange: "go-places.events", EnableREST: false, } } // Load builds a Config from the environment on top of the defaults. func Load() (Config, error) { c := Default() var err error if c.Addr, err = envString("ADDR", c.Addr); err != nil { return c, err } if c.DatabaseURL, err = envString("DATABASE_URL", c.DatabaseURL); err != nil { return c, err } if c.MediaDir, err = envString("MEDIA_DIR", c.MediaDir); err != nil { return c, err } if c.PublicBaseURL, err = envString("PUBLIC_BASE_URL", c.PublicBaseURL); err != nil { return c, err } if c.AuthMode, err = envString("AUTH_MODE", c.AuthMode); err != nil { return c, err } if c.JWTSecret, err = envString("JWT_SECRET", c.JWTSecret); err != nil { return c, err } if c.JWTAudience, err = envString("JWT_AUDIENCE", c.JWTAudience); err != nil { return c, err } if c.DefaultLinkRadiusMeters, err = envFloat("DEFAULT_LINK_RADIUS_METERS", c.DefaultLinkRadiusMeters); err != nil { return c, err } if c.MaxLinkRadiusMeters, err = envFloat("MAX_LINK_RADIUS_METERS", c.MaxLinkRadiusMeters); err != nil { return c, err } if c.MaxUploadBytes, err = envInt64("MAX_UPLOAD_BYTES", c.MaxUploadBytes); err != nil { return c, err } if c.AnalyzerMode, err = envString("ANALYZER_MODE", c.AnalyzerMode); err != nil { return c, err } if c.AnalyzerURL, err = envString("ANALYZER_URL", c.AnalyzerURL); err != nil { return c, err } if c.AnalyzerKey, err = envString("ANALYZER_KEY", c.AnalyzerKey); err != nil { return c, err } if an, err := envInt("ANALYZER_TIMEOUT_SECONDS", int(c.AnalyzerTimeout/time.Second)); err == nil { c.AnalyzerTimeout = time.Duration(an) * time.Second } else { return c, err } if c.AMQPURL, err = envString("AMQP_URL", c.AMQPURL); err != nil { return c, err } if c.EventsExchange, err = envString("EVENTS_EXCHANGE", c.EventsExchange); err != nil { return c, err } if v := os.Getenv("ENABLE_REST"); v != "" { c.EnableREST, err = strconv.ParseBool(v) if err != nil { return c, fmt.Errorf("config: ENABLE_REST must be true or false: %w", err) } } if v := os.Getenv("ALLOWED_IMAGE_TYPES"); v != "" { c.AllowedImageTypes = splitAndTrim(v) } if err := c.Validate(); err != nil { return c, err } return c, nil } // Validate rejects configurations that cannot work, so misconfiguration is // caught at startup instead of on the first request. func (c Config) Validate() error { switch c.AuthMode { case "jwt": if c.JWTSecret == "" { return fmt.Errorf("config: AUTH_MODE=jwt requires JWT_SECRET to be set") } case "dev": default: return fmt.Errorf("config: AUTH_MODE must be %q or %q, got %q", "jwt", "dev", c.AuthMode) } switch c.AnalyzerMode { case "local": case "remote": if c.AnalyzerURL == "" { return fmt.Errorf("config: ANALYZER_MODE=remote requires ANALYZER_URL to be set") } default: return fmt.Errorf("config: ANALYZER_MODE must be %q or %q, got %q", "local", "remote", c.AnalyzerMode) } if c.DefaultLinkRadiusMeters <= 0 { return fmt.Errorf("config: DEFAULT_LINK_RADIUS_METERS must be positive") } if c.MaxLinkRadiusMeters < c.DefaultLinkRadiusMeters { return fmt.Errorf("config: MAX_LINK_RADIUS_METERS must not be smaller than DEFAULT_LINK_RADIUS_METERS") } if c.MaxUploadBytes <= 0 { return fmt.Errorf("config: MAX_UPLOAD_BYTES must be positive") } if len(c.AllowedImageTypes) == 0 { return fmt.Errorf("config: at least one image content type must be allowed") } return nil } // AllowsContentType reports whether a content type may be uploaded. func (c Config) AllowsContentType(contentType string) bool { // Ignore any parameters such as "; charset=utf-8". if i := strings.IndexByte(contentType, ';'); i >= 0 { contentType = contentType[:i] } contentType = strings.ToLower(strings.TrimSpace(contentType)) for _, allowed := range c.AllowedImageTypes { if allowed == contentType { return true } } return false } func envString(key, def string) (string, error) { if v, ok := os.LookupEnv(key); ok && v != "" { return v, nil } return def, nil } func envInt(key string, def int) (int, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } n, err := strconv.Atoi(v) if err != nil { return def, fmt.Errorf("config: %s must be an integer: %w", key, err) } return n, nil } func envInt64(key string, def int64) (int64, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return def, fmt.Errorf("config: %s must be an integer: %w", key, err) } return n, nil } func envFloat(key string, def float64) (float64, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } f, err := strconv.ParseFloat(v, 64) if err != nil { return def, fmt.Errorf("config: %s must be a number: %w", key, err) } return f, nil } func splitAndTrim(v string) []string { parts := strings.Split(v, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }