feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+622
View File
@@ -0,0 +1,622 @@
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)
}