// 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" "encoding/json" "errors" "fmt" "io" "net/http" "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/pdfconvert" "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}", s.handleMetadata) mux.HandleFunc("GET /documents/{id}/content", s.handleContent) } type documentDTO struct { ID string `json:"id"` Title string `json:"title"` FolderID *string `json:"folder_id"` MimeType *string `json:"mime_type"` SizeBytes *int64 `json:"size_bytes"` CreatedAt string `json:"created_at"` CreatedBy string `json:"created_by"` } func (s *Server) handleMetadata(w http.ResponseWriter, r *http.Request) { var d documentDTO var created time.Time err := s.pool.QueryRow(r.Context(), ` SELECT d.id, d.title, d.folder_id, fr.mime_type, fr.size_bytes, d.created_at, d.created_by FROM documents d LEFT JOIN file_revisions fr ON fr.id = d.current_revision_id WHERE d.id = $1 AND d.deleted_at IS NULL AND d.trashed_at IS NULL AND NOT EXISTS (SELECT 1 FROM revision_scans rs WHERE rs.revision_id = d.current_revision_id AND rs.status <> 'clean') `, r.PathValue("id")).Scan(&d.ID, &d.Title, &d.FolderID, &d.MimeType, &d.SizeBytes, &created, &d.CreatedBy) if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "dokument nicht gefunden", http.StatusNotFound) return } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } d.CreatedAt = created.UTC().Format(time.RFC3339) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(d) } // handleContent liefert den entschlüsselten Inhalt der AKTUELLEN Revision // eines Dokuments (Original, bei fertigem PDF-Derivat dieses) (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 } // DOC-20: liegt ein fertiges PDF-Derivat (DOC-06) vor, wird dieses statt // des Originals geliefert; das Original bleibt unveraendert. var derivStatus string var derivKey *string var derivDEK []byte err := s.pool.QueryRow(r.Context(), ` SELECT status, storage_key, wrapped_dek FROM revision_pdf_derivatives WHERE revision_id = $1 `, *currentRevisionID).Scan(&derivStatus, &derivKey, &derivDEK) switch { case errors.Is(err, pgx.ErrNoRows): case err != nil: http.Error(w, err.Error(), http.StatusInternalServerError) return case derivStatus == pdfconvert.StatusReady && derivKey != nil: storageKey, wrappedDEK = *derivKey, derivDEK w.Header().Set("X-Content-Source", "pdf-derivative") default: w.Header().Set("X-Content-Source", "original") w.Header().Set("X-Derivative-Status", derivStatus) } 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]) disposition := "inline" if activeContent(contentType) { contentType, disposition = "application/octet-stream", "attachment" } w.Header().Set("Content-Type", contentType) w.Header().Set("X-Content-Type-Options", "nosniff") // Explizit "inline" statt des Browser-Standards bei unbekanntem Typ - // verhindert den Download-Zwang aus der Ticket-Vorgabe. w.Header().Set("Content-Disposition", disposition) http.ServeContent(w, r, documentID, time.Time{}, bytes.NewReader(plaintext)) } // activeContent: hochgeladene Skript-/Markup-Typen duerfen nie inline im // Ursprung der API ausgefuehrt werden (gespeichertes XSS). func activeContent(contentType string) bool { base, _, _ := strings.Cut(contentType, ";") switch strings.ToLower(strings.TrimSpace(base)) { case "text/html", "text/xml", "application/xml", "application/xhtml+xml", "image/svg+xml", "text/javascript", "application/javascript": return true } return false }