Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
123 lines
3.5 KiB
Go
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
|
|
}
|