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>
174 lines
5.2 KiB
Go
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)
|
|
}
|