Files
archivdms/internal/api/signed_url_handlers.go
T
patrick 89de794356
CI / Backend (go vet, go test -cover) (push) Has been cancelled
CI / Frontend (ESLint, tsc, next build) (push) Has been cancelled
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
2026-08-11 22:27:52 +02:00

163 lines
6.3 KiB
Go

// Signed, time-limited download URLs for archived documents (FDN-03,
// internal/objectstore). Same principle as the external share links
// (share_handlers.go / public_share_handlers.go) — unguessable credential in
// the link, mandatory hard expiry, IP rate limiting, audit trail — but
// stateless: the credential is an HMAC-SHA256 signature over
// tenant|document|expiry instead of a DB row.
//
// POST /api/documents/{id}/signed-url issue a link (authenticated, tenant-scoped)
// GET /public/files?t=&d=&exp=&sig= redeem it (no session, signature is the credential)
//
// Use share links when a document is handed to an external party with its own
// lifecycle (revoke, password, access cap); use signed URLs for short-lived
// machine access to the file itself (viewer, export job) without a session
// cookie. Nothing here bypasses tenant scoping: the tenant id is taken from
// the signed payload and every lookup still filters on it.
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"path/filepath"
"strconv"
"time"
"archivdms/internal/audit"
"archivdms/internal/objectstore"
)
// createSignedURLRequest is the optional POST body. TTLMinutes overrides the
// configured default validity (storage.signed_url_ttl_minutes); it is capped
// at 24 hours so no effectively unbounded link can be minted.
type createSignedURLRequest struct {
TTLMinutes int `json:"ttl_minutes,omitempty"`
}
// createSignedURLResponse is returned to the caller.
type createSignedURLResponse struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expires_at"`
}
// maxSignedURLTTL caps a caller-supplied validity.
const maxSignedURLTTL = 24 * time.Hour
// handleCreateDocumentSignedURL handles POST /api/documents/{id}/signed-url.
func (s *Server) handleCreateDocumentSignedURL(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
if s.objects == nil {
writeError(w, http.StatusServiceUnavailable, "Dateispeicher nicht konfiguriert")
return
}
docID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid document id")
return
}
var req createSignedURLRequest
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
}
ttl := time.Duration(req.TTLMinutes) * time.Minute
if ttl <= 0 {
ttl = s.storageCfg.ResolvedSignedURLTTL()
}
if ttl > maxSignedURLTTL {
ttl = maxSignedURLTTL
}
// IDOR guard: the document must belong to the caller's tenant. GetDocument
// filters WHERE id = $1 AND tenant_id = $2.
doc, err := s.store.GetDocument(r.Context(), docID, *sess.TenantID)
if err != nil {
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
"signed_url doc:"+strconv.FormatInt(docID, 10)+" err:not_found", false)
writeError(w, http.StatusNotFound, "document not found")
return
}
link, err := s.objects.SignedURL(*sess.TenantID, doc.ID, ttl)
if err != nil {
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
"signed_url doc:"+strconv.FormatInt(docID, 10)+" err:"+err.Error(), false)
writeError(w, http.StatusInternalServerError, "Link konnte nicht erstellt werden")
return
}
expiresAt := time.Now().Add(ttl)
s.logShare(r, audit.EventSignedURLCreated, sess.TenantID, sess.Username,
"signed_url doc:"+strconv.FormatInt(docID, 10)+" ttl:"+ttl.String(), true)
writeJSON(w, http.StatusOK, createSignedURLResponse{URL: link, ExpiresAt: expiresAt})
}
// handleSignedFileDownload handles GET /public/files?t=&d=&exp=&sig=. Served
// WITHOUT the s.auth wrapper by design: the signature is the credential.
// Order: rate-limit -> verify signature -> verify expiry -> tenant-scoped
// document lookup -> stream from the WORM store. Every outcome is audit-logged
// (EventSignedURLAccessed), successes and failures alike.
func (s *Server) handleSignedFileDownload(w http.ResponseWriter, r *http.Request) {
ip := s.remoteIP(r)
if !s.shareLimiter.allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many requests")
return
}
if s.objects == nil {
writeError(w, http.StatusServiceUnavailable, "Dateispeicher nicht konfiguriert")
return
}
ref, err := s.objects.VerifySignedURL(r.URL.Query(), time.Now())
if err != nil {
s.logSignedAccess(r, nil, "signed_url_access err:"+err.Error(), false)
if errors.Is(err, objectstore.ErrSignatureExpired) {
writeError(w, http.StatusGone, "Der Link ist abgelaufen.")
return
}
writeError(w, http.StatusForbidden, "Der Link ist ungültig.")
return
}
doc, err := s.store.GetDocument(r.Context(), ref.DocumentID, ref.TenantID)
if err != nil {
s.logSignedAccess(r, &ref.TenantID,
"signed_url_access doc:"+strconv.FormatInt(ref.DocumentID, 10)+" err:not_found", false)
writeError(w, http.StatusNotFound, "Dokument nicht gefunden.")
return
}
f, err := s.objects.Open(r.Context(), ref.TenantID, doc.StoragePath)
if err != nil {
s.reqLog(r.Context()).Error("signed url file open failed", "document_id", doc.ID, "tenant_id", ref.TenantID, "err", err)
s.logSignedAccess(r, &ref.TenantID,
"signed_url_access doc:"+strconv.FormatInt(doc.ID, 10)+" err:"+err.Error(), false)
if errors.Is(err, objectstore.ErrObjectNotFound) {
writeError(w, http.StatusNotFound, "Datei nicht im Archiv vorhanden.")
return
}
writeError(w, http.StatusInternalServerError, "Download fehlgeschlagen.")
return
}
defer f.Close()
s.logSignedAccess(r, &ref.TenantID, "signed_url_access doc:"+strconv.FormatInt(doc.ID, 10), true)
ext := filepath.Ext(doc.StoragePath)
w.Header().Set("Content-Type", detectMimeType("", ext, doc.StoragePath))
w.Header().Set("Content-Disposition", "attachment; filename=\""+safeDownloadName(doc.Title, ext)+"\"")
w.Header().Set("X-Content-Type-Options", "nosniff")
if _, err := io.Copy(w, f); err != nil {
s.reqLog(r.Context()).Warn("signed url stream interrupted", "document_id", doc.ID, "err", err)
}
}
// logSignedAccess records a redemption attempt in the audit log. There is no
// session here, so the username column carries the anonymous marker.
func (s *Server) logSignedAccess(r *http.Request, tenantID *int64, detail string, ok bool) {
s.audlog.Log(audit.Entry{
EventType: audit.EventSignedURLAccessed, Username: "anonymous", TenantID: tenantID,
IPAddress: s.remoteIP(r), Success: ok, Detail: detail,
})
}