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>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
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
}