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>
267 lines
9.2 KiB
Go
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)
|
|
}
|