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
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
295 lines
9.5 KiB
Go
295 lines
9.5 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 (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/authz"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/folder"
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/policyclient"
|
|
)
|
|
|
|
// ObjectACLClient ist die schmale Schnittstelle zu Core RBAC-11 (DOC-23);
|
|
// *policyclient.Client erfuellt sie. Ein Interface statt des konkreten Typs
|
|
// haelt den Test dieses Pakets frei von einem echten Core-Aufruf.
|
|
type ObjectACLClient interface {
|
|
ObjectAllowed(ctx context.Context, tenant string, path []policyclient.ObjectPathNode, right, userID string, groupIDs []string) (allowed bool, hasEntries bool, err error)
|
|
}
|
|
|
|
type Server struct {
|
|
pool *pgxpool.Pool
|
|
objectACL ObjectACLClient
|
|
tenantSlug string
|
|
}
|
|
|
|
func NewServer(pool *pgxpool.Pool) *Server {
|
|
return &Server{pool: pool}
|
|
}
|
|
|
|
// SetObjectACL aktiviert die Objektrechte-Pruefung (DOC-23, RBAC-11) fuer
|
|
// Ordner mit gesetzter ACL; ohne Aufruf bleibt das bisherige Verhalten
|
|
// (nur Mandant/Eigentuemer) unveraendert bestehen.
|
|
func (s *Server) SetObjectACL(client ObjectACLClient, tenantSlug string) {
|
|
s.objectACL = client
|
|
s.tenantSlug = tenantSlug
|
|
}
|
|
|
|
// checkFolderRead prueft, ob caller "read" auf folderID hat (DOC-23):
|
|
// - kein objectACL konfiguriert -> true, nil (Feature aus, altes Verhalten)
|
|
// - Breadcrumb-Fehler (z.B. Ordner nicht gefunden) -> vom Aufrufer selbst
|
|
// zu behandeln, hier true damit der eigentliche 404-Pfad des Aufrufers
|
|
// greift statt eines verwirrenden 403 vor dem "nicht gefunden"
|
|
// - Core nicht erreichbar -> false (fail closed, Akzeptanzkriterium 3)
|
|
// - keine ACL-Eintraege entlang des Pfads -> true (Akzeptanzkriterium 2:
|
|
// Ordner ohne ACL unveraendert)
|
|
// - ACL vorhanden -> deren Entscheidung gilt (Akzeptanzkriterium 1)
|
|
func (s *Server) checkFolderRead(ctx context.Context, folderID, caller string) (bool, error) {
|
|
if s.objectACL == nil {
|
|
return true, nil
|
|
}
|
|
trail, err := folder.Breadcrumb(ctx, s.pool, folderID)
|
|
if err != nil {
|
|
return true, nil
|
|
}
|
|
path := make([]policyclient.ObjectPathNode, len(trail))
|
|
for i, f := range trail {
|
|
path[i] = policyclient.ObjectPathNode{Type: "folder", ID: f.ID}
|
|
}
|
|
allowed, hasEntries, err := s.objectACL.ObjectAllowed(ctx, s.tenantSlug, path, "read", caller, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !hasEntries {
|
|
return true, nil
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
// requireFolderRead prueft Leserecht auf folderID und schreibt bei Bedarf
|
|
// die Fehlerantwort; false bedeutet "Aufrufer hat bereits geantwortet,
|
|
// Handler muss abbrechen". Ohne konfigurierte Objekt-ACL (s.objectACL ==
|
|
// nil) entfaellt jede Identitaetspruefung hier bewusst — das erhaelt das
|
|
// Verhalten vor diesem Ticket fuer Installationen ohne RBAC-11 exakt
|
|
// (Akzeptanzkriterium 2), inklusive fuer Routen, die bislang gar keine
|
|
// Identitaet verlangten.
|
|
func (s *Server) requireFolderRead(w http.ResponseWriter, r *http.Request, folderID string) bool {
|
|
if s.objectACL == nil {
|
|
return true
|
|
}
|
|
caller, ok := authz.Identity(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "identitaet fehlt oder ist widerspruechlich")
|
|
return false
|
|
}
|
|
allowed, err := s.checkFolderRead(r.Context(), folderID, caller)
|
|
if err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
|
|
return false
|
|
}
|
|
if !allowed {
|
|
writeError(w, http.StatusForbidden, "keine berechtigung")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /folders/children", s.handleChildren)
|
|
mux.HandleFunc("GET /folders/{id}", s.handleFolder)
|
|
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) {
|
|
id := folderIDParam(r)
|
|
if id != nil && !s.requireFolderRead(w, r, *id) {
|
|
return
|
|
}
|
|
children, err := folder.ListChildren(r.Context(), s.pool, id)
|
|
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)
|
|
}
|
|
|
|
type folderDetailDTO struct {
|
|
folderDTO
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
func (s *Server) handleFolder(w http.ResponseWriter, r *http.Request) {
|
|
if !s.requireFolderRead(w, r, r.PathValue("id")) {
|
|
return
|
|
}
|
|
trail, err := folder.Breadcrumb(r.Context(), s.pool, r.PathValue("id"))
|
|
if err != nil {
|
|
if err == folder.ErrNotFound {
|
|
writeError(w, http.StatusNotFound, "ordner nicht gefunden")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
names := make([]string, len(trail))
|
|
for i, f := range trail {
|
|
names[i] = f.Name
|
|
}
|
|
writeJSON(w, folderDetailDTO{folderDTO: toDTO(trail[len(trail)-1]), Path: "/" + strings.Join(names, "/")})
|
|
}
|
|
|
|
func (s *Server) handleBreadcrumb(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
if !s.requireFolderRead(w, r, id) {
|
|
return
|
|
}
|
|
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)
|
|
caller, ok := authz.Identity(r)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "identitaet fehlt oder ist widerspruechlich")
|
|
return
|
|
}
|
|
if folderID != nil && !s.requireFolderRead(w, r, *folderID) {
|
|
return
|
|
}
|
|
|
|
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.created_by::text = $1
|
|
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, caller)
|
|
} else {
|
|
rows, err = s.pool.Query(r.Context(), baseQuery+"= $2 ORDER BY "+sortCol+" "+order, caller, *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)
|
|
}
|