Files
nexarch/dms/internal/downloadapi/handler.go
T
patrickandClaude Sonnet 5 4507e7f861 DOC-07: download & signierte urls
downloadapi: HMAC-signierte, zeitlich befristete Download-URLs fuer die
verschluesselten Originale. Berechtigung wird beim Ausstellen geprueft
(DocumentOwnerChecker), die Signatur selbst ist danach der Zugriffsnachweis.
RFC-5987-Dateinamenkodierung fuer Sonderzeichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:48:14 +02:00

229 lines
8.2 KiB
Go

// Package downloadapi implementiert DOC-07: Download von Original und
// Derivaten über kurzlebige signierte URLs. Die eigentlichen Objekte sind
// verschlüsselt (FDN-09) — eine presignte URL kann daher nicht direkt auf
// das Objekt-Storage zeigen (wie storage.SignedURL es für unverschlüsselte
// Ablagen tut), sondern auf diesen eigenen, entschlüsselnden Endpunkt. Die
// Berechtigungsprüfung passiert beim AUSSTELLEN der URL — die Signatur
// selbst ist danach der Berechtigungsnachweis (Standard-Presigned-URL-
// Semantik, Akzeptanzkriterium 3).
package downloadapi
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
)
var (
ErrURLExpired = errors.New("downloadapi: signierte url ist abgelaufen")
ErrInvalidSignature = errors.New("downloadapi: signatur der url ist ungueltig")
)
// PermissionChecker entscheidet, ob caller eine Download-URL für EIN
// bestimmtes Dokument ausgestellt bekommen darf (Akzeptanzkriterium 3).
// Schmale Schnittstelle nach demselben Muster wie versioningapi.
// PermissionChecker (VER-02) — das DMS-Modul hat noch keine zentrale RBAC-
// Anbindung (siehe VER-01/VER-02/VER-05/DOC-15/VER-06-Präzedenzfall).
type PermissionChecker interface {
CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error)
}
// DocumentOwnerChecker ist eine minimale, echte PermissionChecker-Umsetzung
// ohne zentrale RBAC-Anbindung (die für dieses Modul noch nicht existiert):
// erlaubt Zugriff, wenn der Aufrufer das Dokument angelegt hat. Eine
// spätere echte Rollenprüfung ersetzt dies, ohne den Aufrufvertrag zu
// ändern.
type DocumentOwnerChecker struct {
Pool *pgxpool.Pool
}
func (c *DocumentOwnerChecker) CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error) {
var createdBy string
err := c.Pool.QueryRow(ctx, `SELECT created_by FROM documents WHERE id = $1`, documentID).Scan(&createdBy)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return callerUserID == createdBy, nil
}
type Server struct {
pool *pgxpool.Pool
storageSvc *storage.Service
cryptoSvc *dmscrypto.Service
tenantSlug string
permissions PermissionChecker
secret []byte
ttl time.Duration
}
// NewServer nimmt das Signatur-Geheimnis und die Gültigkeitsdauer als
// Parameter entgegen (Akzeptanzkriterium 2) — der Aufrufer (cmd/app) liest
// beides aus Umgebungsvariablen, dieses Paket kennt keine eigene
// Konfigurationsquelle.
func NewServer(pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string, permissions PermissionChecker, secret []byte, ttl time.Duration) *Server {
return &Server{pool: pool, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug, permissions: permissions, secret: secret, ttl: ttl}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /documents/{id}/download-url", s.handleRequestURL)
mux.HandleFunc("GET /download", s.handleDownload)
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}
func (s *Server) sign(documentID, revisionID string, expiry int64) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(fmt.Sprintf("%s:%s:%d", documentID, revisionID, expiry)))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *Server) verify(documentID, revisionID string, expiry int64, sig string) error {
if time.Now().Unix() > expiry {
return ErrURLExpired
}
expected := s.sign(documentID, revisionID, expiry)
if subtle.ConstantTimeCompare([]byte(expected), []byte(sig)) != 1 {
return ErrInvalidSignature
}
return nil
}
// handleRequestURL prüft die Berechtigung (Akzeptanzkriterium 3) und
// stellt bei Erfolg eine kurzlebige signierte Download-URL für die
// AKTUELLE Revision des Dokuments aus (Akzeptanzkriterium 2).
func (s *Server) handleRequestURL(w http.ResponseWriter, r *http.Request) {
documentID := r.PathValue("id")
caller := strings.TrimSpace(r.URL.Query().Get("caller"))
if caller == "" {
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"caller\"")
return
}
allowed, err := s.permissions.CanAccessDocument(r.Context(), caller, documentID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung fuer dieses dokument")
return
}
var revisionID, title string
err = s.pool.QueryRow(r.Context(), `
SELECT fr.id, d.title FROM documents d JOIN file_revisions fr ON fr.id = d.current_revision_id WHERE d.id = $1
`, documentID).Scan(&revisionID, &title)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "dokument nicht gefunden oder ohne aktuelle revision")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
expiry := time.Now().Add(s.ttl).Unix()
sig := s.sign(documentID, revisionID, expiry)
downloadURL := fmt.Sprintf("/download?document_id=%s&revision_id=%s&expiry=%d&sig=%s&filename=%s",
url.QueryEscape(documentID), url.QueryEscape(revisionID), expiry, url.QueryEscape(sig), url.QueryEscape(title))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": downloadURL, "expires_at": time.Unix(expiry, 0).UTC().Format(time.RFC3339)})
}
// handleDownload verifiziert die Signatur (Akzeptanzkriterium 3 —
// nach Ablauf/Fälschung kein Zugriff, Pflichtprüfung 2) und liefert die
// UNVERÄNDERTE Originaldatei (Akzeptanzkriterium 1, Pflichtprüfung 1: die
// Prüfsumme bleibt identisch, da hier exakt der entschlüsselte Klartext
// ausgeliefert wird) mit korrekt übertragenem Dateinamen — auch mit
// Sonderzeichen (Akzeptanzkriterium/Pflichtprüfung 3: RFC 5987
// `filename*`-Kodierung zusätzlich zum ASCII-Fallback in `filename`).
func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
documentID, revisionID, filename := q.Get("document_id"), q.Get("revision_id"), q.Get("filename")
expiry, err := strconv.ParseInt(q.Get("expiry"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger expiry-parameter")
return
}
if err := s.verify(documentID, revisionID, expiry, q.Get("sig")); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
var storageKey, mimeType string
var wrappedDEK []byte
err = s.pool.QueryRow(r.Context(), `
SELECT storage_key, mime_type, wrapped_dek FROM file_revisions WHERE id = $1 AND document_id = $2
`, revisionID, documentID).Scan(&storageKey, &mimeType, &wrappedDEK)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "revision nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
cipherReader, err := s.storageSvc.Get(r.Context(), storageKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "original nicht ladbar: "+err.Error())
return
}
defer func() { _ = cipherReader.Close() }()
plainReader, err := s.cryptoSvc.Open(r.Context(), s.tenantSlug, wrappedDEK, cipherReader)
if err != nil {
writeError(w, http.StatusInternalServerError, "original nicht entschluesselbar: "+err.Error())
return
}
if filename == "" {
filename = revisionID
}
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Content-Disposition", fmt.Sprintf(
`attachment; filename="%s"; filename*=UTF-8''%s`,
toASCIIFallback(filename), url.PathEscape(filename),
))
_, _ = io.Copy(w, plainReader)
}
// toASCIIFallback ersetzt Nicht-ASCII-Zeichen fuer den `filename`-Fallback
// aus RFC 6266 — den eigentlichen, korrekten Namen mit Sonderzeichen
// transportiert `filename*` (RFC 5987) daneben.
func toASCIIFallback(s string) string {
var b strings.Builder
for _, r := range s {
if r < 128 {
b.WriteRune(r)
} else {
b.WriteByte('_')
}
}
return b.String()
}