261 lines
7.6 KiB
Go
261 lines
7.6 KiB
Go
|
|
// 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
|
||
|
|
}
|