versioning.go ergaenzt Restore (stellt eine fruehere Revision ueber denselben CheckOut/CheckIn-Mechanismus als NEUE Revision wieder her, kein separater Codepfad) und GetRevisionContent (beliebige, nicht nur die aktuelle Revision). Revision.CreatedBy ergaenzt fuer Autor-Anzeige. internal/versioningapi: Liste/Content/Restore als HTTP-Endpunkte. OwnerChecker ist eine minimale, echte PermissionChecker-Implementierung (Dokument- oder Revisions-Ersteller) - keine zentrale RBAC-Anbindung fuer dieses Modul vorhanden (VER-01/VER-05-Praezedenzfall), der Aufrufvertrag ist aber so geschnitten, dass eine spaetere echte Rollenpruefung diesen Handler nicht aendern muss. web/dms-browser: Versionsliste (Autor/Zeitpunkt/Kommentar, Download- und Wiederherstellen-Aktion je Zeile), nutzt dieselbe bei DOC-10 gegen 10.000 Zeilen bewiesene Virtualisierungs-Logik. Pruefungen: 1. Wiederherstellung veraendert Historie nicht - alle felder der alten revisionen unveraendert, wiederhergestellter inhalt real entschluesselt und verglichen 2. 100 revisionen bleiben uebersichtlich - dieselbe bei doc-10 bewiesene virtualisierung, weit unterhalb des bewiesenen 10.000er-bereichs 3. berechtigung wird JE revision geprueft - nicht-ersteller sieht accessible:false und bekommt 403, ersteller kann zugreifen (200) Regressionsfrei: go test ./... -p 1 alle pakete PASS, 22/22 vitest-tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
295 lines
12 KiB
Go
295 lines
12 KiB
Go
// Package versioning implementiert VER-01: jede Änderung erzeugt eine
|
|
// neue Revision, Ausleihen (Check-out) sperrt ein Dokument für andere,
|
|
// Einchecken (Check-in) gibt es mit Kommentar frei. Alte Revisionen
|
|
// bleiben unverändert abrufbar — dieses Paket fügt ausschließlich neue
|
|
// file_revisions-Zeilen an, es ändert nie eine bestehende.
|
|
package versioning
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"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"
|
|
)
|
|
|
|
// ErrAlreadyLocked wird von CheckOut geliefert, wenn ein anderer Nutzer
|
|
// das Dokument bereits mit einer noch aktiven Sperre ausgeliehen hat
|
|
// (Akzeptanzkriterium 2 / Pflichtprüfung 1: bei zwei gleichzeitigen
|
|
// Ausleihversuchen gewinnt nur einer).
|
|
var ErrAlreadyLocked = errors.New("versioning: dokument ist bereits ausgeliehen")
|
|
|
|
// ErrNotLockedByUser wird von CheckIn geliefert, wenn der aufrufende
|
|
// Nutzer das Dokument nicht (mehr) ausgeliehen hat.
|
|
var ErrNotLockedByUser = errors.New("versioning: dokument ist nicht von diesem nutzer ausgeliehen")
|
|
|
|
// ErrNotLocked wird von ForceUnlock geliefert, wenn gar keine Sperre
|
|
// besteht.
|
|
var ErrNotLocked = errors.New("versioning: dokument ist nicht ausgeliehen")
|
|
|
|
// DefaultLeaseDuration ist die Standard-Sperrfrist, nach der eine nicht
|
|
// verlängerte Sperre als verwaist gilt und automatisch wieder frei wird.
|
|
const DefaultLeaseDuration = 30 * time.Minute
|
|
|
|
// Lock beschreibt eine aktive Ausleihe.
|
|
type Lock struct {
|
|
DocumentID string
|
|
LockedBy string
|
|
LockedAt time.Time
|
|
LeaseUntil time.Time
|
|
}
|
|
|
|
// CheckOut leiht ein Dokument aus (Akzeptanzkriterium 2). Atomar über
|
|
// INSERT ... ON CONFLICT ... WHERE lease_until < now(): eine bestehende,
|
|
// noch aktive Sperre eines ANDEREN Nutzers verhindert das Ausleihen, eine
|
|
// bereits abgelaufene (verwaiste) Sperre wird automatisch übernommen
|
|
// (Akzeptanzkriterium/Pflichtprüfung 2) — kein separater Aufräum-Job
|
|
// nötig. Der Datenbank-Constraint entscheidet bei gleichzeitigen
|
|
// Versuchen atomar, wer gewinnt (Pflichtprüfung 1).
|
|
func CheckOut(ctx context.Context, pool *pgxpool.Pool, documentID, userID string, leaseDuration time.Duration) (Lock, error) {
|
|
if leaseDuration <= 0 {
|
|
leaseDuration = DefaultLeaseDuration
|
|
}
|
|
var l Lock
|
|
l.DocumentID = documentID
|
|
err := pool.QueryRow(ctx, `
|
|
INSERT INTO document_locks (document_id, locked_by, locked_at, lease_until)
|
|
VALUES ($1, $2, now(), now() + $3::interval)
|
|
ON CONFLICT (document_id) DO UPDATE
|
|
SET locked_by = EXCLUDED.locked_by, locked_at = EXCLUDED.locked_at, lease_until = EXCLUDED.lease_until
|
|
WHERE document_locks.lease_until < now()
|
|
RETURNING locked_by, locked_at, lease_until
|
|
`, documentID, userID, fmt.Sprintf("%d seconds", int(leaseDuration.Seconds()))).Scan(&l.LockedBy, &l.LockedAt, &l.LeaseUntil)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// WHERE-Klausel griff nicht (aktive Fremdsperre) -> kein Zeilen-
|
|
// Update, kein RETURNING-Ergebnis. Das ist der Normalfall bei
|
|
// einem verlorenen Ausleihversuch, kein technischer Fehler.
|
|
return Lock{}, ErrAlreadyLocked
|
|
}
|
|
if err != nil {
|
|
return Lock{}, fmt.Errorf("versioning: ausleihen: %w", err)
|
|
}
|
|
return l, nil
|
|
}
|
|
|
|
// GetLock liefert die aktuelle Sperre eines Dokuments, falls vorhanden
|
|
// (auch eine bereits abgelaufene — der Aufrufer entscheidet, ob das für
|
|
// seinen Zweck relevant ist).
|
|
func GetLock(ctx context.Context, pool *pgxpool.Pool, documentID string) (*Lock, error) {
|
|
var l Lock
|
|
l.DocumentID = documentID
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT locked_by, locked_at, lease_until FROM document_locks WHERE document_id = $1
|
|
`, documentID).Scan(&l.LockedBy, &l.LockedAt, &l.LeaseUntil)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("versioning: sperre lesen: %w", err)
|
|
}
|
|
return &l, nil
|
|
}
|
|
|
|
// ForceUnlock hebt eine Sperre unabhängig vom Inhaber auf
|
|
// (Akzeptanzkriterium 3: Sperre ist durch Administratoren aufhebbar). Die
|
|
// eigentliche Rollenprüfung ("ist adminUserID tatsächlich Administrator")
|
|
// ist Sache der RBAC-Schicht vor diesem Aufruf (noch keine HTTP-Schicht
|
|
// für diese Kachel, siehe VER-05-Präzedenzfall) — dieses Paket kennt nur
|
|
// die Fach-Operation selbst.
|
|
func ForceUnlock(ctx context.Context, pool *pgxpool.Pool, documentID string) error {
|
|
tag, err := pool.Exec(ctx, `DELETE FROM document_locks WHERE document_id = $1`, documentID)
|
|
if err != nil {
|
|
return fmt.Errorf("versioning: sperre aufheben: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrNotLocked
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Revision ist EINE unveränderliche Datei-Revision.
|
|
type Revision struct {
|
|
ID string
|
|
DocumentID string
|
|
RevisionNumber int
|
|
ChecksumSHA256 string
|
|
SizeBytes int64
|
|
MimeType string
|
|
Comment *string
|
|
CreatedBy string
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// CheckIn prüft, dass documentID tatsächlich von userID ausgeliehen ist
|
|
// (Akzeptanzkriterium 2), verschlüsselt und speichert plaintext als NEUE
|
|
// Revision (nie eine bestehende überschreibend, Akzeptanzkriterium 1) und
|
|
// gibt die Sperre danach frei. Scheitert ein Schritt, bleibt die Sperre
|
|
// bestehen — kein halb eingecheckter Zustand ohne erkennbaren Besitzer.
|
|
func CheckIn(
|
|
ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string,
|
|
documentID, userID, comment, mimeType string, plaintext io.Reader,
|
|
) (Revision, error) {
|
|
lock, err := GetLock(ctx, pool, documentID)
|
|
if err != nil {
|
|
return Revision{}, err
|
|
}
|
|
if lock == nil || lock.LockedBy != userID || lock.LeaseUntil.Before(time.Now()) {
|
|
return Revision{}, ErrNotLockedByUser
|
|
}
|
|
|
|
hasher := sha256.New()
|
|
tee := io.TeeReader(plaintext, hasher)
|
|
env, err := cryptoSvc.Seal(ctx, tenantSlug, tee)
|
|
if err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: verschluesseln: %w", err)
|
|
}
|
|
ciphertext, err := io.ReadAll(env.Ciphertext)
|
|
if err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: chiffretext lesen: %w", err)
|
|
}
|
|
checksumHex := hex.EncodeToString(hasher.Sum(nil))
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: transaktion starten: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
var nextNumber int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COALESCE(max(revision_number), 0) + 1 FROM file_revisions WHERE document_id = $1
|
|
`, documentID).Scan(&nextNumber); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: naechste revisionsnummer ermitteln: %w", err)
|
|
}
|
|
|
|
var r Revision
|
|
r.DocumentID, r.RevisionNumber, r.ChecksumSHA256, r.SizeBytes, r.MimeType, r.CreatedBy = documentID, nextNumber, checksumHex, int64(len(ciphertext)), mimeType, userID
|
|
commentPtr := &comment
|
|
r.Comment = commentPtr
|
|
tempKey := storage.ObjectKey(documentID, "pending")
|
|
if err := tx.QueryRow(ctx, `
|
|
INSERT INTO file_revisions (document_id, revision_number, storage_key, checksum_sha256, size_bytes, mime_type, created_by, wrapped_dek, comment)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING id, created_at
|
|
`, documentID, nextNumber, tempKey, checksumHex, len(ciphertext), mimeType, userID, env.WrappedDEK, commentPtr).Scan(&r.ID, &r.CreatedAt); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: revision anlegen: %w", err)
|
|
}
|
|
|
|
finalKey := storage.ObjectKey(documentID, r.ID)
|
|
if _, err := tx.Exec(ctx, `UPDATE file_revisions SET storage_key = $2 WHERE id = $1`, r.ID, finalKey); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: storage-key aktualisieren: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE documents SET current_revision_id = $2, updated_at = now() WHERE id = $1`, documentID, r.ID); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: aktuelle revision setzen: %w", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `DELETE FROM document_locks WHERE document_id = $1 AND locked_by = $2`, documentID, userID); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: sperre freigeben: %w", err)
|
|
}
|
|
|
|
if _, err := storageSvc.Put(ctx, finalKey, bytes.NewReader(ciphertext), int64(len(ciphertext)), "application/octet-stream"); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: objekt ablegen: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: einchecken committen: %w", err)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// ListRevisions liefert ALLE Revisionen eines Dokuments, älteste zuerst —
|
|
// Akzeptanzkriterium 1 / Pflichtprüfung 3: alte Revisionen bleiben
|
|
// unverändert abrufbar. Nie nil.
|
|
func ListRevisions(ctx context.Context, pool *pgxpool.Pool, documentID string) ([]Revision, error) {
|
|
rows, err := pool.Query(ctx, `
|
|
SELECT id, document_id, revision_number, checksum_sha256, size_bytes, mime_type, comment, created_by, created_at
|
|
FROM file_revisions WHERE document_id = $1 ORDER BY revision_number ASC
|
|
`, documentID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("versioning: revisionen abfragen: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
revisions := []Revision{}
|
|
for rows.Next() {
|
|
var r Revision
|
|
if err := rows.Scan(&r.ID, &r.DocumentID, &r.RevisionNumber, &r.ChecksumSHA256, &r.SizeBytes, &r.MimeType, &r.Comment, &r.CreatedBy, &r.CreatedAt); err != nil {
|
|
return nil, fmt.Errorf("versioning: revisions-zeile lesen: %w", err)
|
|
}
|
|
revisions = append(revisions, r)
|
|
}
|
|
return revisions, rows.Err()
|
|
}
|
|
|
|
// ErrRevisionNotFound wird geliefert, wenn eine angefragte Revision nicht
|
|
// existiert (oder nicht zum angegebenen Dokument gehört).
|
|
var ErrRevisionNotFound = errors.New("versioning: revision nicht gefunden")
|
|
|
|
// GetRevisionContent lädt und entschlüsselt den Inhalt EINER bestimmten,
|
|
// beliebigen Revision (nicht nur der aktuellen) — Akzeptanzkriterium 3:
|
|
// jede Revision ist einzeln herunterladbar.
|
|
func GetRevisionContent(ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug, documentID, revisionID string) ([]byte, string, error) {
|
|
var storageKey, mimeType string
|
|
var wrappedDEK []byte
|
|
err := pool.QueryRow(ctx, `
|
|
SELECT storage_key, mime_type, wrapped_dek FROM file_revisions WHERE id = $1 AND document_id = $2
|
|
`, revisionID, documentID).Scan(&storageKey, &mimeType, &wrappedDEK)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, "", ErrRevisionNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("versioning: revision lesen: %w", err)
|
|
}
|
|
|
|
cipherReader, err := storageSvc.Get(ctx, storageKey)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("versioning: original aus storage laden: %w", err)
|
|
}
|
|
defer func() { _ = cipherReader.Close() }()
|
|
plainReader, err := cryptoSvc.Open(ctx, tenantSlug, wrappedDEK, cipherReader)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("versioning: original entschluesseln: %w", err)
|
|
}
|
|
plaintext, err := io.ReadAll(plainReader)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("versioning: original lesen: %w", err)
|
|
}
|
|
return plaintext, mimeType, nil
|
|
}
|
|
|
|
// Restore stellt eine frühere Revision wieder her, indem ihr Inhalt als
|
|
// NEUE Revision eingecheckt wird (Akzeptanzkriterium 2: Wiederherstellung
|
|
// erzeugt eine neue Revision statt zu überschreiben — die Historie bleibt
|
|
// dadurch vollständig unverändert, Pflichtprüfung 1). Nutzt intern
|
|
// denselben CheckOut/CheckIn-Mechanismus wie ein regulärer Check-in, das
|
|
// Dokument darf daher nicht bereits von einem ANDEREN Nutzer ausgeliehen
|
|
// sein.
|
|
func Restore(
|
|
ctx context.Context, pool *pgxpool.Pool, storageSvc *storage.Service, cryptoSvc *dmscrypto.Service, tenantSlug string,
|
|
documentID, sourceRevisionID, userID string,
|
|
) (Revision, error) {
|
|
plaintext, mimeType, err := GetRevisionContent(ctx, pool, storageSvc, cryptoSvc, tenantSlug, documentID, sourceRevisionID)
|
|
if err != nil {
|
|
return Revision{}, err
|
|
}
|
|
|
|
var sourceNumber int
|
|
if err := pool.QueryRow(ctx, `SELECT revision_number FROM file_revisions WHERE id = $1`, sourceRevisionID).Scan(&sourceNumber); err != nil {
|
|
return Revision{}, fmt.Errorf("versioning: quell-revisionsnummer lesen: %w", err)
|
|
}
|
|
|
|
if _, err := CheckOut(ctx, pool, documentID, userID, DefaultLeaseDuration); err != nil {
|
|
return Revision{}, err
|
|
}
|
|
comment := fmt.Sprintf("Wiederhergestellt aus Revision %d", sourceNumber)
|
|
return CheckIn(ctx, pool, storageSvc, cryptoSvc, tenantSlug, documentID, userID, comment, mimeType, bytes.NewReader(plaintext))
|
|
}
|