// 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, }) }