Beim Löschen von convert.go ist Aethers einzige Verwendung des JWT-Schlüssels mitgegangen: signJWT stand dort. Zurück blieb ein Feld auf dem Client, das niemand liest, plus Config-Eintrag und Aufrufparameter – Go meldet ungenutzte Strukturfelder nicht, deshalb lief der Build durch. Eine Einstellung, die nichts mehr tut, ist schlimmer als keine: Wer sie setzt, glaubt, sie wirke. OFFICE_JWT_SECRET bleibt in der .env, wird aber vom **Office-Container** gelesen, nicht von Aether. Geprüft mit Euro-Office: JWT_ENABLED=true und Aether ohne jedes Signieren – der WOPI-Weg läuft. Doku richtiggestellt, nachdem Marc den Compose-Schnipsel angezweifelt hat: - „Euro-Office ist hier nie gelaufen" stimmte nicht mehr. Jetzt steht da, was tatsächlich geprüft ist (Abbild, Start, Discovery, CheckFileInfo, Ausliefern, mobileEdit) und was nicht: PutFile, also das Zurückschreiben. - Der Absatz zu /ConvertService.ashx beschrieb gelöschten Code. - OFFICE_PUBLIC_URL: die Falle benannt, die einen Tag gekostet hat. Stand dort localhost, lief alles am Docker-Rechner und blieb am Telefon schwarz – dort ist localhost das Telefon. Ohne Fehlermeldung. - Zur Portbindung: Die Office-Schnipsel binden auf 0.0.0.0, anders als Aether selbst. Das ist nötig, weil der Browser direkt hin muss – steht jetzt dabei, samt dem sauberen Weg über einen Reverse Proxy. Geprüft: go build/vet sauber, alle 234 Prüfungen der sieben Selbsttest-Suiten grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
301 lines
11 KiB
Go
301 lines
11 KiB
Go
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
|
||
|
||
// 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.
|
||
//
|
||
// Derselbe Wert geht über /api/v1/public/config an die Oberfläche, die das
|
||
// Skript daraufhin selbst nachlädt. Beides aus einer Quelle: so kann die
|
||
// CSP nicht einen anderen Ursprung erlauben als den geladenen.
|
||
//
|
||
// 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 != ""
|
||
}
|
||
|
||
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", ""),
|
||
|
||
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",
|
||
}
|
||
|
||
// 30 Tage, und die Frist **gleitet**: Sie zählt ab der letzten Benutzung,
|
||
// nicht ab dem Anmelden (siehe auth.SessionStore.Touch). Vorher stand hier
|
||
// ein Tag ab Anmeldung – wer täglich arbeitete, flog trotzdem täglich
|
||
// heraus. Ein Ablauf, der den Anwesenden trifft und nicht den Abwesenden,
|
||
// erkauft keine Sicherheit, sondern nur Ärger.
|
||
//
|
||
// Kürzer ist jederzeit einstellbar; bei einem gemeinsam genutzten Rechner
|
||
// wäre das sinnvoll.
|
||
maxAge, err := strconv.Atoi(getEnv("SESSION_MAX_AGE", "2592000"))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid SESSION_MAX_AGE: %w", err)
|
||
}
|
||
if maxAge <= 0 {
|
||
return nil, fmt.Errorf("SESSION_MAX_AGE must be greater than 0 (seconds)")
|
||
}
|
||
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
|
||
}
|