talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
109 lines
4.3 KiB
Go
109 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// CORS door for POST /speak, and ONLY for this endpoint: a browser (e.g. a
|
|
// later LibreChat integration) may call exactly the announcement interface
|
|
// cross-origin, never the control endpoints. Permission comes exclusively
|
|
// from the config list server.allowed_origins (env TALK_ALLOWED_ORIGINS):
|
|
// exact origins such as "https://chat.example.com"; the entry "*" explicitly
|
|
// allows any origin. Deliberate default: EMPTY. The daemon then sends no
|
|
// CORS headers at all and the browser blocks (door closed until configured).
|
|
//
|
|
// The door is closed server-side, not only browser-side (originGuard): CORS
|
|
// alone only prevents READING the response, not the effect. A browser sends
|
|
// a POST with a text/plain body or a form without preflight, and a server
|
|
// that still reads the body as JSON would have spoken, muted or cancelled.
|
|
// Therefore talkd rejects every request with a foreign Origin header (403)
|
|
// that is not the permitted path via /speak. Only the preflight OPTIONS
|
|
// /speak stays a 204 without CORS headers for unlisted origins (it has no
|
|
// effect; the browser drops the planned POST). CLI, curl and other
|
|
// non-browser clients send no Origin header and are unaffected.
|
|
|
|
// CORS response headers (names in one place so preflight and the real POST
|
|
// set the same ones).
|
|
const (
|
|
headerAllowOrigin = "Access-Control-Allow-Origin"
|
|
headerAllowMethods = "Access-Control-Allow-Methods"
|
|
headerAllowHeaders = "Access-Control-Allow-Headers"
|
|
headerMaxAge = "Access-Control-Max-Age"
|
|
headerVary = "Vary"
|
|
)
|
|
|
|
// preflightAge is the preflight cache duration: 10 minutes cover a chat
|
|
// session and keep the OPTIONS chatter small.
|
|
const preflightAge = "600"
|
|
|
|
// corsACAO returns the value for Access-Control-Allow-Origin: the literal
|
|
// "*" for a wildcard configuration, otherwise the exactly matched origin
|
|
// (identical to the config entry). Empty means: not allowed.
|
|
func (h *handler) corsACAO(r *http.Request) string {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
return ""
|
|
}
|
|
for _, allowed := range h.allowedOrigins {
|
|
if allowed == "*" {
|
|
return "*"
|
|
}
|
|
if allowed == origin {
|
|
return allowed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// setSpeakCors sets the CORS headers on real POST /speak responses when the
|
|
// request origin is allowed. Vary: Origin is always present (even with the
|
|
// door closed) so caches never wrongly reuse the response across origins.
|
|
func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add(headerVary, "Origin")
|
|
if acao := h.corsACAO(r); acao != "" {
|
|
w.Header().Set(headerAllowOrigin, acao)
|
|
}
|
|
}
|
|
|
|
// speakPreflight handles OPTIONS /speak (CORS preflight): 204 with the
|
|
// permission headers for a listed origin, 204 without any CORS headers
|
|
// otherwise (unlisted, missing or door completely closed). The browser
|
|
// drops the actual request while the daemon stays reachable for
|
|
// non-browser clients.
|
|
func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Add(headerVary, "Origin")
|
|
if acao := h.corsACAO(r); acao != "" {
|
|
w.Header().Set(headerAllowOrigin, acao)
|
|
w.Header().Set(headerAllowMethods, "POST, OPTIONS")
|
|
w.Header().Set(headerAllowHeaders, "Content-Type")
|
|
w.Header().Set(headerMaxAge, preflightAge)
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// originGuard is the middleware in front of the router: requests with an
|
|
// Origin header (i.e. from a browser) only pass as OPTIONS /speak (preflight,
|
|
// no effect) or as POST /speak from a listed origin. Everything else
|
|
// (unlisted origin on /speak, any browser access to control endpoints and
|
|
// /mcp) is rejected with 403 before a body is read or an effect is
|
|
// triggered. Without an Origin header (CLI, curl, MCP clients) nothing
|
|
// happens.
|
|
func (h *handler) originGuard(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
isSpeak := strings.TrimRight(r.URL.Path, "/") == "/speak"
|
|
if isSpeak && (r.Method == http.MethodOptions || (r.Method == http.MethodPost && h.corsACAO(r) != "")) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
w.Header().Add(headerVary, "Origin")
|
|
writeError(w, http.StatusForbidden,
|
|
"origin "+origin+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)")
|
|
})
|
|
}
|