// Package documentapi implementiert DOC-05s Backend-Voraussetzung: einen // Endpunkt, über den der PDF-Viewer den ORIGINALINHALT eines Dokuments // ohne Download-Zwang beziehen kann. Content-Type wird ausschließlich über // Magic-Bytes des ENTSCHLÜSSELTEN Inhalts bestimmt (net/http. // DetectContentType), nicht über den bei DOC-01 vom Client mitgegebenen // MIME-Typ — dieselbe Disziplin wie DOC-04 (siehe "Bekannte Fehler // vermeiden"). http.ServeContent unterstützt HTTP-Range-Requests nativ, // darüber lädt PDF.js große Dokumente seitenweise nach (Akzeptanzkriterium // 3), ohne dass dieses Paket selbst Chunking-Logik bräuchte. package documentapi import ( "bytes" "errors" "fmt" "io" "net/http" "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" ) type Server struct { pool *pgxpool.Pool storageSvc *storage.Service cryptoSvc *dmscrypto.Service tenantSlug string } func NewServer(pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string) *Server { return &Server{pool: pool, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /documents/{id}/content", s.handleContent) } // handleContent liefert den entschlüsselten Originalinhalt der AKTUELLEN // Revision eines Dokuments (Akzeptanzkriterium 1: PDF wird ohne Download // angezeigt — inline, mit vom Server bestimmtem Content-Type statt // Content-Disposition: attachment). func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) { documentID := r.PathValue("id") var currentRevisionID *string var trashedAt *time.Time if err := s.pool.QueryRow(r.Context(), ` SELECT current_revision_id, trashed_at FROM documents WHERE id = $1 `, documentID).Scan(¤tRevisionID, &trashedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "dokument nicht gefunden", http.StatusNotFound) return } http.Error(w, err.Error(), http.StatusInternalServerError) return } if trashedAt != nil { // DOC-11 Akzeptanzkriterium 1: geloeschte Dokumente verschwinden // aus ALLEN normalen Ansichten, auch dem Direktzugriff per ID. http.Error(w, "dokument nicht gefunden", http.StatusNotFound) return } if currentRevisionID == nil { http.Error(w, "dokument hat keine aktuelle revision", http.StatusNotFound) return } // DOC-17 Akzeptanzkriterium 1: ein laufender/fehlgeschlagener Virenscan // (revision_scans-Status ungleich "clean") sperrt auch den Direktzugriff // per ID. Fehlt die revision_scans-Zeile ganz, bleibt der Zugriff // unveraendert erlaubt (kein rueckwirkendes Sperren bestehender Daten). var scanBlocked bool if err := s.pool.QueryRow(r.Context(), ` SELECT EXISTS(SELECT 1 FROM revision_scans WHERE revision_id = $1 AND status <> 'clean') `, *currentRevisionID).Scan(&scanBlocked); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if scanBlocked { http.Error(w, "dokument nicht gefunden", http.StatusNotFound) return } var storageKey string var wrappedDEK []byte if err := s.pool.QueryRow(r.Context(), ` SELECT storage_key, wrapped_dek FROM file_revisions WHERE id = $1 `, *currentRevisionID).Scan(&storageKey, &wrappedDEK); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } cipherReader, err := s.storageSvc.Get(r.Context(), storageKey) if err != nil { http.Error(w, fmt.Sprintf("original nicht ladbar: %v", err), http.StatusInternalServerError) return } defer func() { _ = cipherReader.Close() }() plainReader, err := s.cryptoSvc.Open(r.Context(), s.tenantSlug, wrappedDEK, cipherReader) if err != nil { http.Error(w, fmt.Sprintf("original nicht entschluesselbar: %v", err), http.StatusInternalServerError) return } plaintext, err := io.ReadAll(plainReader) if err != nil { http.Error(w, fmt.Sprintf("original nicht lesbar: %v", err), http.StatusInternalServerError) return } sniffLen := 512 if len(plaintext) < sniffLen { sniffLen = len(plaintext) } contentType := http.DetectContentType(plaintext[:sniffLen]) w.Header().Set("Content-Type", contentType) // Explizit "inline" statt des Browser-Standards bei unbekanntem Typ - // verhindert den Download-Zwang aus der Ticket-Vorgabe. w.Header().Set("Content-Disposition", "inline") http.ServeContent(w, r, documentID, time.Time{}, bytes.NewReader(plaintext)) }