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
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
185 lines
5.2 KiB
Go
185 lines
5.2 KiB
Go
package searchapi
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"html"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex"
|
|
)
|
|
|
|
type PermissionChecker interface {
|
|
CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error)
|
|
}
|
|
|
|
type Server struct {
|
|
index *sql.DB
|
|
permissions PermissionChecker
|
|
}
|
|
|
|
func NewServer(index *sql.DB, permissions PermissionChecker) *Server {
|
|
return &Server{index: index, permissions: permissions}
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /search", s.handleSearch)
|
|
}
|
|
|
|
type hitJSON struct {
|
|
DocumentID string `json:"document_id"`
|
|
Title string `json:"title"`
|
|
FolderPath string `json:"folder_path"`
|
|
DocumentType string `json:"document_type"`
|
|
Score int `json:"score"`
|
|
UpdatedAt int64 `json:"updated_at"`
|
|
// SnippetHTML enthaelt nur <mark>; alle Fremdtexte sind HTML-escaped.
|
|
SnippetHTML string `json:"snippet_html"`
|
|
}
|
|
|
|
type responseJSON struct {
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
TookMS int64 `json:"took_ms"`
|
|
Hits []hitJSON `json:"hits"`
|
|
// Facets nur bei gesetztem Parameter "facets"; Schluessel wie dort angegeben.
|
|
Facets map[string][]facetValueJSON `json:"facets,omitempty"`
|
|
}
|
|
|
|
type facetValueJSON struct {
|
|
Value string `json:"value"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
func intParam(r *http.Request, name string, def int) (int, error) {
|
|
v := r.URL.Query().Get(name)
|
|
if v == "" {
|
|
return def, nil
|
|
}
|
|
return strconv.Atoi(v)
|
|
}
|
|
|
|
func renderSnippet(sn searchindex.Snippet) string {
|
|
var b strings.Builder
|
|
for _, p := range sn {
|
|
if p.Match {
|
|
b.WriteString("<mark>" + html.EscapeString(p.Text) + "</mark>")
|
|
} else {
|
|
b.WriteString(html.EscapeString(p.Text))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
q := r.URL.Query()
|
|
caller := strings.TrimSpace(q.Get("caller"))
|
|
if caller == "" {
|
|
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"caller\"")
|
|
return
|
|
}
|
|
term := strings.TrimSpace(q.Get("q"))
|
|
if term == "" {
|
|
writeError(w, http.StatusBadRequest, "fehlender pflicht-parameter \"q\"")
|
|
return
|
|
}
|
|
limit, err := intParam(r, "limit", searchindex.DefaultPageSize)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "ungueltiger parameter \"limit\"")
|
|
return
|
|
}
|
|
offset, err := intParam(r, "offset", 0)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "ungueltiger parameter \"offset\"")
|
|
return
|
|
}
|
|
sortVal, err := searchindex.ParseSort(q.Get("sort"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
filters := []searchindex.Filter{{Field: searchindex.FilterCreatedBy, Value: caller}}
|
|
for _, name := range []string{"folder_path", "created_by"} {
|
|
if v := q.Get(name); v != "" {
|
|
field, err := searchindex.ParseFilterField(name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
filters = append(filters, searchindex.Filter{Field: field, Value: v})
|
|
}
|
|
}
|
|
facetFilters, specs, err := parseFacetParams(q)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
searchReq := searchindex.SearchRequest{
|
|
Term: term, Filters: filters, Sort: sortVal, Limit: limit, Offset: offset, Facet: facetFilters,
|
|
}
|
|
|
|
page, err := searchindex.SearchPage(r.Context(), s.index, searchReq)
|
|
if err != nil {
|
|
if errors.Is(err, searchindex.ErrInvalidRequest) {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
resp := responseJSON{Total: page.Total, Limit: limit, Offset: offset, Hits: []hitJSON{}}
|
|
if resp.Limit <= 0 {
|
|
resp.Limit = searchindex.DefaultPageSize
|
|
}
|
|
for _, h := range page.Hits {
|
|
allowed, err := s.permissions.CanAccessDocument(r.Context(), caller, h.DocumentID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if !allowed {
|
|
continue
|
|
}
|
|
resp.Hits = append(resp.Hits, hitJSON{
|
|
DocumentID: h.DocumentID, Title: h.Title, FolderPath: h.FolderPath, DocumentType: h.DocumentType,
|
|
Score: h.Score, UpdatedAt: h.UpdatedAt, SnippetHTML: renderSnippet(h.Snippet),
|
|
})
|
|
}
|
|
if len(specs) > 0 {
|
|
results, err := searchindex.Facets(r.Context(), s.index, searchReq, specs)
|
|
if err != nil {
|
|
if errors.Is(err, searchindex.ErrInvalidRequest) {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
resp.Facets = map[string][]facetValueJSON{}
|
|
for _, fr := range results {
|
|
vals := make([]facetValueJSON, 0, len(fr.Values))
|
|
for _, v := range fr.Values {
|
|
vals = append(vals, facetValueJSON{Value: v.Value, Count: v.Count})
|
|
}
|
|
resp.Facets[facetName(fr.Spec)] = vals
|
|
}
|
|
}
|
|
resp.TookMS = time.Since(start).Milliseconds()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(resp)
|
|
}
|