Files
Aether/backend/pkg/config/config.go
T
HoneyandClaude Opus 5 3019e84681 feat(oidc): OIDC_BUTTON_LABEL, und vier kaputte Zeilen in .env.example
Der Knopf hiess fest "Sign in with SSO / OIDC". Der Anbieter heisst bei
jedem anders; "Anmelden mit PocketID" sagt dem, der davorsteht, mehr.
Vorgabe unveraendert. Der Wert geht nur ueber /public/config, wenn OIDC
ueberhaupt an ist.

Beim Testen dabei selbst einen Fehler gebaut und eingefangen: Ein
Kommentar hinter einem LEEREN Wert wird in einer .env zum Wert.
OIDC_ISSUER= # z. B. https://... startete den OIDC-Aufbau mit dem
Kommentartext als Adresse, und der Start brach ab.

In deploy/.env.example steckten vier weitere solche Zeilen, darunter
OFFICE_PROVIDER= # collabora | onlyoffice - Office waere mit Unsinn als
Anbieter eingeschaltet worden. Alle umgestellt, Warnung an den Dateikopf.

Dazu im README belegt, was beim Ausschalten der Versionierung passiert:
Fassungen bleiben, auch im Papierkorb; erst das Leeren entfernt sie samt
Blobs, und zwar sofort statt erst beim GC.

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

323 lines
11 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"
"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
// OIDCButtonLabel steht auf dem Knopf der Anmeldeseite. Der Anbieter hat
// bei jedem einen anderen Namen – „Sign in with SSO / OIDC" sagt dem
// Nutzer weniger als „Anmelden mit PocketID".
OIDCButtonLabel 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"),
MaxVersionsKept: getEnvInt("MAX_VERSIONS_KEPT", 5),
OIDCButtonLabel: getEnv("OIDC_BUTTON_LABEL", "Sign in with SSO / OIDC"),
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
}