go-places/api/internal/config/config.go

261 lines
7.6 KiB
Go
Raw Permalink Normal View History

2026-10-07 15:14:50 +02:00
// 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
}