// 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) }