FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled

- FDN-02: Rollback-fähige Down-Migrationen (024-026), archivdms seed dev CLI
- FDN-03: internal/objectstore Interface + lokaler WORM-Treiber, signierte Download-URLs
- FDN-07: go.mod/go.sum vervollständigt (fehlender go-ldap/v3-Eintrag), CI-Pipeline (.gitea/workflows/ci.yml, bereits in FDN-01 committet) damit lauffähig
- FDN-08: Request-ID-Middleware, /metrics-Endpoint, Panic-Recovery, Login/Logout/Me technisches Logging inkl. Access-Log je Anfrage
This commit is contained in:
2026-08-11 22:27:52 +02:00
parent 9a24ea29e1
commit 89de794356
30 changed files with 2688 additions and 113 deletions
+83 -1
View File
@@ -6,10 +6,13 @@ package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"time"
@@ -19,6 +22,7 @@ import (
"archivdms/internal/ldapauth"
"archivdms/internal/ldapstore"
"archivdms/internal/mailer"
"archivdms/internal/objectstore"
"archivdms/internal/ocr"
"archivdms/internal/pagesplit"
"archivdms/internal/storage"
@@ -46,6 +50,10 @@ type Server struct {
logger *slog.Logger
mux *http.ServeMux
ocr *ocr.Extractor
// objects is the WORM object-storage driver (internal/objectstore): the
// only place archived files are written, read or unlinked. Wired by
// SetStorageConfig/SetObjectStore.
objects objectstore.Store
thumbs *thumbnail.Generator
// pagesplitter performs barcode separator-page splitting of multi-page PDF
// uploads before archival (internal/pagesplit). May be nil / disabled, in
@@ -66,6 +74,15 @@ type Server struct {
// (per client IP) to blunt token/password enumeration.
shareLimiter *ipRateLimiter
// metrics ist die prozesslokale Metrik-Registry (FDN-08,
// internal/api/observability.go). Kein globaler Zustand: hängt am Server.
metrics *metricsRegistry
// baseHandler ist die in New() gebaute Middleware-Kette um s.mux
// (requestID -> metrics -> recover). Einmal gebaut, danach nur gelesen —
// kein Lazy-Init in ServeHTTP (Data Race).
baseHandler http.Handler
// accountingLimiter rate-limits the Bearer-key Buchhaltungs-Pull-API
// (per client IP) to blunt API-key guessing. Separate bucket set from
// shareLimiter so a busy accounting client cannot starve share downloads.
@@ -76,8 +93,46 @@ type Server struct {
// paths, max upload size) into the API server. Needed by
// handleUploadDocument, which cannot rely solely on the storage.Store
// (that only knows its own base dir, not the inbox/ocr-tmp layout).
//
// It also constructs the object-storage driver (internal/objectstore), the
// single place where archived files are written, read and unlinked. Call
// SetFQDN before this if generated signed URLs should be absolute.
func (s *Server) SetStorageConfig(cfg config.StorageConfig) {
s.storageCfg = cfg
secret := s.cfg.Secret
if strings.TrimSpace(secret) == "" {
// No master secret configured: fall back to an ephemeral, per-process
// key so file access keeps working; signed URLs then simply do not
// survive a restart. Never fail startup over this.
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
secret = strconv.FormatInt(time.Now().UnixNano(), 36)
} else {
secret = hex.EncodeToString(buf)
}
s.logger.Warn("objectstore: api.secret unset, using ephemeral signing key")
}
driver, err := objectstore.NewLocalStore(cfg, secret, s.publicBaseURL())
if err != nil {
s.logger.Error("objectstore init failed", "err", err)
return
}
s.objects = driver
}
// SetObjectStore overrides the object-storage driver (tests / alternative
// wiring). Normally set implicitly by SetStorageConfig.
func (s *Server) SetObjectStore(o objectstore.Store) {
s.objects = o
}
// publicBaseURL is the origin used for generated links. Empty FQDN yields
// site-relative URLs.
func (s *Server) publicBaseURL() string {
if strings.TrimSpace(s.fqdn) == "" {
return ""
}
return "https://" + strings.TrimSuffix(strings.TrimSpace(s.fqdn), "/")
}
// SetOCR wires the OCR extractor into the API server. May be nil, in which
@@ -150,8 +205,10 @@ func New(
shareLimiter: newIPRateLimiter(20, 1.0),
// Batch pulls are legitimate here: 60 requests burst, refilled at 5/sec.
accountingLimiter: newIPRateLimiter(60, 5.0),
metrics: newMetricsRegistry(),
}
s.routes()
s.baseHandler = s.requestIDMiddleware(s.metricsMiddleware(s.recoverMiddleware(s.mux)))
return s
}
@@ -168,6 +225,10 @@ func (s *Server) authAdmin(h http.HandlerFunc) http.HandlerFunc {
func (s *Server) routes() {
s.mux.HandleFunc("GET /api/health", s.handleHealth)
s.mux.HandleFunc("GET /api/version", s.handleVersion)
// Prometheus-Scrape-Endpunkt (FDN-08, internal/api/metrics_handlers.go).
// Bewusst ohne s.auth — ein Scraper hat keine Session; der Zugriff wird
// stattdessen per Quell-IP begrenzt (loopback + api.metrics_allowed_ips).
s.mux.HandleFunc("GET /metrics", s.handleMetrics)
s.mux.HandleFunc("POST /api/auth/login", s.handleLogin)
s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe))
@@ -377,6 +438,12 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /public/share/{token}", s.handlePublicShareMeta)
s.mux.HandleFunc("POST /public/share/{token}/download", s.handlePublicShareDownload)
// Signed, time-limited download URLs (internal/api/signed_url_handlers.go).
// Issuing is authenticated + tenant-scoped; redeeming runs WITHOUT s.auth
// because the HMAC signature in the query string is the credential.
s.mux.HandleFunc("POST /api/documents/{id}/signed-url", s.auth(s.handleCreateDocumentSignedURL))
s.mux.HandleFunc("GET /public/files", s.handleSignedFileDownload)
// Buchhaltungs-Pull-API (internal/api/accounting_handlers.go).
// Key administration runs on the normal session auth and is domain_admin-only
// (a key grants tenant-wide read access to archived documents).
@@ -417,8 +484,23 @@ func (s *Server) routes() {
}
// ServeHTTP implements http.Handler.
//
// Die Basis-Middleware-Kette (FDN-08) liegt bewusst hier und nicht an den
// einzelnen Routen, damit sie ausnahmslos für JEDE Anfrage gilt — auch für
// /public/*, /metrics und nicht gefundene Pfade:
//
// requestID -> metrics -> recover -> ServeMux
//
// Reihenfolge: requestID zuerst, damit Metrik- und Panic-Log die
// Korrelations-ID haben; recover innen, damit der 500 noch über den
// statusRecorder der Metrik-Middleware läuft und dort gezählt wird.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
if s.baseHandler == nil {
// Server wurde nicht über New() gebaut (Tests): ohne Kette bedienen.
s.mux.ServeHTTP(w, r)
return
}
s.baseHandler.ServeHTTP(w, r)
}
// --- system handlers ---