Files
Aether/backend/pkg/config/config.go
T
marcandClaude Opus 5 9b8e82cdb2 feat(honeybar): HoneyOS-Leiste einbinden, CSP-Ausnahme dafür
Die Leiste kommt als Skript von der HoneyOS-Domain und holt sich von dort
ihre App-Liste. Beides blockte die CSP bisher (script-src / connect-src
fielen auf 'self' zurueck) – ohne Ausnahme erschien sie gar nicht.

- HONEYBAR_ORIGIN nimmt genau diesen einen Ursprung in script-src und
  connect-src auf. Leer heisst: keine Leiste, und die Regel bleibt so eng
  wie bisher. Frame-Freigabe erbt sie ausdruecklich nicht.
- SecurityHeaders nimmt die Ausnahmen jetzt als CSP-Struct statt als
  variadische frameSrc-Liste – mit drei Richtungen wurde das sonst unklar.
- Das <script>-Tag steht in der index.html und nicht serverseitig
  eingespritzt: der Service Worker legt die index.html in seinen Vorrat
  (navigateFallback), was zur Bauzeit fehlt, kaeme im Browser nie an.
- defer, damit ein langsames oder abgeschaltetes HoneyOS den Start von
  Aether nicht ausbremst.

Tests fuer die neue Regel: eng ohne Ausnahme, beide Richtungen mit,
Pfad wird auf den Ursprung gekuerzt, Adresse ohne Schema faellt raus.

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

332 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
// HoneybarOrigin ist der Ursprung, von dem die HoneyOS-Leiste geladen wird
// (z. B. https://os.example.com). Er wird in die CSP aufgenommen — ohne das
// blockt der Browser das Skript und dessen Anfragen.
//
// Muss zur Adresse im <script>-Tag der index.html passen. Leer heißt: keine
// Leiste, und die CSP bleibt so eng wie bisher.
HoneybarOrigin 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", ""), "/"),
HoneybarOrigin: strings.TrimRight(getEnv("HONEYBAR_ORIGIN", ""), "/"),
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
}