package health import ( "encoding/json" "net" "net/http" "strings" "sync" "time" "go.uber.org/zap" "golang.org/x/time/rate" "github.com/veylant/ia-gateway/internal/apierror" "github.com/veylant/ia-gateway/internal/pii" ) // PlaygroundAnalyzeRequest is the JSON body for POST /playground/analyze. type PlaygroundAnalyzeRequest struct { Text string `json:"text"` } // PlaygroundAnalyzeResponse is the JSON response for POST /playground/analyze. type PlaygroundAnalyzeResponse struct { Entities []PlaygroundEntity `json:"entities"` AnonymizedText string `json:"anonymized_text"` } // PlaygroundEntity is a detected PII entity returned by the playground endpoint. type PlaygroundEntity struct { Type string `json:"type"` Value string `json:"value,omitempty"` // original value (shown in UI badge) Pseudonym string `json:"pseudonym,omitempty"` Start int `json:"start"` End int `json:"end"` Confidence float64 `json:"confidence"` Layer string `json:"layer"` } // playgroundIPLimiter holds per-IP token bucket limiters. // Limiters are evicted after 5 minutes of inactivity. type playgroundIPLimiter struct { mu sync.Mutex limiters map[string]*ipEntry } type ipEntry struct { limiter *rate.Limiter lastSeen time.Time } var globalPlaygroundLimiter = &playgroundIPLimiter{ limiters: make(map[string]*ipEntry), } func (p *playgroundIPLimiter) get(ip string) *rate.Limiter { p.mu.Lock() defer p.mu.Unlock() entry, ok := p.limiters[ip] if !ok { // 20 req/min per IP, burst of 5. entry = &ipEntry{limiter: rate.NewLimiter(rate.Every(3*time.Second), 5)} p.limiters[ip] = entry } entry.lastSeen = time.Now() // Evict stale entries (>5 min inactive) to prevent unbounded memory growth. for k, v := range p.limiters { if time.Since(v.lastSeen) > 5*time.Minute { delete(p.limiters, k) } } return entry.limiter } // PlaygroundAnalyzeHandler returns an HTTP handler for POST /playground/analyze. // The endpoint is public (no JWT required) and rate-limited to 20 req/min per IP. // If piiClient is nil the handler returns a simulated response so the playground // page remains usable even when the PII sidecar is not running. func PlaygroundAnalyzeHandler(piiClient *pii.Client, logger *zap.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // IP-based rate limiting. ip := clientIP(r) if !globalPlaygroundLimiter.get(ip).Allow() { apierror.WriteError(w, apierror.NewRateLimitError( "playground rate limit exceeded — max 20 requests/minute per IP", )) return } // Parse request body. var req PlaygroundAnalyzeRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error())) return } var resp PlaygroundAnalyzeResponse if req.Text == "" { resp = PlaygroundAnalyzeResponse{Entities: []PlaygroundEntity{}, AnonymizedText: ""} writeJSON(w, resp) return } // Delegate to PII service if available. if piiClient != nil { result, err := piiClient.Detect(r.Context(), req.Text, "playground", "public-playground", true, false) if err == nil { entities := make([]PlaygroundEntity, 0, len(result.Entities)) for _, e := range result.Entities { entities = append(entities, PlaygroundEntity{ Type: e.EntityType, Value: req.Text[clamp(int(e.Start), 0, len(req.Text)):clamp(int(e.End), 0, len(req.Text))], Pseudonym: e.Pseudonym, Start: int(e.Start), End: int(e.End), Confidence: float64(e.Confidence), Layer: e.DetectionLayer, }) } resp = PlaygroundAnalyzeResponse{ Entities: entities, AnonymizedText: result.AnonymizedText, } writeJSON(w, resp) return } logger.Warn("playground PII detection failed — using simulated response", zap.Error(err)) } // PII service unavailable: return a deterministic simulated response so the // playground page still demonstrates the feature. resp = simulatedResponse(req.Text) writeJSON(w, resp) } } // simulatedResponse returns a static example response when the PII service is down. func simulatedResponse(text string) PlaygroundAnalyzeResponse { return PlaygroundAnalyzeResponse{ Entities: []PlaygroundEntity{ {Type: "PERSON", Value: "(example)", Pseudonym: "[PERSON_1]", Start: 0, End: 0, Confidence: 0.99, Layer: "simulated"}, {Type: "EMAIL_ADDRESS", Value: "(example)", Pseudonym: "[EMAIL_1]", Start: 0, End: 0, Confidence: 0.99, Layer: "simulated"}, }, AnonymizedText: strings.TrimSpace(text) + "\n\n[PII service offline — showing example output]", } } // clientIP extracts the real client IP from the request, respecting X-Real-IP // and X-Forwarded-For headers set by Traefik / Nginx. func clientIP(r *http.Request) string { if ip := r.Header.Get("X-Real-IP"); ip != "" { if parsed := net.ParseIP(strings.TrimSpace(ip)); parsed != nil { return parsed.String() } } if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { // X-Forwarded-For may be a comma-separated list; use the first (client) IP. parts := strings.SplitN(forwarded, ",", 2) if parsed := net.ParseIP(strings.TrimSpace(parts[0])); parsed != nil { return parsed.String() } } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host } func clamp(v, min, max int) int { if v < min { return min } if v > max { return max } return v } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(v) }