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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user