feat(api): CORS-Tür für POST /speak — OPTIONS-Preflight und ACAO aus Config
OPTIONS /speak beantwortet Preflights mit 204 + Allow-Headers/Methods/Max-Age, wenn die Origin in server.allowed_origins steht; ungeliste Origins und leere Liste bekommen keine CORS-Header (Browser blockt). Nur /speak, kein anderer Endpunkt. /status ergänzt additiv cors:[origins] (immer vorhanden, [] wenn zu).
This commit is contained in:
1 parent
cef6e6b9f2
commit
156c20dbb3
5 files changed
+174
-35
No files matched your search
+69
-27
@@ -24,7 +24,7 @@ type HandlerConfig struct {
|
||||
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
|
||||
Mute queue.MuteStore
|
||||
|
||||
// Version ist die Daemon-Version für GET /status.
|
||||
// Version ist die Daemon-Version für GET /status und serverInfo.
|
||||
Version string
|
||||
|
||||
// KnownProviders sind die gültigen Provider-Namen (Validierung des
|
||||
@@ -34,73 +34,106 @@ type HandlerConfig struct {
|
||||
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
|
||||
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
|
||||
Provider func() string
|
||||
|
||||
// AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer =
|
||||
// keine CORS-Header, der Browser blockt; siehe cors.go).
|
||||
AllowedOrigins []string
|
||||
}
|
||||
|
||||
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
||||
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
|
||||
func NewHandler(cfg HandlerConfig) http.Handler {
|
||||
h := &handler{
|
||||
queue: cfg.Queue,
|
||||
mute: cfg.Mute,
|
||||
version: cfg.Version,
|
||||
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||
provider: cfg.Provider,
|
||||
queue: cfg.Queue,
|
||||
mute: cfg.Mute,
|
||||
version: cfg.Version,
|
||||
known: make(map[string]bool, len(cfg.KnownProviders)),
|
||||
provider: cfg.Provider,
|
||||
allowedOrigins: cfg.AllowedOrigins,
|
||||
}
|
||||
for _, name := range cfg.KnownProviders {
|
||||
h.known[name] = true
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /speak", h.speak)
|
||||
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06)
|
||||
mux.HandleFunc("GET /status", h.status)
|
||||
mux.HandleFunc("POST /cancel", h.cancel)
|
||||
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06)
|
||||
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
|
||||
return mux
|
||||
}
|
||||
|
||||
// handler ist der interne Zustand der HTTP-Oberfläche.
|
||||
type handler struct {
|
||||
queue *queue.Queue
|
||||
mute queue.MuteStore
|
||||
version string
|
||||
known map[string]bool
|
||||
provider func() string
|
||||
queue *queue.Queue
|
||||
mute queue.MuteStore
|
||||
version string
|
||||
known map[string]bool
|
||||
provider func() string
|
||||
allowedOrigins []string
|
||||
}
|
||||
|
||||
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
||||
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
|
||||
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz
|
||||
// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen —
|
||||
// die Antwort nennt dann den Grund ({"discarded":"…"}).
|
||||
// die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur
|
||||
// hier und nur bei gelisteter Origin (siehe cors.go).
|
||||
func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
|
||||
h.setSpeakCors(w, r)
|
||||
|
||||
var req SpeakRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, "text fehlt oder ist leer")
|
||||
adm := h.admitSpeak(req)
|
||||
if adm.errMsg != "" {
|
||||
writeError(w, adm.status, adm.errMsg)
|
||||
return
|
||||
}
|
||||
writeJSON(w, adm.status, adm.resp)
|
||||
}
|
||||
|
||||
// speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von
|
||||
// POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body —
|
||||
// 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit
|
||||
// errMsg als Fehler (MCP übersetzt beides, siehe mcp.go).
|
||||
type speakAdmission struct {
|
||||
status int
|
||||
resp SpeakResponse
|
||||
errMsg string
|
||||
}
|
||||
|
||||
// admitSpeak ist der eine Aufnahme-Pfad hinter POST /speak und dem
|
||||
// MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive
|
||||
// Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide
|
||||
// Türen, kein Contract-Drift.
|
||||
func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
return speakAdmission{status: http.StatusBadRequest, errMsg: "text fehlt oder ist leer"}
|
||||
}
|
||||
if req.Priority == "" {
|
||||
req.Priority = string(queue.PriorityNormal)
|
||||
}
|
||||
if !queue.Priority(req.Priority).Valid() {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority))
|
||||
return
|
||||
return speakAdmission{status: http.StatusBadRequest,
|
||||
errMsg: fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)}
|
||||
}
|
||||
if req.Provider != "" && !h.known[req.Provider] {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider))
|
||||
return
|
||||
return speakAdmission{status: http.StatusBadRequest,
|
||||
errMsg: fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)}
|
||||
}
|
||||
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
|
||||
writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang))
|
||||
return
|
||||
return speakAdmission{status: http.StatusBadRequest,
|
||||
errMsg: fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)}
|
||||
}
|
||||
|
||||
if h.mute.Muted() {
|
||||
writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true})
|
||||
return
|
||||
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Muted: true}}
|
||||
}
|
||||
|
||||
adm, err := h.queue.Enqueue(queue.Item{
|
||||
@@ -112,17 +145,16 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
|
||||
Now: req.Now,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error())
|
||||
return
|
||||
return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()}
|
||||
}
|
||||
if adm.Discarded != "" {
|
||||
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
|
||||
// Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit
|
||||
// Aufrufer keine Sonderbehandlung brauchen.
|
||||
writeJSON(w, http.StatusAccepted, SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast})
|
||||
return
|
||||
return speakAdmission{status: http.StatusAccepted,
|
||||
resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}}
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, SpeakResponse{ID: adm.ID})
|
||||
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID}}
|
||||
}
|
||||
|
||||
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
|
||||
@@ -139,6 +171,8 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||||
Queue: len(pending),
|
||||
QueueIDs: ids,
|
||||
Muted: h.mute.Muted(),
|
||||
MCP: true,
|
||||
Cors: corsList(h.allowedOrigins),
|
||||
}
|
||||
if cur, ok := h.queue.Current(); ok {
|
||||
st.Current = &CurrentItem{
|
||||
@@ -155,6 +189,14 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status
|
||||
// soll "cors": [] zeigen, wenn die Tür zu ist.
|
||||
func corsList(origins []string) []string {
|
||||
out := make([]string, len(origins))
|
||||
copy(out, origins)
|
||||
return out
|
||||
}
|
||||
|
||||
// lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von
|
||||
// /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
|
||||
// frei / Ruhezeit inaktiv).
|
||||
|
||||
Reference in new issue
Block a user