Files
Aether/backend/cmd/aether/main.go
T
marcandClaude Opus 5 55dcf517f2 feat(admin): Abgleich zwischen Platte und Datenbank
Aether fuehrt zwei Buecher: die Bytes auf der Platte und die Eintraege in
der Datenbank. Wer Dateien von aussen hineinlegt -- per SFTP oder aus
einer Sicherung zurueckgeholt --, erzeugt nur das eine. Die Datei liegt
da, Aether weiss nichts von ihr, und der GC haelt sie nach 24 Stunden
fuer einen Rest und loescht sie.

Neu deshalb internal/rescan, die Gegenrichtung zum GC, und ein Knopf
unter Admin -> Audit. Drei Faelle:

  Datei da, kein Eintrag    Eintrag anlegen, samt Ordnerkette,
                            Pruefsumme und Dateityp
  Eintrag da, Datei fort    Eintrag entfernen, dazu Vorschaubild und
                            fruehere Fassungen
  von aussen ersetzt        Groesse und Pruefsumme uebernehmen

Zwei Schritte, nie einer: Check liest nur, Apply aendert. Ein Knopf, der
beides in einem taete, gaebe niemandem die Gelegenheit, vorher
hinzusehen -- und was hier geloescht wird, ist nicht der Papierkorb.

Nicht angefasst werden .trash/ und .versions/. Beide liegen im selben
Baum, gehoeren aber dem System: Wuerden sie uebernommen, tauchten
geloeschte Dateien wieder auf und jede fruehere Fassung wuerde zu einer
eigenen Datei.

Zwei Fallen, die erst der Praxistest zeigte:

  - Der Dateityp muss aus Endung *oder Inhalt* kommen. Beim Hochladen
    schickt der Browser ihn mit; eine per SFTP abgelegte Datei bringt
    niemanden mit, der ihn kennt. Dazu hat das Alpine-Abbild keine
    /etc/mime.types, und Gos eingebaute Liste kennt nicht einmal .txt --
    jede Textdatei waere als application/octet-stream gelandet.
  - COALESCE($1, $2) ohne Cast: Postgres haelt die Parameter dann fuer
    Text und bricht mit "operator does not exist: uuid = text" ab. Die
    Ordnerkette wurde nie angelegt.

Die Pruefsumme wird trotz des vollstaendigen Lesevorgangs berechnet. Ein
Eintrag ohne sie waere ein Eintrag, dessen Beschaedigung beim
Herunterladen niemand bemerkt -- genau die Zusage, die Aether sonst
ueberall einhaelt.

scripts/rescan-selbsttest.py mit 20 Pruefungen in fuenf Gruppen, darunter
"Pruefen aendert nichts" und "Papierkorb bleibt unangetastet". Nebenbei
raeumt der Freigabe-Selbsttest jetzt auch seinen Papierkorb und nimmt
einen eindeutigen Kontonamen je Lauf -- ein abgebrochener Lauf blockierte
sonst jeden weiteren mit 409.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 01:10:25 +02:00

750 lines
30 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/rescan"
"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 {
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)
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)
// 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, 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)
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)
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)
}