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>
229 lines
8.2 KiB
Go
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()
|
|
}
|