package main import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "os/signal" "path" "path/filepath" "strings" "syscall" "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" "github.com/google/uuid" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "aether/internal/acl" "aether/internal/admin" "aether/internal/audit" "aether/internal/auth" "aether/internal/avatar" "aether/internal/calendar" "aether/internal/documents" "aether/internal/files" "aether/internal/gc" "aether/internal/office" appoidc "aether/internal/oidc" "aether/internal/rescan" "aether/internal/sharing" "aether/internal/storage" "aether/internal/tags" "aether/internal/thumbnails" appwebdav "aether/internal/webdav" "aether/pkg/bg" "aether/pkg/config" appdb "aether/pkg/db" "aether/pkg/middleware" "aether/pkg/response" ) // spaHandler liefert statische Dateien aus und fällt für alles andere auf // index.html zurück. // // Nötig, weil das Frontend seine Adressen selbst verwaltet: `/s/`, // `/admin` und `/profile` existieren als Dateien nicht. Ohne Rückfalloption // beantwortet ein reiner FileServer sie mit 404 – ein frisch geteilter Link // landete beim Empfänger also auf einer Fehlerseite. Dass es bisher trotzdem // meist funktionierte, lag allein am Service Worker, der die Navigation // abfing; wer die Seite zum ersten Mal öffnet, hat den noch nicht. func spaHandler(root string) http.Handler { fs := http.FileServer(http.Dir(root)) index := filepath.Join(root, "index.html") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upath := path.Clean("/" + r.URL.Path) // Serverseitige Bereiche nie überdecken – dort ist ein 404 die richtige // Antwort und kein Grund, die Anwendung auszuliefern. for _, reserved := range []string{"/api/", "/dav/", "/share/", "/branding/", "/wopi/"} { if strings.HasPrefix(upath+"/", reserved) { http.NotFound(w, r) return } } if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(upath))); err == nil { setStaticCaching(w, upath) fs.ServeHTTP(w, r) return } // Nur echte Seitenaufrufe bekommen die Anwendung. Eine fehlende // JavaScript- oder Bilddatei soll als 404 sichtbar bleiben, statt // stillschweigend HTML zu liefern – das erzeugt sonst rätselhafte // Syntaxfehler in der Konsole statt einer klaren Meldung. if r.Method != http.MethodGet || !strings.Contains(r.Header.Get("Accept"), "text/html") { http.NotFound(w, r) return } setStaticCaching(w, "/index.html") http.ServeFile(w, r, index) }) } // setStaticCaching entscheidet, was der Browser behalten darf. // // Ohne diese Kopfzeilen setzt er selbst etwas fest: Fehlt Cache-Control, // schätzt er anhand von Last-Modified, wie lange eine Antwort wohl gilt. // Für index.html hieß das, dass ein Nutzer nach einem Update tagelang die // alte Anwendung sehen konnte – die dann Bausteine nachlädt, die es auf dem // Server nicht mehr gibt. Sichtbar wird das nicht als "veraltet", sondern // als rätselhafter Fehler mitten in der Anwendung. // // Die Aufteilung ist die übliche und ergibt sich aus den Dateinamen selbst: // // - /assets/… trägt einen Inhalts-Hash im Namen. Ändert sich der Inhalt, // ändert sich der Name. Solche Dateien darf der Browser für immer // behalten – "immutable" spart ihm sogar die Rückfrage. // - Alles mit festem Namen (index.html, sw.js, das Manifest) muss bei // jedem Aufruf nachgefragt werden. "no-cache" heißt nicht "nicht // speichern", sondern "vor dem Benutzen rückfragen": Mit ETag antwortet // der Server dann meist nur mit 304 und ein paar Byte. func setStaticCaching(w http.ResponseWriter, upath string) { if strings.HasPrefix(upath, "/assets/") { w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") return } w.Header().Set("Cache-Control", "no-cache") } func main() { cfg, err := config.Load() if err != nil { fmt.Fprintf(os.Stderr, "config error: %v\n", err) os.Exit(1) } if cfg.IsDev() { log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) } zerolog.SetGlobalLevel(zerolog.InfoLevel) // **Ohne diese Zeile schreibt jedes log.Ctx(ctx) ins Nichts.** // // zerolog.Ctx liefert einen *abgeschalteten* Logger, wenn im Kontext // keiner steckt und kein Standard gesetzt ist. Im Baum stehen 34 solcher // Aufrufe – darunter die Warnung, dass beim Zurückrollen einer Fassung ein // Blob verwaist ist, und die Meldung über eine beschädigte Datei beim // Download. Alle gingen bis zum 12.09.2026 verloren, und zwar lautlos: // Der Aufruf sieht richtig aus, und ausprobieren kann man ihn nur, indem // man den Fehler herbeiführt, den er melden soll. // // Gefunden beim Schreiben des Prüfsummen-Selbsttests – der neue // Logeintrag blieb aus, obwohl der Abbruch funktionierte. zerolog.DefaultContextLogger = &log.Logger log.Info().Str("env", cfg.AppEnv).Str("addr", cfg.ListenAddr).Str("public_url", cfg.PublicURL).Msg("Aether starting") ctx := context.Background() if err := appdb.Migrate(cfg.DSN()); err != nil { log.Fatal().Err(err).Msg("migrations failed") } log.Info().Msg("migrations up to date") pool, err := appdb.Connect(ctx, cfg.DSN()) if err != nil { log.Fatal().Err(err).Msg("database connection failed") } defer pool.Close() // ── Storage ─────────────────────────────────────────────────────────────── // Thumbnails are stored in the main blob store under the "thumbs/" prefix. blobStore, err := storage.NewLocalFS(cfg.StoragePath, cfg.TmpPath) if err != nil { log.Fatal().Err(err).Msg("blob storage init failed") } // ── Packages ────────────────────────────────────────────────────────────── auditor := audit.NewLogger(pool) aclStore := acl.NewStore(pool) aclHandler := acl.NewHandler(aclStore, auditor) authStore := auth.NewStore(pool) sessions := auth.NewSessionStore(cfg.SessionSecret, cfg.SessionMaxAge) fileStore := files.NewStore(pool) authHandler := auth.NewHandler(authStore, sessions, fileStore, auditor) tagStore := tags.NewStore(pool) tagHandler := tags.NewHandler(tagStore, aclStore) // ── Texterkennung ───────────────────────────────────────────────────────── // Bleibt nil, wenn abgeschaltet oder die Programme fehlen. Alles, was // docStore benutzt, prüft deshalb auf nil – eine Cloud ohne OCR ist eine // vollständige Cloud, nur ohne Volltextsuche. var docStore *documents.Store if cfg.OCREnabled { if ok, missing := documents.Available(); !ok { log.Warn().Strs("missing", missing). Msg("Texterkennung abgeschaltet: Programme fehlen im Image") } else { docStore = documents.NewStore(pool) worker := documents.NewWorker(docStore, blobStore, &documents.Extractor{ Languages: cfg.OCRLanguages, MaxPages: cfg.OCRMaxPages, Timeout: cfg.OCRTimeout, TmpDir: cfg.TmpPath, }, log.Logger) // Die Schleife darin sichert jeden Auftrag einzeln ab (siehe // worker.Run). Diese Hülle fängt, was **davor** passiert – das // Aufräumen der Zwischendateien beim Start. bg.Go("OCR-Arbeiter", func() { worker.Run(ctx) }) // Bestand nachtragen: Beim ersten Start nach dem Einbau hat noch // keine einzige Datei einen Auftrag. Ohne das bliebe alles // unauffindbar, was vorher hochgeladen wurde – und das ist am // Anfang der gesamte Bestand. bg.Go("Bestand nachtragen", func() { n, err := docStore.EnqueueMissing(ctx, 5000) if err != nil { log.Warn().Err(err).Msg("Bestand nachtragen fehlgeschlagen") return } if n > 0 { log.Info().Int("files", n).Msg("Bestandsdateien zur Texterkennung eingeplant") } // Einträge nach älterer Regel erneut erkennen. Siehe // documents.ExtractVersion – die Seitenmarke im Text kam // nachträglich dazu, und ohne sie kann der Betrachter bei // einem Scan die Fundstelle nicht auf einer Seite verorten. m, err := docStore.EnqueueOutdated(ctx, 5000) if err != nil { log.Warn().Err(err).Msg("veraltete Texteintraege nachtragen fehlgeschlagen") return } if m > 0 { log.Info().Int("files", m).Int("version", documents.ExtractVersion). Msg("veraltete Texteintraege zur erneuten Erkennung eingeplant") } }) } } // fileHandler bekommt docStore mit: Der PDF-Betrachter holt sich darueber // den erkannten Text eines Scans, in Seiten zerlegt. // Wie viele frühere Fassungen aufgehoben werden. Einmal hier gesetzt, // danach nur gelesen – siehe files.SetMaxVersionsKept. files.SetMaxVersionsKept(cfg.MaxVersionsKept) fileHandler := files.NewHandler(fileStore, blobStore, auditor, aclStore, tagStore, docStore) shareStore := sharing.NewStore(pool) shareHandler := sharing.NewHandler(shareStore, fileStore, blobStore, auditor) collector := gc.New(pool, blobStore) adminStore := admin.NewStore(pool) // Der Abgleich braucht dieselben Bausteine wie der GC, geht aber in die // andere Richtung: von der Platte in die Datenbank statt umgekehrt. rescanner := rescan.New(pool, fileStore, blobStore) adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore, rescanner) // docHandler ist auch bei abgeschaltetem OCR gültig – er meldet dann // schlicht enabled:false. Eine Methode auf einem nil-Zeiger geht in Go // gut, solange sie ihn nicht dereferenziert; Status prüft genau darauf. docHandler := documents.NewHandler(docStore, func(r *http.Request) bool { u := auth.UserFromContext(r.Context()) return u != nil && u.IsAdmin }, func(r *http.Request) uuid.UUID { if u := auth.UserFromContext(r.Context()); u != nil { return u.ID } return uuid.Nil }) // ── Office ──────────────────────────────────────────────────────────────── // Bleibt nil, wenn OFFICE_PROVIDER leer ist. Ein nil-Handler meldet der // Oberfläche schlicht „aus"; die Anwendung ist ohne Office vollständig. var officeHandler *office.Handler if cfg.OfficeEnabled() { client, err := office.NewClient(cfg.OfficeProvider, cfg.OfficeURL, cfg.OfficePublicURL, cfg.OfficeAllowedHosts, cfg.OfficeInsecureTLS) if err != nil { // Kein Fatal: Eine falsche Office-Adresse soll die Cloud nicht am // Starten hindern. Dateien bleiben erreichbar, nur das Bearbeiten // fehlt – und der Log sagt, warum. log.Warn().Err(err).Msg("Office abgeschaltet: Konfiguration fehlerhaft") } else { officeHandler = office.NewHandler(client, office.NewSigner(cfg.SessionSecret), fileStore, blobStore, aclStore, cfg.PublicURL, cfg.OfficeWopiURL) log.Info().Str("provider", cfg.OfficeProvider).Str("url", cfg.OfficeURL). Msg("Office-Bearbeitung aktiv") } } // Der Ursprung, den die Sicherheitsrichtlinie als Rahmen zulassen muss. // Leer, wenn kein Office-Server eingerichtet ist – dann bleibt die // Richtlinie so eng wie zuvor. officeFrameSrc := "" if officeHandler.Enabled() { officeFrameSrc = cfg.OfficePublicURL if officeFrameSrc == "" { officeFrameSrc = cfg.OfficeURL } } avatarHandler := avatar.NewHandler(authStore, blobStore) // WebDAV davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore) // CalDAV calStore := calendar.NewStore(pool, aclStore) calHandler := calendar.NewHandler(authStore, calStore) calAPIHandler := calendar.NewAPIHandler(calStore) // OIDC (optional – disabled if OIDC_ISSUER not set) oidcHandler, err := appoidc.New(ctx, appoidc.Config{ Issuer: cfg.OIDCIssuer, ClientID: cfg.OIDCClientID, ClientSecret: cfg.OIDCClientSecret, RedirectURL: cfg.OIDCRedirectURL, LinkByUsername: cfg.OIDCLinkByUsername, }, authStore, sessions, blobStore) if err != nil { log.Fatal().Err(err).Msg("OIDC init failed") } // ── Admin seed ──────────────────────────────────────────────────────────── if cfg.AdminUsername != "" && cfg.AdminPassword != "" { if err := seedAdmin(ctx, authStore, cfg.AdminUsername, cfg.AdminPassword); err != nil { log.Warn().Err(err).Msg("admin seed skipped") } else { // Die **Länge** mit ins Log, nicht das Passwort. // // Anlass: Ein `$` in der .env verschluckt bei Docker Compose // lautlos den Rest des Wertes – `geheim$wort` kommt als `geheim` // an. Angelegt wird dann ein Konto mit einem Passwort, das // niemand kennt, und die Anmeldung scheitert mit „invalid // credentials", obwohl alles richtig eingetippt ist. Gemessen, // und im Log war nichts davon zu sehen. // // Eine Zahl verrät nichts Brauchbares, macht den verschluckten // Wert aber sofort sichtbar. log.Info(). Str("username", cfg.AdminUsername). Int("password_length", len(cfg.AdminPassword)). Msg("admin account ready") if len(cfg.AdminPassword) < 12 { log.Warn(). Int("password_length", len(cfg.AdminPassword)). Msg("ADMIN_PASSWORD ist auffaellig kurz – enthaelt es ein $? " + "In der .env muss es als $$ geschrieben oder der Wert in " + "'einfache Anfuehrungszeichen' gesetzt werden, sonst " + "schneidet Docker Compose dort ab.") } } } // ── Blob-GC ─────────────────────────────────────────────────────────────── // Räumt Blobs weg, auf die keine Datenbankzeile mehr zeigt. Läuft im // Hintergrund; jeder Lauf, der etwas entfernt, landet im Audit-Log. collector.Start(ctx, func(res gc.Result) { auditor.Log(ctx, audit.Event{ ActorName: "system", Action: audit.ActionGarbageCollect, Detail: fmt.Sprintf("%d orphaned blobs removed, %d bytes freed", res.Deleted, res.FreedBytes), }) }) // ── Router ──────────────────────────────────────────────────────────────── // chi lehnt Methoden ab, die es nicht kennt – ohne diese Registrierung // beantwortet der Router jedes PROPFIND & Co. mit 405, bevor der // WebDAV-Handler (und damit dessen Auth) überhaupt erreicht wird. for _, m := range []string{"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "LOCK", "UNLOCK", "REPORT", "MKCALENDAR"} { chi.RegisterMethod(m) } r := chi.NewRouter() r.Use(chimiddleware.RequestID) r.Use(middleware.TrustedProxy(cfg)) r.Use(middleware.Logger) r.Use(middleware.SecurityHeaders(middleware.CSP{ FrameSrc: []string{officeFrameSrc}, // Die HoneyOS-Leiste kommt als Skript von dort und holt sich von dort // auch ihre App-Liste. Leer = keine Leiste und die alte, engere Regel. ScriptSrc: []string{cfg.HoneybarOrigin}, ConnectSrc: []string{cfg.HoneybarOrigin}, })) r.Use(chimiddleware.Recoverer) r.Get("/health", func(w http.ResponseWriter, r *http.Request) { response.JSON(w, http.StatusOK, map[string]string{"status": "ok", "version": "0.2.0"}) }) // Logo ohne Anmeldung – die Login-Seite zeigt es, bevor jemand angemeldet ist. r.Get("/branding/logo", adminHandler.ServeLogo) r.Get("/branding/icon/{size}", adminHandler.ServeAppIcon) // Das Manifest beschreibt die installierbare App. Es kommt aus der // Datenbank, damit Name, Farbe und Symbol dem Admin-Bereich folgen. r.Get("/manifest.webmanifest", adminHandler.ServeManifest) // WOPI – **bewusst ausserhalb der Sitzungs- und CSRF-Kette.** Diese Aufrufe // kommen vom Office-Server, nicht aus einem Browser; ihre Berechtigung ist // allein das kurzlebige Token, das an genau eine Datei gebunden ist. if officeHandler.Enabled() { r.Get("/wopi/files/{id}", officeHandler.CheckFileInfo) r.Get("/wopi/files/{id}/contents", officeHandler.GetFile) r.Post("/wopi/files/{id}/contents", officeHandler.PutFile) } // Public share API – completely outside /api/v1 to avoid auth middleware conflict r.Get("/share/{token}", shareHandler.GetPublicInfo) r.Post("/share/{token}/download", shareHandler.Download) // WebDAV endpoint – own auth (Basic Auth), outside /api/v1. // Kein StripPrefix: der xwebdav.Handler entfernt "/dav" selbst über sein // Prefix-Feld. Beides zusammen strippt doppelt und liefert nur noch 404. r.Mount("/dav", davHandler) // CalDAV endpoint – eigene Basic Auth (App-Passwörter), wie /dav. // .well-known/caldav liegt bewusst außerhalb von /caldav: RFC 6764 sieht // diesen Pfad an der Wurzel vor, Clients wie DAVx5 fragen dort zuerst an. r.Mount("/caldav", calHandler) r.Get("/.well-known/caldav", calHandler.ServeWellKnown) // Public config (no auth) – used by login page to discover OIDC availability r.Get("/api/v1/public/config", func(w http.ResponseWriter, r *http.Request) { cloudName := "Aether" oidcLabel := "" if oidcHandler.IsEnabled() { oidcLabel = cfg.OIDCButtonLabel } // Farben gehören hierher (statt hinter die Auth), damit schon die // Login-Seite im eingestellten Farbschema erscheint. var primaryColor, accentColor, logoURL, faviconURL, appleTouchURL string if s, err := adminStore.GetAllSettings(r.Context()); err == nil { if n, ok := s["cloud_name"]; ok && n != "" { cloudName = n } primaryColor = s["primary_color"] accentColor = s["accent_color"] logoURL = admin.LogoURL(s["logo_storage_key"]) // Dasselbe Symbol in zwei Größen: klein für den Tab, groß für den // Startbildschirm von iOS. faviconURL = admin.AppIconURL(s["app_icon_storage_key"], "64") appleTouchURL = admin.AppIconURL(s["app_icon_storage_key"], "192") } response.JSON(w, http.StatusOK, map[string]any{ "oidc_enabled": oidcHandler.IsEnabled(), "cloud_name": cloudName, "primary_color": primaryColor, "accent_color": accentColor, "logo_url": logoURL, "favicon_url": faviconURL, "apple_touch_icon_url": appleTouchURL, // Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht // erst anbietet, wenn es keinen Office-Server gibt. "office_enabled": officeHandler.Enabled(), // Welcher Anbieter – die Oberflaeche entscheidet daran, ob ein // Dokument am Telefon im Editor oder als PDF aufgeht. Collabora // hat eine brauchbare Handy-Ansicht, OnlyOffice baut dort die // PC-Oberflaeche auf. Ohne Office-Server ein leerer Wert. "office_provider": func() string { if officeHandler.Enabled() { return cfg.OfficeProvider } return "" }(), // Damit der Verlaufs-Dialog dieselbe Zahl nennt, die der Server // anwendet. Vorher stand sie zweimal im Quelltext, mit einem // Kommentar „muss zusammenpassen" – das ist keine Zusicherung. "max_versions_kept": cfg.MaxVersionsKept, // Beschriftung des SSO-Knopfes. Nur mitgeschickt, wenn OIDC an // ist – sonst gibt der Endpunkt eine Einstellung preis, die // niemanden etwas angeht. "oidc_button_label": oidcLabel, // Die HoneyOS-Leiste laedt die Oberflaeche selbst nach. Zur // Laufzeit statt beim Bauen, damit ein fertiges Abbild ueber die // .env umgezogen werden kann — und damit derselbe Ursprung // gilt, den die CSP erlaubt. "honeybar_origin": cfg.HoneybarOrigin, }) }) // Auth routes – login/logout strictly rate-limited, /me is lenient r.Route("/api/v1/auth", func(r chi.Router) { r.With(httprate.LimitByIP(20, time.Minute)).Post("/login", authHandler.Login) r.With(httprate.LimitByIP(20, time.Minute)).Post("/logout", authHandler.Logout) r.With(auth.RequireAuth(sessions, authStore)).Get("/me", authHandler.Me) r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Patch("/profile", authHandler.UpdateProfile) r.With(auth.RequireAuth(sessions, authStore), auth.CSRF).Post("/change-password", authHandler.ChangePassword) // OIDC routes (only wired if oidcHandler != nil) if oidcHandler != nil { r.With(httprate.LimitByIP(20, time.Minute)).Get("/oidc/login", oidcHandler.Login) r.Get("/oidc/callback", oidcHandler.Callback) } }) // Protected API r.Route("/api/v1", func(r chi.Router) { r.Use(auth.RequireAuth(sessions, authStore)) r.Use(auth.CSRF) r.Use(httprate.LimitByIP(2000, time.Minute)) // Files // Sammel-Download als Archiv. POST, weil eine Auswahl beliebig lang sein // kann – als Adresse würde sie an der Längengrenze scheitern. r.Post("/download/zip", fileHandler.DownloadZip) r.Post("/files", afterUpload(fileHandler, fileStore, blobStore, docStore)) r.Get("/files", fileHandler.List) // Suche über Namen von Ordnern und Dateien, begrenzt auf das, was der // Anfragende erreichen darf. r.Get("/search", fileHandler.Search) r.Get("/files/{id}/download", fileHandler.Download) r.Get("/files/{id}/thumbnail", fileHandler.GetThumbnail) // Erkannter Text, in Seiten zerlegt – fuer die Suche im PDF-Betrachter. r.Get("/files/{id}/text", fileHandler.TextPages) // Sitzung fuer den Office-Rahmen: Hier entsteht das WOPI-Token, im // angemeldeten Zusammenhang. r.Get("/office/session/{id}", officeHandler.Session) // Dasselbe Dokument als PDF – zum Ansehen, ohne den Editor. Am // Telefon der Normalfall. r.Patch("/files/{id}/versioning", fileHandler.SetVersioning) r.Get("/files/{id}/versions", fileHandler.ListVersions) r.Get("/files/{id}/versions/{versionId}/download", fileHandler.DownloadVersion) r.Post("/files/{id}/versions/{versionId}/restore", fileHandler.RestoreVersion) r.Delete("/files/{id}", fileHandler.Delete) r.Patch("/files/{id}/rename", fileHandler.Rename) r.Patch("/files/{id}/move", fileHandler.Move) r.Post("/files/{id}/share", shareHandler.Create) // Folders r.Post("/folders", fileHandler.CreateFolder) r.Patch("/folders/{id}/rename", fileHandler.RenameFolder) r.Patch("/folders/{id}/move", fileHandler.MoveFolder) r.Delete("/folders/{id}", fileHandler.DeleteFolder) // Favorites r.Get("/favorites", fileHandler.ListFavorites) r.Post("/favorites", fileHandler.AddFavorite) r.Delete("/favorites/{folderId}", fileHandler.RemoveFavorite) r.Patch("/favorites/{folderId}/expand", fileHandler.SetFavoriteExpanded) r.Patch("/favorites/reorder", fileHandler.ReorderFavorites) // Trash // Etiketten – siehe internal/tags. Die Rechteprüfung steckt in den // Abfragen des Stores, nicht in einer Middleware davor. r.Get("/tags", tagHandler.List) r.Post("/tags", tagHandler.Create) r.Patch("/tags/{id}", tagHandler.Update) r.Delete("/tags/{id}", tagHandler.Delete) r.Get("/tags/{id}/files", tagHandler.Files) r.Put("/files/{id}/tags", tagHandler.SetForFile) // Absender: dieselbe Form wie die Etiketten, aber genau einer je Datei. // Gesetzt wird er ueber /files/{id}/tags mit – ein Dialog, ein Vorgang. r.Get("/correspondents", tagHandler.ListCorrespondents) r.Post("/correspondents", tagHandler.CreateCorrespondent) r.Patch("/correspondents/{id}", tagHandler.UpdateCorrespondent) r.Delete("/correspondents/{id}", tagHandler.DeleteCorrespondent) r.Get("/correspondents/{id}/files", tagHandler.CorrespondentFiles) r.Get("/trash", fileHandler.ListTrash) r.Post("/trash/{id}/restore", fileHandler.RestoreFile) r.Delete("/trash", fileHandler.EmptyTrash) r.Delete("/trash/{id}", fileHandler.HardDelete) // Shares r.Get("/shares", shareHandler.List) r.Delete("/shares/{id}", shareHandler.Delete) // Interne Freigaben r.Get("/shared-with-me", aclHandler.SharedWithMe) r.Get("/share-recipients", aclHandler.Recipients) r.Get("/folders/{id}/acl", aclHandler.ListFolderACL) r.Post("/folders/{id}/acl", aclHandler.GrantFolderACL) r.Get("/files/{id}/acl", aclHandler.ListFileACL) r.Post("/files/{id}/acl", aclHandler.GrantFileACL) // Kalender liegen in derselben Rechtetabelle – deshalb dieselben // Endpunkte, und /acl/{id} widerruft alle drei Arten. r.Get("/calendars/{id}/acl", aclHandler.ListCalendarACL) r.Post("/calendars/{id}/acl", aclHandler.GrantCalendarACL) r.Delete("/acl/{id}", aclHandler.RevokeACL) // App passwords (WebDAV/sync clients) // Profilbild eines Kontos. Sichtbar für jedes angemeldete Konto – // es steht ohnehin an Freigaben und in Nutzerlisten. r.Get("/users/{id}/avatar", avatarHandler.Serve) // Kalender – die Termine selbst laufen über CalDAV (/caldav/), das // hier ist nur Verwaltung: mehrere Kalender, umbenennen, Farbe, // löschen, sowie Export/Import als .ics. r.Get("/calendars", calAPIHandler.List) r.Post("/calendars", calAPIHandler.Create) r.Patch("/calendars/{id}", calAPIHandler.Update) r.Delete("/calendars/{id}", calAPIHandler.Delete) r.Get("/calendars/{id}/export", calAPIHandler.Export) r.Post("/calendars/{id}/import", calAPIHandler.Import) r.Get("/calendars/{id}/events", calAPIHandler.ListEvents) r.Post("/calendars/{id}/events", calAPIHandler.CreateEvent) r.Patch("/calendars/{id}/events/{uid}", calAPIHandler.UpdateEvent) r.Delete("/calendars/{id}/events/{uid}", calAPIHandler.DeleteEvent) r.Post("/calendars/{id}/events/{uid}/move", calAPIHandler.MoveEvent) r.Get("/app-passwords", authHandler.ListAppPasswords) r.Post("/app-passwords", authHandler.CreateAppPassword) r.Delete("/app-passwords/{id}", authHandler.DeleteAppPassword) // Settings (readable by all authenticated users for theming) r.Get("/admin/settings", adminHandler.GetSettings) // Admin-only routes (server-side admin check inside each handler) r.Patch("/admin/settings", adminHandler.UpdateSettings) r.Get("/admin/users", adminHandler.ListUsers) r.Post("/admin/users", adminHandler.CreateUser) r.Patch("/admin/users/{id}", adminHandler.UpdateUser) r.Post("/admin/users/{id}/reset-password", adminHandler.ResetPassword) r.Delete("/admin/users/{id}", adminHandler.DeleteUser) r.Get("/admin/audit", adminHandler.ListAudit) r.Get("/admin/audit/actions", adminHandler.ListAuditActions) r.Post("/admin/gc", adminHandler.RunGC) r.Post("/admin/rescan", adminHandler.Rescan) // Was Vorschaubilder, Profilbilder und gewandelte Dokumente belegen – // Platz, der in keinem Kontingent auftaucht. r.Get("/admin/storage/derived", adminHandler.DerivedStorage) // Immer erreichbar, auch wenn OCR aus ist – der Handler antwortet dann // mit enabled:false. Eine Route, die je nach Konfiguration 404 liefert, // wäre für die Oberfläche nicht von einem Fehler zu unterscheiden. r.Get("/admin/ocr", docHandler.Status) // Derselbe Stand, aber nur für die eigenen Dateien – ohne Adminrechte. r.Get("/ocr", docHandler.MyStatus) r.Post("/admin/branding/logo", adminHandler.UploadLogo) r.Delete("/admin/branding/logo", adminHandler.DeleteLogo) r.Post("/admin/branding/icon", adminHandler.UploadAppIcon) r.Delete("/admin/branding/icon", adminHandler.DeleteAppIcon) r.Get("/admin/group-folders", adminHandler.ListGroupFolders) r.Post("/admin/group-folders", adminHandler.CreateGroupFolder) r.Delete("/admin/group-folders/{id}", adminHandler.DeleteGroupFolder) r.Get("/admin/groups", adminHandler.ListGroups) r.Post("/admin/groups", adminHandler.CreateGroup) r.Patch("/admin/groups/{id}", adminHandler.UpdateGroup) r.Delete("/admin/groups/{id}", adminHandler.DeleteGroup) r.Get("/admin/groups/{id}/members", adminHandler.ListGroupMembers) r.Post("/admin/groups/{id}/members", adminHandler.AddGroupMember) r.Delete("/admin/groups/{id}/members/{userId}", adminHandler.RemoveGroupMember) }) // Serve built frontend r.Handle("/*", spaHandler("./static")) srv := &http.Server{ Addr: cfg.ListenAddr, Handler: r, ReadHeaderTimeout: 15 * time.Second, WriteTimeout: 60 * time.Minute, // großzügig für Multi-GB Up-/Downloads IdleTimeout: 60 * time.Second, } done := make(chan struct{}) quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) // Die einzige nackte Goroutine, die bleibt – und das mit Absicht: Sie // wartet auf ein Signal und schließt `done`. Ein recover brächte hier // nichts, denn stürbe sie vor dem close, wartete main ohnehin für immer. // Sie berührt außerdem keine fremden Daten. go func() { <-quit log.Info().Msg("shutting down...") shutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutCtx) close(done) }() log.Info().Str("addr", srv.Addr).Msg("listening") if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatal().Err(err).Msg("server error") } <-done log.Info().Msg("stopped") } // afterUpload hängt sich hinter den Upload und stößt an, was danach im // Hintergrund passieren soll: das Vorschaubild und – bei Dokumenten – die // Texterkennung. // // Der Unterschied zwischen beiden ist Absicht: Das Vorschaubild entsteht hier // direkt in einer Goroutine, die Texterkennung wird nur in die Warteschlange // eingetragen. Ein verlorenes Vorschaubild fällt sofort auf und ist in // Millisekunden neu gemacht; verlorener Text fällt nie auf – man sucht später // vergeblich und weiß nicht, warum. Deshalb ist er die Datenbankzeile wert. // // docStore darf nil sein (OCR abgeschaltet oder Programme fehlen). func afterUpload(fh *files.Handler, fileStore *files.Store, st storage.Storage, docStore *documents.Store) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { crw := &captureWriter{ResponseWriter: w} fh.Upload(crw, r) if crw.status != http.StatusCreated { return } // Parse file ID and MIME type from captured JSON response (wrapped in {"data":{...}}). var wrapper struct { Data struct { ID uuid.UUID `json:"id"` Name string `json:"name"` MimeType string `json:"mime_type"` } `json:"data"` } if err := json.Unmarshal(crw.body.Bytes(), &wrapper); err != nil || wrapper.Data.ID == uuid.Nil { return } created := wrapper.Data if docStore != nil && documents.Supported(created.MimeType, created.Name) { // Eigener Kontext: Der Anfrage-Kontext endet, sobald die Antwort // draußen ist – der Eintrag ginge dann ins Leere. ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second) if err := docStore.Enqueue(ctx, created.ID); err != nil { log.Warn().Err(err).Str("file_id", created.ID.String()). Msg("Texterkennung konnte nicht eingeplant werden") } cancel() } if !thumbnails.SupportedMIME(created.MimeType) { return } // Vorschaubild im Hintergrund – ein Fehlschlag ist nicht schlimm. // // **Über bg.Go, nicht als nackte Goroutine.** Hier wird eine // hochgeladene Datei dekodiert. Chis Recoverer hängt am // Anfragebehandler und sieht davon nichts; eine Panik im Dekodierer // nähme sonst den ganzen Prozess mit – ausgelöst durch einen // gewöhnlichen Upload. bg.Go("Vorschaubild", func() { ctx := context.Background() f, err := fileStore.GetFileByID(ctx, created.ID) if err != nil { return } rc, err := st.Get(ctx, f.StorageKey) if err != nil { return } defer rc.Close() if err := thumbnails.Generate(ctx, st, created.ID.String(), created.MimeType, rc); err != nil { log.Warn().Err(err).Str("file_id", created.ID.String()).Msg("thumbnail generation failed") return } _ = fileStore.UpdateThumbnail(ctx, created.ID, true) }) } } func seedAdmin(ctx context.Context, store *auth.Store, username, password string) error { exists, err := store.AdminExists(ctx) if err != nil { return fmt.Errorf("check admin: %w", err) } if exists { return fmt.Errorf("admin already exists, skipping seed") } hash, err := auth.HashPassword(password) if err != nil { return fmt.Errorf("hash password: %w", err) } _, err = store.Create(ctx, username, hash, true) return err } // captureWriter buffers the response body while forwarding to the real ResponseWriter. type captureWriter struct { http.ResponseWriter status int body bytes.Buffer } func (cw *captureWriter) WriteHeader(status int) { cw.status = status cw.ResponseWriter.WriteHeader(status) } func (cw *captureWriter) Write(b []byte) (int, error) { cw.body.Write(b) return cw.ResponseWriter.Write(b) }