SRC-01: manticore-suchindex-fuer-mails
Manticore-RT-Index für Mail-Suchdokumente (Betreff, Text, Anhangstext, Metadaten), statisches versioniertes Schema. - fields.go: statische Feld-/Index-Namen-Whitelist, einzige Quelle für Feldnamen im Paket (vermeidet known-issues-archivmail.md #11/#12: Sprintf/Join-basierte SQL-Klauselbildung). - migrations/0001_mail_documents.sql: statisches Schema, per go:embed eingebettet, über /sql?mode=raw angelegt (kein String-Zusammenbau). - client.go: Index/Search über die strukturierte Manticore-HTTP-JSON-API, Tenant-Filter über strukturiertes equals-Feld statt WHERE-Interpolation. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-01-PRUEFPROTOKOLL.md): 1. TestNoDynamicSQLClauseBuilding: automatisierter Quelltext-Scan bestätigt keine Sprintf/Join-SQL-Klauselbildung. 2. TestSearch_MaliciousInputDoesNotAlterFieldNames: Injection-artige Eingaben verändern nachweislich keine Feldnamen im gesendeten Payload. 3. TestSearch_FindsExpectedDocument: Funktionstest gegen echtes Manticore auf 192.168.1.131 liefert erwartete Treffer. Zusätzlich TestSearch_TenantIsolation für Akzeptanzkriterium 3. Kein Umbau: storage/crypto/encstorage/dedup unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
704b64fe27
commit
c3bf8100b1
@@ -0,0 +1,181 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 0001_mail_documents.sql: statisches, versioniertes Schema
|
||||
// (Akzeptanzkriterium 1). Feldnamen hier UND in fields.go müssen
|
||||
// deckungsgleich bleiben — die Konstanten in fields.go sind die einzige
|
||||
// Stelle, aus der Go-Code Feldnamen für Schreib-/Lesezugriffe bezieht.
|
||||
// Manticores SQL-Parser unterstützt keine "--"-Kommentare, daher bleibt
|
||||
// die eingebettete Datei selbst kommentarfrei.
|
||||
//
|
||||
//go:embed migrations/0001_mail_documents.sql
|
||||
var schemaMigration string
|
||||
|
||||
// Client spricht ausschließlich über die strukturierte Manticore-HTTP-
|
||||
// JSON-API (kein String-Zusammenbau von SQL-Klauseln, siehe fields.go).
|
||||
// Die SQL-Schnittstelle wird nur für EnsureSchema verwendet, und dort
|
||||
// ausschließlich mit dem statischen, eingebetteten Migrationstext —
|
||||
// niemals mit zur Laufzeit zusammengesetzten Werten.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureSchema legt den Index gemäß dem versionierten, statischen
|
||||
// Migrationstext an (Akzeptanzkriterium 1). Idempotent (CREATE TABLE
|
||||
// IF NOT EXISTS im Migrationstext).
|
||||
func (c *Client) EnsureSchema(ctx context.Context) error {
|
||||
form := "query=" + schemaMigration
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sql?mode=raw", strings.NewReader(form))
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: schema-anfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: schema anlegen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("search: schema anlegen, status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Document ist ein Mail-Suchdokument. Feldnamen im JSON-Tag entsprechen
|
||||
// exakt den Konstanten in fields.go.
|
||||
type Document struct {
|
||||
ID uint64 `json:"-"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
MessageID string `json:"message_id"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
AttachmentText string `json:"attachment_text"`
|
||||
SentAtUnixEpoch int64 `json:"sent_at"`
|
||||
}
|
||||
|
||||
// Index legt/ersetzt ein Suchdokument (Akzeptanzkriterium 2: Schreibzugriff
|
||||
// ausschließlich über statische, vordefinierte Feldnamen aus dem
|
||||
// Document-Struct — kein dynamischer Feldname möglich).
|
||||
func (c *Client) Index(ctx context.Context, doc Document) error {
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"id": doc.ID,
|
||||
"doc": doc,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: dokument serialisieren: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/replace", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: index-anfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("search: dokument indexieren: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("search: dokument indexieren, status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Result ist ein Suchtreffer.
|
||||
type Result struct {
|
||||
MessageID string
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
|
||||
// den Mandanten tenantSlug (Akzeptanzkriterium 3: mandantengetrennt
|
||||
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
|
||||
// Match-Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel.
|
||||
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
||||
matchFields := strings.Join(searchableTextFields, ",")
|
||||
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"query": map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||
{"match": map[string]any{matchFields: queryText}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suchanfrage serialisieren: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/search", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suchanfrage bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: suche ausführen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: antwort lesen: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("search: suche, status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var parsed searchResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("search: antwort parsen: %w", err)
|
||||
}
|
||||
|
||||
results := make([]Result, 0, len(parsed.Hits.Hits))
|
||||
for _, hit := range parsed.Hits.Hits {
|
||||
results = append(results, Result{
|
||||
MessageID: hit.Source.MessageID,
|
||||
Subject: hit.Source.Subject,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type searchResponse struct {
|
||||
Hits struct {
|
||||
Hits []struct {
|
||||
Source struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Subject string `json:"subject"`
|
||||
} `json:"_source"`
|
||||
} `json:"hits"`
|
||||
} `json:"hits"`
|
||||
}
|
||||
Reference in New Issue
Block a user