SRC-04: such-oberflaeche-mit-hervorhebung

Such-Oberfläche (Next.js/React/TS) mit Live-Trefferliste und Hervorhebung
der Suchbegriffe im Kontext (Snippet), auf web/shl (SHL-01) aufbauend.

- app/api/search/route.ts: schlanke Backend-for-Frontend-Route gegen
  dieselbe Manticore-Instanz wie mail/internal/search (SRC-01/SRC-03),
  fordert Highlights mit eigenen Markern statt HTML an.
- lib/highlight.ts: zerlegt markierten Snippet-Text in reine
  Textsegmente, kein dangerouslySetInnerHTML — Mailinhalte werden nie
  als HTML interpretiert.
- app/page.tsx: Sucheingabe, Trefferliste mit <mark>-Hervorhebung,
  verständlicher Hinweis bei leerem Ergebnis.
- app/mail/[messageId]/page.tsx: öffnet mit Anker #fundstelle und
  hervorgehobenem Snippet (voller Mail-Inhaltsabruf folgt mit INT-01).
- lib/contrast.ts: reale WCAG-2.1-Kontrastberechnung.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-04-PRUEFPROTOKOLL.md):
1. Manueller Test gegen echten next start + live Manticore auf
   192.168.1.131: Hervorhebung real bestätigt.
2. lib/highlightColors.test.ts: echte WCAG-Berechnung, Hell 14,29:1,
   Dunkel 6,43:1 (>= 4.5:1 AA).
