Files
archivdms/internal/api/search_handlers.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

123 lines
3.5 KiB
Go

package api
import (
"errors"
"net/http"
"strconv"
"strings"
"archivdms/internal/auth"
"archivdms/internal/index"
"archivdms/internal/storage"
"archivdms/internal/userstore"
)
// handleSearchDocuments serves GET /api/documents/search — full-text +
// attribute search backed by the per-tenant Manticore index.
//
// q full-text term (matched against title/ocr_text/tags/
// correspondent/doc_type). Optional; when empty the query
// degrades to a filter-only listing ordered by recency.
// tag repeatable tag id filter (documents carrying ANY given tag).
// doc_type_id restrict to a single document type.
// page 1-based page number (default 1).
// page_size hits per page (default 20, capped at 100).
//
// ACL: role 'user' is filtered against their permission-group memberships
// (ANY(acl_group_ids)); domain_admin/superadmin bypass the ACL, exactly like
// handleListDocuments. When the search backend is not configured the endpoint
// returns 503 rather than a silent empty result.
func (s *Server) handleSearchDocuments(w http.ResponseWriter, r *http.Request) {
sess := sessionFromCtx(r.Context())
if sess.TenantID == nil {
writeError(w, http.StatusForbidden, "tenant context required")
return
}
tenantID := *sess.TenantID
q := index.SearchQuery{
Query: strings.TrimSpace(r.URL.Query().Get("q")),
Page: parsePositiveInt(r.URL.Query().Get("page"), 1),
PageSize: clampInt(parsePositiveInt(r.URL.Query().Get("page_size"), 20), 1, 100),
TagIDs: parseInt64List(r.URL.Query()["tag"]),
DocTypeID: parseOptionalInt64(r.URL.Query().Get("doc_type_id")),
}
// Roles are the outer ACL boundary (see handleListDocuments): role 'user'
// is filtered against document_visibility via their group memberships;
// domain_admin/superadmin see every document in the tenant (ACLGroupIDs nil).
if !auth.HasRole(sess.Role, userstore.RoleDomainAdmin) {
groupIDs, err := s.store.ListGroupIDsForUser(r.Context(), sess.UserID, tenantID)
if err != nil {
writeError(w, http.StatusInternalServerError, "search failed")
return
}
// Non-nil (possibly empty) slice => ACL filter applies. An empty slice
// means the user is in no group and therefore sees nothing.
q.ACLGroupIDs = groupIDs
}
res, err := s.store.SearchDocuments(r.Context(), tenantID, q)
if err != nil {
if errors.Is(err, storage.ErrSearchUnavailable) {
writeError(w, http.StatusServiceUnavailable, "Suche nicht verfügbar, Manticore nicht konfiguriert")
return
}
writeError(w, http.StatusInternalServerError, "search failed")
return
}
writeJSON(w, http.StatusOK, res)
}
// --- small query-param parsing helpers ---
func parsePositiveInt(s string, def int) int {
if s == "" {
return def
}
n, err := strconv.Atoi(s)
if err != nil || n <= 0 {
return def
}
return n
}
func clampInt(n, min, max int) int {
if n < min {
return min
}
if n > max {
return max
}
return n
}
func parseOptionalInt64(s string) *int64 {
if s == "" {
return nil
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return nil
}
return &n
}
// parseInt64List parses a slice of query values (each possibly comma-separated)
// into positive int64 ids, silently dropping anything unparseable.
func parseInt64List(vals []string) []int64 {
var out []int64
for _, v := range vals {
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if n, err := strconv.ParseInt(part, 10, 64); err == nil && n > 0 {
out = append(out, n)
}
}
}
return out
}