Files
nexarch/dms/internal/versioningapi/handler.go
T
patrickandClaude Sonnet 5 b5cee200db VER-04: diff-ansicht
Diff-Endpunkt (GET /documents/{id}/diff) im versioningapi-Handler, Diff-Seite
mit freier Revisionswahl, Farbe+Symbol je Aenderung und Sprung zur naechsten
Aenderung im dms-browser-Frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 23:31:57 +02:00

267 lines
9.2 KiB
Go

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