package config import ( "fmt" "net" "net/url" "os" "strconv" "strings" "time" ) type Config struct { AppEnv string ListenAddr string // APP_LISTEN_ADDR, e.g. ":8080" PublicURL string // PUBLIC_URL, e.g. "https://cloud.example.com" // TrustedProxies is the list of IPs/CIDRs whose X-Forwarded-* headers are trusted. TrustedProxies []*net.IPNet DBHost string // MaxVersionsKept: Wie viele frühere Fassungen einer Datei aufgehoben // werden, wenn die Versionierung für sie eingeschaltet ist. MaxVersionsKept int DBPort string DBName string DBUser string DBPassword string SessionSecret string SessionMaxAge int StoragePath string TmpPath string AdminUsername string AdminPassword string // ExternalApps erscheinen im App-Umschalter der Oberfläche. Kommt aus der // Umgebung, damit neue Apps ohne Neubau des Frontends dazukommen können. ExternalApps []ExternalApp // OCR – Volltextsuche über den Inhalt von PDFs und Bildern. // Standardmäßig an; fehlen die Programme im Image, schaltet sich der // Arbeiter beim Start selbst ab, statt den Start zu verhindern. OCREnabled bool OCRLanguages string // OCR_LANGUAGES, z. B. "deu+eng" OCRMaxPages int // je Datei; 0 = unbegrenzt OCRTimeout time.Duration // je Datei // OIDC – all optional; if OIDCIssuer is empty, OIDC login is disabled. OIDCIssuer string OIDCClientID string OIDCClientSecret string OIDCRedirectURL string // OIDCLinkByUsername verbindet ein bestehendes Passwort-Konto mit der // OIDC-Identität, statt ein zweites anzulegen. Voreingestellt **aus** – // siehe die Begründung an auth.Store.GetOrCreateByOIDC. OIDCLinkByUsername bool // Office – optional. Ist OfficeProvider leer, ist die Bearbeitung aus und // die Anwendung verhält sich wie zuvor. // // Ein Adapter für beide Anbieter: Collabora und OnlyOffice sprechen // dieselbe Schnittstelle (WOPI). Zwei Integrationen zu bauen hieße, // dieselbe Sache zweimal zu pflegen. OfficeProvider string // "collabora" | "onlyoffice" | "" (aus) OfficeURL string // Adresse, unter der das **Backend** den Server erreicht // OfficePublicURL ist die Adresse, unter der der **Browser** ihn erreicht. // // Die beiden sind selten gleich: Im Compose-Netz heißt der Server // `collabora`, im Browser muss dort ein Name stehen, den der Rechner des // Menschen auflösen kann. Seine Ermittlungsliste nennt zudem seinen // eigenen internen Namen – die Adresse muss also umgeschrieben werden. // Leer heißt: dieselbe wie OfficeURL. OfficePublicURL string // OfficeWopiURL ist die Adresse, unter der der **Office-Server** Aether // erreicht. // // Der dritte Weg in diesem Dreieck, und der am leichtesten übersehene: // Der Office-Server holt die Datei selbst hier ab. Aus seinem Container // heraus ist „localhost" er selbst – PUBLIC_URL taugt dafür also nur, // wenn es ein Name ist, den auch er auflöst. Leer heißt: PUBLIC_URL. OfficeWopiURL string // OfficeAllowedHosts begrenzt, welche Adressen das Backend anfragen darf. // Ohne diese Grenze wird aus einem Tippfehler oder einer untergeschobenen // Konfiguration ein Weg ins interne Netz (SSRF). OfficeAllowedHosts []string // OfficeInsecureTLS erlaubt ein selbstsigniertes Zertifikat am // Office-Server. Nur für den Testaufbau auf demselben Rechner. OfficeInsecureTLS bool } // splitList zerlegt eine kommagetrennte Liste und wirft Leeres weg. func splitList(raw string) []string { var out []string for _, part := range strings.Split(raw, ",") { if v := strings.TrimSpace(part); v != "" { out = append(out, v) } } return out } // OfficeEnabled sagt, ob die Bearbeitung überhaupt in Frage kommt. Beides muss // gesetzt sein – ein Anbieter ohne Adresse wäre eine halbe Konfiguration, und // die soll nicht erst beim ersten Klick auffallen. func (c *Config) OfficeEnabled() bool { return (c.OfficeProvider == "collabora" || c.OfficeProvider == "onlyoffice") && c.OfficeURL != "" } // ExternalApp ist ein Eintrag im App-Umschalter. type ExternalApp struct { Name string `json:"name"` URL string `json:"url"` Icon string `json:"icon"` // ein Emoji, optional } // parseExternalApps liest EXTERNAL_APPS im Format // // Name|https://url|Emoji , Name|https://url|Emoji // // Einträge ohne Namen oder URL werden übersprungen, damit ein Tippfehler in der // .env nicht den Start verhindert. func parseExternalApps(raw string) []ExternalApp { var apps []ExternalApp for _, entry := range strings.Split(raw, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } parts := strings.Split(entry, "|") if len(parts) < 2 { continue } app := ExternalApp{ Name: strings.TrimSpace(parts[0]), URL: strings.TrimSpace(parts[1]), } if len(parts) > 2 { app.Icon = strings.TrimSpace(parts[2]) } if app.Name == "" || app.URL == "" { continue } apps = append(apps, app) } return apps } func Load() (*Config, error) { cfg := &Config{ AppEnv: getEnv("APP_ENV", "development"), ListenAddr: getEnv("APP_LISTEN_ADDR", ":8080"), PublicURL: getEnv("PUBLIC_URL", "http://localhost:8080"), DBHost: getEnv("DB_HOST", "localhost"), MaxVersionsKept: getEnvInt("MAX_VERSIONS_KEPT", 5), DBPort: getEnv("DB_PORT", "5432"), DBName: getEnv("DB_NAME", "aether"), DBUser: getEnv("DB_USER", "aether"), DBPassword: getEnv("DB_PASSWORD", ""), SessionSecret: getEnv("SESSION_SECRET", ""), StoragePath: getEnv("STORAGE_PATH", "./data"), TmpPath: getEnv("TMP_PATH", "./data/tmp"), AdminUsername: getEnv("ADMIN_USERNAME", ""), AdminPassword: getEnv("ADMIN_PASSWORD", ""), ExternalApps: parseExternalApps(getEnv("EXTERNAL_APPS", "")), OCREnabled: getEnv("OCR_ENABLED", "true") != "false", OfficeProvider: strings.ToLower(strings.TrimSpace(getEnv("OFFICE_PROVIDER", ""))), OfficeURL: strings.TrimRight(getEnv("OFFICE_URL", ""), "/"), OfficePublicURL: strings.TrimRight(getEnv("OFFICE_PUBLIC_URL", ""), "/"), OfficeWopiURL: strings.TrimRight(getEnv("OFFICE_WOPI_URL", ""), "/"), OfficeAllowedHosts: splitList(getEnv("OFFICE_ALLOWED_HOSTS", "")), OfficeInsecureTLS: getEnv("OFFICE_INSECURE_TLS", "false") == "true", OCRLanguages: getEnv("OCR_LANGUAGES", "deu+eng"), OIDCIssuer: getEnv("OIDC_ISSUER", ""), OIDCClientID: getEnv("OIDC_CLIENT_ID", ""), OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""), OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""), // Nur ein ausdrückliches "true" schaltet es ein: Ein Tippfehler soll // nicht dazu führen, dass Konten stillschweigend übernommen werden. OIDCLinkByUsername: getEnv("OIDC_LINK_BY_USERNAME", "false") == "true", } maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "86400")) if err != nil { return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err) } cfg.SessionMaxAge = maxAge if cfg.SessionSecret == "" { return nil, fmt.Errorf("SESSION_SECRET must be set") } if cfg.DBPassword == "" { return nil, fmt.Errorf("DB_PASSWORD must be set") } // Grenzen für die Texterkennung. Ein Tippfehler in der .env soll den Start // nicht verhindern – deshalb Rückfall auf den Standard statt Abbruch. if n, err := strconv.Atoi(getEnv("OCR_MAX_PAGES", "50")); err == nil && n >= 0 { cfg.OCRMaxPages = n } else { cfg.OCRMaxPages = 50 } if d, err := time.ParseDuration(getEnv("OCR_TIMEOUT", "10m")); err == nil && d > 0 { cfg.OCRTimeout = d } else { cfg.OCRTimeout = 10 * time.Minute } cfg.TrustedProxies, err = parseCIDRList(getEnv("TRUSTED_PROXIES", "")) if err != nil { return nil, fmt.Errorf("invalid TRUSTED_PROXIES: %w", err) } return cfg, nil } // DSN liefert eine `postgres://`-URL – nicht die `key=value`-Form. // // Vorher wurde hier mit fmt.Sprintf zusammengeklebt, und beide Formate haben // Sonderzeichen, die dabei kaputtgehen: // // - In der URL-Form beenden `@` und `:` den Anmeldeteil, und `%` leitet eine // Maskierung ein. Ein Passwort wie `8UoNj%CoWJ7ze@qy` ergab // `postgres://aether:8UoNj%CoWJ7ze@qy@localhost:5432/...` und damit // „invalid URL escape %Co". Der Server kam nicht über die Migrationen // hinaus – gemessen bei der ersten Einrichtung. // - In der key=value-Form trennt das Leerzeichen die Paare. Ein Passwort mit // Leerzeichen wäre dort genauso kaputtgegangen, nur stiller: Der Rest // hinter dem Leerzeichen wäre als eigener Schlüssel gelesen worden. // // Beides erledigt jetzt die Standardbibliothek. `url.UserPassword` maskiert, // was maskiert werden muss, und `net.JoinHostPort` klammert eine IPv6-Adresse. // pgxpool und golang-migrate nehmen beide diese URL unverändert an – die // Umrechnung von Hand in pkg/db ist damit ersatzlos weggefallen. // // Ein Passwort darf danach jedes Zeichen enthalten. Das ist keine // Bequemlichkeit: Wer erst herausfinden muss, welche Zeichen erlaubt sind, // wählt am Ende ein schwächeres. func (c *Config) DSN() string { u := url.URL{ Scheme: "postgres", User: url.UserPassword(c.DBUser, c.DBPassword), Host: net.JoinHostPort(c.DBHost, c.DBPort), Path: "/" + c.DBName, RawQuery: "sslmode=disable", } return u.String() } func (c *Config) IsDev() bool { return c.AppEnv == "development" } // IsTrustedProxy reports whether ip is in the TrustedProxies list. func (c *Config) IsTrustedProxy(ip net.IP) bool { for _, network := range c.TrustedProxies { if network.Contains(ip) { return true } } return false } func parseCIDRList(raw string) ([]*net.IPNet, error) { var networks []*net.IPNet for _, entry := range strings.Split(raw, ",") { entry = strings.TrimSpace(entry) if entry == "" { continue } // Accept plain IPs by appending /32 or /128. if !strings.Contains(entry, "/") { if strings.Contains(entry, ":") { entry += "/128" } else { entry += "/32" } } _, network, err := net.ParseCIDR(entry) if err != nil { return nil, fmt.Errorf("cannot parse %q: %w", entry, err) } networks = append(networks, network) } return networks, nil } // getEnvInt liest eine Zahl. Ein unbrauchbarer Wert nimmt die Vorgabe, statt // den Start zu verhindern: Eine Tippfehler in einer Nebensache soll nicht // dazu führen, dass die Cloud gar nicht erst hochkommt. func getEnvInt(key string, fallback int) int { v := os.Getenv(key) if v == "" { return fallback } n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n < 0 { return fallback } return n } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }