// Package versioningapi implementiert VER-02s Backend-Voraussetzung: // Versionsliste, Wiederherstellung und Einzel-Download je Revision. Ruft // ausschließlich internal/versioning auf, keine eigene Revisions-Logik. package versioningapi import ( "context" "encoding/json" "net/http" "strings" "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" "gitea.perlbach24.de/scripte/nexarch/dms/internal/versioning" ) // PermissionChecker entscheidet, ob caller Zugriff auf EINE bestimmte // Revision hat (Akzeptanzkriterium/Pflichtprüfung 3: Berechtigung wird // JE Revision geprüft, nicht nur einmal je Dokument). Schmale // Schnittstelle, damit eine echte RBAC-Anbindung später eingesetzt werden // kann, ohne diesen Handler zu ändern. type PermissionChecker interface { CanAccessRevision(ctx context.Context, callerUserID, documentID, revisionID string) (bool, error) } // OwnerChecker ist eine minimale, echte Umsetzung ohne zentrale RBAC- // Anbindung (die für dieses Modul noch nicht existiert, siehe VER-01/ // VER-05-Präzedenzfall): erlaubt Zugriff, wenn der Aufrufer entweder das // Dokument angelegt ODER die konkrete Revision selbst eingecheckt hat. // Eine spätere zentrale RBAC-Prüfung ersetzt das durch eine echte // Rollen-/Rechteabfrage, ohne den Aufrufvertrag (PermissionChecker) zu // ändern. type OwnerChecker struct { Pool *pgxpool.Pool } func (c *OwnerChecker) CanAccessRevision(ctx context.Context, callerUserID, documentID, revisionID string) (bool, error) { var documentOwner, revisionOwner string err := c.Pool.QueryRow(ctx, ` SELECT d.created_by, fr.created_by FROM file_revisions fr JOIN documents d ON d.id = fr.document_id WHERE fr.id = $1 AND fr.document_id = $2 `, revisionID, documentID).Scan(&documentOwner, &revisionOwner) if err == pgx.ErrNoRows { return false, nil } if err != nil { return false, err } return callerUserID == documentOwner || callerUserID == revisionOwner, nil } type Server struct { pool *pgxpool.Pool storageSvc *storage.Service cryptoSvc *dmscrypto.Service tenantSlug string permissions PermissionChecker } func NewServer(pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string, permissions PermissionChecker) *Server { return &Server{pool: pool, storageSvc: storageSvc, cryptoSvc: cryptoSvc, tenantSlug: tenantSlug, permissions: permissions} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /documents/{id}/revisions", s.handleList) mux.HandleFunc("GET /documents/{id}/revisions/{revisionID}/content", s.handleContent) mux.HandleFunc("POST /documents/{id}/revisions/{revisionID}/restore", s.handleRestore) mux.HandleFunc("GET /documents/{id}/diff", s.handleDiff) } 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 requireCaller(w http.ResponseWriter, r *http.Request) (string, bool) { caller := strings.TrimSpace(r.URL.Query().Get("caller")) if caller == "" { writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"caller\"") return "", false } return caller, true } type revisionDTO struct { ID string `json:"id"` RevisionNumber int `json:"revision_number"` SizeBytes int64 `json:"size_bytes"` MimeType string `json:"mime_type"` Comment *string `json:"comment"` CreatedBy string `json:"created_by"` CreatedAt string `json:"created_at"` Accessible bool `json:"accessible"` } // handleList liefert alle Revisionen chronologisch mit Metadaten // (Akzeptanzkriterium 1) — je Revision wird die Berechtigung EINZELN // geprüft (Akzeptanzkriterium/Pflichtprüfung 3), nicht zugängliche // Revisionen werden mit `accessible: false` markiert statt ganz // weggelassen (der Aufrufer sieht, DASS es weitere Revisionen gibt, ohne // deren Inhalt zu bekommen). func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { documentID := r.PathValue("id") caller, ok := requireCaller(w, r) if !ok { return } revisions, err := versioning.ListRevisions(r.Context(), s.pool, documentID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } dtos := make([]revisionDTO, len(revisions)) for i, rev := range revisions { allowed, err := s.permissions.CanAccessRevision(r.Context(), caller, documentID, rev.ID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } dtos[i] = revisionDTO{ ID: rev.ID, RevisionNumber: rev.RevisionNumber, SizeBytes: rev.SizeBytes, MimeType: rev.MimeType, Comment: rev.Comment, CreatedBy: rev.CreatedBy, CreatedAt: rev.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), Accessible: allowed, } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(dtos) } // handleContent liefert den Inhalt EINER Revision (Akzeptanzkriterium 3: // einzeln herunterladbar) — nur nach positiver Einzelprüfung. func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) { documentID, revisionID := r.PathValue("id"), r.PathValue("revisionID") caller, ok := requireCaller(w, r) if !ok { return } allowed, err := s.permissions.CanAccessRevision(r.Context(), caller, documentID, revisionID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !allowed { writeError(w, http.StatusForbidden, "keine berechtigung fuer diese revision") return } plaintext, mimeType, err := versioning.GetRevisionContent(r.Context(), s.pool, s.storageSvc, s.cryptoSvc, s.tenantSlug, documentID, revisionID) if err == versioning.ErrRevisionNotFound { writeError(w, http.StatusNotFound, "revision nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", mimeType) w.Header().Set("Content-Disposition", "inline") _, _ = w.Write(plaintext) } // handleRestore stellt eine frühere Revision als NEUE Revision wieder her // (Akzeptanzkriterium 2). func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { documentID, revisionID := r.PathValue("id"), r.PathValue("revisionID") caller, ok := requireCaller(w, r) if !ok { return } allowed, err := s.permissions.CanAccessRevision(r.Context(), caller, documentID, revisionID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !allowed { writeError(w, http.StatusForbidden, "keine berechtigung fuer diese revision") return } newRev, err := versioning.Restore(r.Context(), s.pool, s.storageSvc, s.cryptoSvc, s.tenantSlug, documentID, revisionID, caller) if err != nil { writeError(w, http.StatusConflict, err.Error()) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(revisionDTO{ ID: newRev.ID, RevisionNumber: newRev.RevisionNumber, SizeBytes: newRev.SizeBytes, MimeType: newRev.MimeType, Comment: newRev.Comment, CreatedBy: newRev.CreatedBy, CreatedAt: newRev.CreatedAt.UTC().Format("2006-01-02T15:04:05Z07:00"), Accessible: true, }) } type diffLineDTO struct { Op string `json:"op"` Text string `json:"text"` } type metadataDiffDTO struct { Field string `json:"field"` Before string `json:"before"` After string `json:"after"` } type diffResponseDTO struct { Text []diffLineDTO `json:"text"` Metadata []metadataDiffDTO `json:"metadata"` } // handleDiff liefert Text- und Metadaten-Unterschied zwischen zwei frei // wählbaren Revisionen (VER-04 Akzeptanzkriterium 1) — Berechtigung wird // für BEIDE Revisionen einzeln geprüft, wie beim Einzel-Download. func (s *Server) handleDiff(w http.ResponseWriter, r *http.Request) { documentID := r.PathValue("id") caller, ok := requireCaller(w, r) if !ok { return } from := strings.TrimSpace(r.URL.Query().Get("from")) to := strings.TrimSpace(r.URL.Query().Get("to")) if from == "" || to == "" { writeError(w, http.StatusBadRequest, "fehlende pflicht-parameter \"from\"/\"to\"") return } for _, revisionID := range []string{from, to} { allowed, err := s.permissions.CanAccessRevision(r.Context(), caller, documentID, revisionID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !allowed { writeError(w, http.StatusForbidden, "keine berechtigung fuer diese revision") return } } textDiff, metadataDiff, err := versioning.DiffRevisions(r.Context(), s.pool, s.storageSvc, s.cryptoSvc, s.tenantSlug, documentID, from, to) if err == versioning.ErrRevisionNotFound { writeError(w, http.StatusNotFound, "revision nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp := diffResponseDTO{Text: make([]diffLineDTO, len(textDiff)), Metadata: make([]metadataDiffDTO, len(metadataDiff))} for i, op := range textDiff { resp.Text[i] = diffLineDTO{Op: string(op.Op), Text: op.Text} } for i, d := range metadataDiff { resp.Metadata[i] = metadataDiffDTO{Field: d.Field, Before: d.Before, After: d.After} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) }