Files
Aether/backend/cmd/aether/main.go
T
marcandClaude Opus 5 77bf16b27f feat(calendar): Termin einem Kalender zuordnen und umhaengen
Beim Anlegen war die Kalenderauswahl schon da (der oberste ist die
Vorgabe), beim Bearbeiten war sie festgestellt -- mit der Begruendung,
ein Umhaengen sei Loeschen und Neuanlegen unter neuer Kennung. Das
stimmt so nicht: In CalDAV wechselt dabei nur die Sammlung, UID und
Inhalt bleiben. Genau das macht der neue Endpunkt

    POST /api/v1/calendars/{id}/events/{uid}/move  {"calendar_id": …}

Er aendert nur die Zugehoerigkeit der Zeile und erhoeht das ctag auf
**beiden** Seiten. Mit nur einem holte ein Client die andere Haelfte
erst beim naechsten Vollabgleich, und der Termin stuende
zwischenzeitlich doppelt oder gar nicht im Telefon.

Bewusst ein eigener Endpunkt und kein weiteres Feld im PATCH: Ein Umzug
laesst sich auch ohne jede Aenderung am Termin ausloesen, und dafuer den
ganzen Termin mitschicken zu muessen waere eine Einladung, nebenbei
etwas zu ueberschreiben. Im Formular laeuft deshalb erst das PATCH und
dann der Umzug -- andersherum stuende der Termin nach einem Fehler
verschoben, aber unveraendert da, die verwirrendere Haelfte.

Dazu die Zieh-Geste: einen Termin auf einen Kalender in der
Seitenleiste ziehen, am Handy nach kurzem Gedrueckthalten. Ueber
Pointer-Events statt ueber die Drag-and-Drop-Schnittstelle des
Browsers -- die gibt es auf Android nicht, und genau dort soll die
Geste ja auch funktionieren. Ist die Kalenderliste zugeklappt (am Handy
der Regelfall), erscheint waehrend des Ziehens eine Leiste unten in
Daumennaehe, sonst liefe die Geste ins Leere.

Zwei Faelle, die sonst aergern: Wer in einen ausgeblendeten Kalender
zieht, bekommt ihn eingeblendet -- der Termin waere sonst scheinbar
spurlos verschwunden. Und der Klick, der einem Ziehen hinterherkommt,
wird verschluckt, statt den Termindialog zu oeffnen.

Eine Reihe zieht vollstaendig um; sie ist eine einzige Datei, kein
Buendel von Einzelterminen. Das sagt das Formular auch an.

Selbsttest um elf Pruefungen erweitert (36 gruen): Umzug angenommen,
Kennung bleibt, aus der Quelle weg und im Ziel da, ctag auf beiden
Seiten gestiegen, Umzug auf sich selbst folgenlos, fremder Kalender
404, Reihe vollstaendig umgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:07:36 +02:00

717 lines
29 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 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/sharing"
"aether/internal/storage"
"aether/internal/tags"
"aether/internal/thumbnails"
appwebdav "aether/internal/webdav"
"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/<token>`,
// `/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 {
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
}
http.ServeFile(w, r, index)
})
}
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)
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)
go 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.
go 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)
adminHandler := admin.NewHandler(adminStore, blobStore, auditor, collector, fileStore)
// 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, cfg.OfficeJWTSecret)
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)
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.Get("/files/{id}/preview.pdf", officeHandler.PreviewPDF)
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)
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)
// 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)
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
}
// Generate thumbnail in background – non-fatal if it fails.
go 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)
}