Files
Aether/backend/cmd/aether/main.go
T
HoneyandClaude Opus 5 31a027c93a 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>
2026-08-24 09:25:36 +02:00

623 lines
24 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/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.
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)
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
}
}
// WebDAV
davHandler := appwebdav.NewHandler(authStore, fileStore, aclStore, blobStore)
// 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)
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 {
log.Info().Str("username", cfg.AdminUsername).Msg("admin account ready")
}
}
// ── 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"} {
chi.RegisterMethod(m)
}
r := chi.NewRouter()
r.Use(chimiddleware.RequestID)
r.Use(middleware.TrustedProxy(cfg))
r.Use(middleware.Logger)
r.Use(middleware.SecurityHeaders(officeFrameSrc))
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)
// 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"
// 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")
}
apps := cfg.ExternalApps
if apps == nil {
apps = []config.ExternalApp{} // nie null – das Frontend erwartet eine Liste
}
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,
"external_apps": apps,
// Damit die Oberflaeche den Menuepunkt „Edit in Office" gar nicht
// erst anbietet, wenn es keinen Office-Server gibt.
"office_enabled": officeHandler.Enabled(),
})
})
// 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)
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)
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)
// 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)
}