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 }