Files
nexarch/dms/internal/folderapi/handler.go
T
patrickandClaude Sonnet 5 43574f7872 DOC-17: malware-scan-vor-ablage
ClamAV-Anbindung (clamd-INSTREAM-Protokoll) als verpflichtender Scan-Schritt
vor Ablage. Fund fuehrt zu vollstaendiger Entfernung von Dateiobjekt und
Dokument plus Audit-Event statt stiller Ablage; Sichtbarkeit in
Listen/Direktzugriff gesperrt bis scan_status=clean. EICAR-Erkennung real
gegen installierten clamd verifiziert (dabei Null-Byte-Antwort-Bug
gefunden und behoben).

Nebenbei: fehlende trashed_at-Spalte in drei Test-Schemata (DOC-11-Luecke)
nachgetragen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 00:41:52 +02:00

174 lines
5.2 KiB
Go

// Package folderapi implementiert DOC-10s Backend-Voraussetzung: HTTP-
// Endpunkte für Navigationsbaum, Brotkrumenpfad und Dokumentenliste. Ruft
// ausschließlich internal/folder auf, keine eigene Baum-/Pfad-Logik.
package folderapi
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folder"
)
type Server struct {
pool *pgxpool.Pool
}
func NewServer(pool *pgxpool.Pool) *Server {
return &Server{pool: pool}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /folders/children", s.handleChildren)
mux.HandleFunc("GET /folders/{id}/children", s.handleChildren)
mux.HandleFunc("GET /folders/{id}/breadcrumb", s.handleBreadcrumb)
mux.HandleFunc("GET /folders/documents", s.handleDocuments)
mux.HandleFunc("GET /folders/{id}/documents", s.handleDocuments)
}
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})
}
// folderIDParam liefert nil für den Wurzelaufruf (Route ohne {id}, z. B.
// "/folders/children"), sonst die Pfad-ID.
func folderIDParam(r *http.Request) *string {
id := r.PathValue("id")
if id == "" {
return nil
}
return &id
}
type folderDTO struct {
ID string `json:"id"`
ParentFolderID *string `json:"parent_folder_id"`
Name string `json:"name"`
}
func toDTO(f folder.Folder) folderDTO {
return folderDTO{ID: f.ID, ParentFolderID: f.ParentFolderID, Name: f.Name}
}
// Akzeptanzkriterium 1: Baum und Liste bleiben synchron — beide fragen
// denselben Endpunkt-Satz gegen denselben Datenstand ab, kein separater
// "Baum-Cache".
func (s *Server) handleChildren(w http.ResponseWriter, r *http.Request) {
children, err := folder.ListChildren(r.Context(), s.pool, folderIDParam(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dtos := make([]folderDTO, len(children))
for i, f := range children {
dtos[i] = toDTO(f)
}
writeJSON(w, dtos)
}
func (s *Server) handleBreadcrumb(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
trail, err := folder.Breadcrumb(r.Context(), s.pool, id)
if err != nil {
if err == folder.ErrNotFound {
writeError(w, http.StatusNotFound, "ordner nicht gefunden")
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
dtos := make([]folderDTO, len(trail))
for i, f := range trail {
dtos[i] = toDTO(f)
}
writeJSON(w, dtos)
}
type documentDTO struct {
ID string `json:"id"`
Title string `json:"title"`
SizeBytes *int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
// handleDocuments liefert die Dokumente EINES Ordners (nicht des gesamten
// Teilbaums) mit optionaler Sortierung (?sort=title|created_at|size,
// ?order=asc|desc — Akzeptanzkriterium: Sortierung/Spaltenwahl bleibt
// Sache des Frontends, dieser Endpunkt liefert nur die Rohdaten sortiert).
func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
folderID := folderIDParam(r)
sortCol := "created_at"
switch r.URL.Query().Get("sort") {
case "title":
sortCol = "d.title"
case "size":
sortCol = "size_bytes"
default:
sortCol = "d.created_at"
}
order := "ASC"
if strings.EqualFold(r.URL.Query().Get("order"), "desc") {
order = "DESC"
}
// NUR whitelisted Spaltennamen (siehe switch oben) landen im SQL-Text
// selbst (kein Sprintf mit Nutzereingabe) - Sortierparameter ist damit
// injection-sicher trotz dynamischem ORDER BY.
// DOC-17 Akzeptanzkriterium 1: ein laufender/fehlgeschlagener Scan
// (revision_scans-Status ungleich "clean") verbirgt das Dokument aus
// dieser normalen Ansicht. Fehlt die revision_scans-Zeile ganz (kein
// Scan jemals eingereiht, z.B. bei Dokumenten aus der Zeit vor DOC-17),
// bleibt das Dokument unverändert sichtbar — kein rückwirkendes
// Verstecken bestehender Daten.
baseQuery := `
SELECT d.id, d.title, fr.size_bytes, d.created_at
FROM documents d
LEFT JOIN file_revisions fr ON fr.id = d.current_revision_id
WHERE d.deleted_at IS NULL AND d.trashed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM revision_scans rs WHERE rs.revision_id = d.current_revision_id AND rs.status <> 'clean')
AND d.folder_id `
var rows pgx.Rows
var err error
if folderID == nil {
rows, err = s.pool.Query(r.Context(), baseQuery+"IS NULL ORDER BY "+sortCol+" "+order)
} else {
rows, err = s.pool.Query(r.Context(), baseQuery+"= $1 ORDER BY "+sortCol+" "+order, *folderID)
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
docs := []documentDTO{}
for rows.Next() {
var d documentDTO
var createdAt time.Time
if err := rows.Scan(&d.ID, &d.Title, &d.SizeBytes, &createdAt); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
d.CreatedAt = createdAt.UTC().Format(time.RFC3339)
docs = append(docs, d)
}
if err := rows.Err(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, docs)
}