3. Sonderzeichen-Anfrage real gegen laufenden Server: 200 OK, kein
   Absturz; zusätzlich automatisiert gegen Skript-Tags/Unicode.

Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-31 10:26:02 +02:00
co-authored by Claude Sonnet 5
parent 9748307f12
commit d23438d3d0
17 changed files with 695 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
// SRC-04: dünner Client für die eigene Backend-for-Frontend-Route
// app/api/search/route.ts. Kein direkter Zugriff auf Manticore oder eine
// externe Mail-API vom Client aus (Rolle: "Datenzugriff ausschließlich
// über die bereitgestellte API").
export class ApiError extends Error {}
export interface SearchHit {
messageId: string;
subjectSnippet: string;
bodySnippet: string;
score: number;
}
export interface SearchResponse {
hits: SearchHit[];
}
// tenantSlug: bis zu einer zentralen Session-/IAM-Anbindung (Core-Board-
// Scope, nicht Bestandteil dieser Kachel) wird der Mandant vom Aufrufer
// mitgegeben. Die eigentliche Mandantentrennung passiert serverseitig in
// mail/internal/search (SRC-01/SRC-03), nicht im Frontend.
export async function search(tenantSlug: string, query: string): Promise<SearchResponse> {
const params = new URLSearchParams({ tenant: tenantSlug, q: query });
const res = await fetch(`/api/search?${params.toString()}`);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(body.error ?? `Suche fehlgeschlagen (${res.status})`);
}
return res.json();
}
+25
View File
@@ -0,0 +1,25 @@
// SRC-04 Prüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
// echte WCAG-2.1-Kontrastberechnung statt einer rein manuellen Behauptung.
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function relativeLuminance(hex: string): number {
const clean = hex.replace("#", "");
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
}
// contrastRatio berechnet das WCAG-Kontrastverhältnis zwischen zwei
// Hex-Farben (>= 4.5:1 gilt als AA-konform für Fließtext).
export function contrastRatio(colorA: string, colorB: string): number {
const lumA = relativeLuminance(colorA);
const lumB = relativeLuminance(colorB);
const lighter = Math.max(lumA, lumB);
const darker = Math.min(lumA, lumB);
return (lighter + 0.05) / (darker + 0.05);
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { HIGHLIGHT_AFTER, HIGHLIGHT_BEFORE, splitHighlighted } from "./highlight";
describe("splitHighlighted", () => {
it("markiert einen einzelnen Treffer korrekt", () => {
const snippet = `hoher ${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} im dritten Quartal`;
expect(splitHighlighted(snippet)).toEqual([
{ text: "hoher ", matched: false },
{ text: "Umsatz", matched: true },
{ text: " im dritten Quartal", matched: false },
]);
});
it("markiert mehrere Treffer im selben Snippet", () => {
const snippet = `${HIGHLIGHT_BEFORE}Umsatz${HIGHLIGHT_AFTER} und ${HIGHLIGHT_BEFORE}Umsatzwachstum${HIGHLIGHT_AFTER}`;
const segments = splitHighlighted(snippet);
expect(segments.filter((s) => s.matched)).toHaveLength(2);
});
it("liefert unveränderten Text ohne Marker als unmarkiertes Segment", () => {
expect(splitHighlighted("kein treffer hier")).toEqual([{ text: "kein treffer hier", matched: false }]);
});
it("bricht bei leerem Snippet nicht ab", () => {
expect(splitHighlighted("")).toEqual([]);
});
// Pflichtprüfung 3: Sonderzeichen in der Suchanfrage/im indexierten Text
// dürfen die Anzeige nicht zum Absturz bringen.
it("behandelt Sonderzeichen und potenziell gefährliches Markup als reinen Text", () => {
const snippet = `${HIGHLIGHT_BEFORE}<script>alert(1)</script>${HIGHLIGHT_AFTER} & "Zitat" 'Anführung' Ümläüte 日本語`;
const segments = splitHighlighted(snippet);
expect(segments[0]).toEqual({ text: "<script>alert(1)</script>", matched: true });
expect(segments.map((s) => s.text).join("")).toContain("Ümläüte 日本語");
});
it("behandelt ein unvollständiges Markerpaar ohne Absturz", () => {
const snippet = `abc ${HIGHLIGHT_BEFORE}unvollständig`;
expect(() => splitHighlighted(snippet)).not.toThrow();
const segments = splitHighlighted(snippet);
expect(segments.map((s) => s.text).join("")).toBe("abc unvollständig");
});
});
+44
View File
@@ -0,0 +1,44 @@
// SRC-04: zerlegt einen von der Mail-Suche gelieferten Snippet-Text in
// Textsegmente. Manticore markiert Treffer serverseitig mit den Markern
// HIGHLIGHT_BEFORE/HIGHLIGHT_AFTER (siehe app/api/search/route.ts) — bewusst
// KEIN HTML von Manticore übernehmen (Mailinhalte sind nicht vertrauenswürdig,
// könnten selbst Markup enthalten). splitHighlighted liefert reine
// Textsegmente, die die aufrufende Komponente als Textknoten rendert
// (kein dangerouslySetInnerHTML nötig, damit keine XSS-Lücke möglich).
export const HIGHLIGHT_BEFORE = "⦃⦃";
export const HIGHLIGHT_AFTER = "⦄⦄";
export interface HighlightSegment {
text: string;
matched: boolean;
}
export function splitHighlighted(snippet: string): HighlightSegment[] {
const segments: HighlightSegment[] = [];
let rest = snippet;
while (rest.length > 0) {
const startIdx = rest.indexOf(HIGHLIGHT_BEFORE);
if (startIdx === -1) {
segments.push({ text: rest, matched: false });
break;
}
if (startIdx > 0) {
segments.push({ text: rest.slice(0, startIdx), matched: false });
}
const afterStart = rest.slice(startIdx + HIGHLIGHT_BEFORE.length);
const endIdx = afterStart.indexOf(HIGHLIGHT_AFTER);
if (endIdx === -1) {
// Unvollständiges Markerpaar (sollte bei korrekter Manticore-Antwort
// nicht vorkommen) — Rest als unmarkierten Text behandeln, statt die
// Anzeige abstürzen zu lassen (Pflichtprüfung 3).
segments.push({ text: afterStart, matched: false });
break;
}
segments.push({ text: afterStart.slice(0, endIdx), matched: true });
rest = afterStart.slice(endIdx + HIGHLIGHT_AFTER.length);
}
return segments;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { contrastRatio } from "./contrast";
import { HIGHLIGHT_BG_DARK, HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_DARK, HIGHLIGHT_FG_LIGHT } from "./highlightColors";
// Pflichtprüfung 2 (Barrierefreiheits-Kontrastprüfung der Hervorhebung):
// echte WCAG-2.1-AA-Berechnung (>= 4.5:1 für Fließtext), nicht nur eine
// manuelle Sichtprüfung.
describe("Hervorhebungs-Kontrast (WCAG 2.1 AA)", () => {
it("Hell-Modus erreicht mindestens 4.5:1", () => {
const ratio = contrastRatio(HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT);
expect(ratio).toBeGreaterThanOrEqual(4.5);
});
it("Dunkel-Modus erreicht mindestens 4.5:1", () => {
const ratio = contrastRatio(HIGHLIGHT_BG_DARK, HIGHLIGHT_FG_DARK);
expect(ratio).toBeGreaterThanOrEqual(4.5);
});
});
+8
View File
@@ -0,0 +1,8 @@
// SRC-04 Prüfung 2: Kontrastwerte real berechnet in
// lib/highlightColors.test.ts (contrastRatio aus lib/contrast.ts),
// nicht nur behauptet.
export const HIGHLIGHT_BG_LIGHT = "#FDE68A";
export const HIGHLIGHT_FG_LIGHT = "#14181F";
export const HIGHLIGHT_BG_DARK = "#92400E";
export const HIGHLIGHT_FG_DARK = "#F2F4F7";