FDN-02/FDN-03/FDN-07/FDN-08: Migrations-Rollback, Objekt-Storage-Interface, go.sum-Fix, Observability
- 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:
+83
-1
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user