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
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>
293 lines
10 KiB
Go
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
|
|
}
|