Files
Aether/backend/pkg/config/config.go
T
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:36 +02:00

272 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"fmt"
"net"
"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
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"),
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
}
func (c *Config) DSN() string {
return fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
c.DBHost, c.DBPort, c.DBName, c.DBUser, c.DBPassword,
)
}
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
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}