Files
nexarch/dms/internal/trashapi/handler.go
patrickandClaude Sonnet 5 2c21110d40
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
DOC-21: ordner mit dokumenten anderer nutzer nicht verschieben
Der Guard prueft nur den Ordnerzugriff; ohne diese Sperre haette der
Ordner-Eigentuemer fremde Dokumente im Teilbaum mit in den Papierkorb
gezogen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 12:09:06 +02:00

293 lines
10 KiB
Go

// Package trashapi implementiert DOC-12s Backend-Voraussetzung: HTTP-
// Endpunkte für Papierkorb-Liste, Wiederherstellung (einzeln und in
// Auswahl) und endgültiges Löschen. Ruft ausschließlich internal/trash auf,
// keine eigene Papierkorb-Logik.
package trashapi
import (
"encoding/json"
"errors"
"net/http"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/storage"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/trash"
)
type Server struct {
pool *pgxpool.Pool
storageSvc *storage.Service
retentionDuration time.Duration
searchIndex trash.SearchIndexEnqueuer
holds legalhold.Checker
}
// NewServer nimmt die Papierkorb-Frist als Parameter entgegen — der Aufrufer
// (cmd/app) liest sie aus einer Umgebungsvariable, dieses Paket kennt keine
// eigene Konfigurationsquelle.
func NewServer(pool *pgxpool.Pool, storageSvc *storage.Service, retentionDuration time.Duration) *Server {
return &Server{pool: pool, storageSvc: storageSvc, retentionDuration: retentionDuration}
}
// SetSearchIndexEnqueuer aktiviert SRC-02s Loeschungs-/Wiederherstellungs-
// Propagation in den Suchindex — optional, damit bestehende Aufrufer/Tests
// ohne Indexierungs-Pipeline unveraendert funktionieren.
func (s *Server) SetSearchIndexEnqueuer(e trash.SearchIndexEnqueuer) {
s.searchIndex = e
}
func (s *Server) SetLegalHoldChecker(c legalhold.Checker) {
s.holds = c
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /trash", s.handleList)
mux.HandleFunc("POST /trash", s.handleTrash)
mux.HandleFunc("POST /trash/restore", s.handleRestore)
mux.HandleFunc("POST /trash/purge", s.handlePurge)
}
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
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
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})
}
type entryDTO struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
TrashedAt string `json:"trashed_at"`
RemainingSeconds int64 `json:"remaining_seconds"`
}
// handleList liefert alle Papierkorb-Einträge mit Restlaufzeit
// (Akzeptanzkriterium 1). Eine bereits abgelaufene Restlaufzeit wird als 0
// ausgegeben, nie negativ — der Aufräum-Job (DOC-11) entfernt solche
// Einträge ohnehin zeitnah, ein negativer Wert wäre nur verwirrend.
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
entries, err := trash.ListTrashed(r.Context(), s.pool)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dtos := make([]entryDTO, len(entries))
for i, e := range entries {
remaining := s.retentionDuration - time.Since(e.TrashedAt)
remainingSeconds := int64(remaining.Seconds())
if remainingSeconds < 0 {
remainingSeconds = 0
}
dtos[i] = entryDTO{
ID: e.ID, Type: string(e.Type), Name: e.Name,
TrashedAt: e.TrashedAt.UTC().Format(time.RFC3339), RemainingSeconds: remainingSeconds,
}
}
writeJSON(w, dtos)
}
type selectionRequest struct {
DocumentIDs []string `json:"document_ids"`
FolderIDs []string `json:"folder_ids"`
}
type selectionResult struct {
RestoredDocumentIDs []string `json:"restored_document_ids,omitempty"`
RestoredFolderIDs []string `json:"restored_folder_ids,omitempty"`
PurgedDocumentIDs []string `json:"purged_document_ids,omitempty"`
PurgedFolderIDs []string `json:"purged_folder_ids,omitempty"`
Failed map[string]string `json:"failed,omitempty"`
}
// handleRestore stellt eine Auswahl von Dokumenten UND Ordnern wieder her
// (Akzeptanzkriterium 2: einzeln — Auswahl mit einem Element — UND in
// Auswahl gleichermaßen über denselben Endpunkt).
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
caller, ok := requireCaller(w, r)
if !ok {
return
}
var req selectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger request-body: "+err.Error())
return
}
result := selectionResult{Failed: map[string]string{}}
for _, id := range req.DocumentIDs {
if err := trash.RestoreDocument(r.Context(), s.pool, s.searchIndex, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
result.RestoredDocumentIDs = append(result.RestoredDocumentIDs, id)
}
for _, id := range req.FolderIDs {
if err := trash.RestoreFolder(r.Context(), s.pool, s.searchIndex, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
result.RestoredFolderIDs = append(result.RestoredFolderIDs, id)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
writeJSON(w, result)
}
// handlePurge löscht eine Auswahl von Dokumenten UND Ordnern sofort
// endgültig (Akzeptanzkriterium 3) — die Bestätigung selbst liegt in der
// Oberfläche (Dialog vor diesem Aufruf), dieser Endpunkt führt nur aus.
func (s *Server) handlePurge(w http.ResponseWriter, r *http.Request) {
caller, ok := requireCaller(w, r)
if !ok {
return
}
var req selectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger request-body: "+err.Error())
return
}
result := selectionResult{Failed: map[string]string{}}
for _, id := range req.DocumentIDs {
if err := trash.PurgeDocumentNow(r.Context(), s.pool, s.storageSvc, s.searchIndex, s.holds, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
result.PurgedDocumentIDs = append(result.PurgedDocumentIDs, id)
}
for _, id := range req.FolderIDs {
if err := trash.PurgeFolderNow(r.Context(), s.pool, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
result.PurgedFolderIDs = append(result.PurgedFolderIDs, id)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
writeJSON(w, result)
}
var uuidPattern = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
type trashResult struct {
TrashedDocumentIDs []string `json:"trashed_document_ids,omitempty"`
TrashedFolderIDs []string `json:"trashed_folder_ids,omitempty"`
Failed map[string]string `json:"failed,omitempty"`
}
// handleTrash verschiebt eine Auswahl von Dokumenten und Ordnern in den
// Papierkorb. Bereits verschobene Objekte gelten als erledigt (kein zweiter
// Audit-Eintrag); ein von einem anderen Nutzer ausgeliehenes Dokument wird
// nicht verschoben.
func (s *Server) handleTrash(w http.ResponseWriter, r *http.Request) {
caller, ok := requireCaller(w, r)
if !ok {
return
}
var req selectionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger request-body: "+err.Error())
return
}
if len(req.DocumentIDs)+len(req.FolderIDs) == 0 {
writeError(w, http.StatusBadRequest, "leere auswahl")
return
}
result := trashResult{Failed: map[string]string{}}
for _, id := range req.DocumentIDs {
reason, already := s.checkTrashable(r, "documents", id, caller)
if reason != "" {
result.Failed[id] = reason
continue
}
if !already {
if err := trash.SoftDeleteDocument(r.Context(), s.pool, s.searchIndex, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
}
result.TrashedDocumentIDs = append(result.TrashedDocumentIDs, id)
}
for _, id := range req.FolderIDs {
reason, already := s.checkTrashable(r, "folders", id, caller)
if reason != "" {
result.Failed[id] = reason
continue
}
if !already {
if err := trash.SoftDeleteFolder(r.Context(), s.pool, s.searchIndex, id, caller); err != nil {
result.Failed[id] = err.Error()
continue
}
}
result.TrashedFolderIDs = append(result.TrashedFolderIDs, id)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
writeJSON(w, result)
}
// checkTrashable liefert einen Ablehnungsgrund ("" = verschiebbar) und ob
// das Objekt schon im Papierkorb liegt.
func (s *Server) checkTrashable(r *http.Request, kind, id, caller string) (reason string, alreadyTrashed bool) {
if !uuidPattern.MatchString(id) {
return "ungueltige id", false
}
var trashed, lockedByOther, foreignDocuments bool
var err error
if kind == "documents" {
err = s.pool.QueryRow(r.Context(), `
SELECT d.trashed_at IS NOT NULL,
EXISTS (SELECT 1 FROM document_locks l
WHERE l.document_id = d.id AND l.lease_until > now() AND l.locked_by::text <> $2)
FROM documents d WHERE d.id = $1
`, id, caller).Scan(&trashed, &lockedByOther)
} else {
err = s.pool.QueryRow(r.Context(), `
SELECT f.trashed_at IS NOT NULL,
EXISTS (SELECT 1 FROM document_locks l JOIN documents d ON d.id = l.document_id
WHERE l.lease_until > now() AND l.locked_by::text <> $2 AND d.trashed_at IS NULL
AND d.folder_id IN (SELECT id FROM folders WHERE path LIKE f.path || '%')),
EXISTS (SELECT 1 FROM documents d
WHERE d.trashed_at IS NULL AND d.created_by::text <> $2
AND d.folder_id IN (SELECT id FROM folders WHERE path LIKE f.path || '%'))
FROM folders f WHERE f.id = $1
`, id, caller).Scan(&trashed, &lockedByOther, &foreignDocuments)
}
switch {
case errors.Is(err, pgx.ErrNoRows):
return "nicht gefunden", false
case err != nil:
return "pruefung fehlgeschlagen", false
case lockedByOther:
return "gesperrt: von anderem nutzer ausgeliehen", false
case foreignDocuments:
return "ordner enthaelt dokumente anderer nutzer", false
}
return "", trashed
}