Files
petal/internal/tts/handler.go
prosolis 69bf3ffde1 Close the door the edge gate used to hold
A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.

The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.

Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.

The rest, smaller:

  - PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
    authentik here fronts half a dozen applications. Still legal, now
    said out loud every boot, and set in both env examples.
  - LLM failures relayed err.Error() to the browser, which carries the
    address of the inference box on the far side of the VPN. Logged
    instead; the client only ever rendered "the helper is resting".
  - Exports scheme-check their links. Escaping makes a URL safe to sit
    in an attribute and says nothing about following it, and an export
    is the one artifact here meant to leave. Writing the test found the
    markdown image src, which I'd missed reading it.
  - The draft rescue is namespaced per account and cleared on sign-out.
    Everything else in localStorage is a preference; this is her unsaved
    writing, sitting in a profile two people share.
  - /auth/logout is POST-only. With SameSite=Lax a GET route lets any
    page on the internet sign her out mid-draft.
  - Image uploads get a per-account allowance and the TTS cache a size
    cap. Both share the encrypted volume the database is on, and a full
    disk is SQLite failing to write, not a feature degrading.
  - The session cookie takes the __Host- prefix over https, so nothing
    else under parodia.dev can plant one. Old cookies still resolve;
    nobody is signed out to get there.
  - npm audit: linkify-it and postcss.

Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:24:47 -07:00

391 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package tts implements read-aloud: it proxies short passages to a local Piper
// HTTP server (a fast, offline neural TTS), optionally transcodes Piper's WAV to
// mp3/opus with ffmpeg, and serves the audio back to the editor. Synthesized
// clips are content-addressed on disk so tapping the same word twice is instant
// and never re-synthesizes. The feature is entirely optional: when TTS_ENDPOINT
// is unset the route isn't mounted and the frontend falls back to the browser's
// Web Speech API.
package tts
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"gitea.parodia.dev/drwily/petal/internal/config"
)
// maxTextBytes bounds a single synthesis request. Read-aloud is for a word or a
// sentence or two, not whole documents; this keeps one tap from pinning Piper.
const maxTextBytes = 4000
// lengthScale slows Piper slightly below natural pace — a learner following along
// with the words, mirroring the old utterance.rate = 0.95. Higher = slower.
const lengthScale = 1.1
// slowLengthScale is the "say it slower" replay (SUGGESTIONS §5e): roughly 0.75×
// the normal pace, which is the speed listening drills have used for decades.
// Piper stretches durations rather than resampling, so the voice keeps its pitch
// instead of turning into a slowed tape.
const slowLengthScale = lengthScale / 0.75
// audioFormat describes one output encoding: the cache-file extension, the
// response Content-Type, and the ffmpeg args that turn Piper's WAV (on stdin)
// into this format (on stdout). A nil ffmpegArgs means "serve the WAV as-is".
type audioFormat struct {
ext string
contentType string
ffmpegArgs []string
}
// formats maps the configured TTS_AUDIO_FORMAT to its encoding. mp3 is the safe
// default (every browser plays it); opus is ~half the size for speech but has
// patchier Safari support; wav skips ffmpeg entirely.
var formats = map[string]audioFormat{
"wav": {ext: ".wav", contentType: "audio/wav"},
"mp3": {ext: ".mp3", contentType: "audio/mpeg",
ffmpegArgs: []string{"-f", "mp3", "-c:a", "libmp3lame", "-b:a", "64k", "-ac", "1"}},
"opus": {ext: ".opus", contentType: "audio/ogg",
ffmpegArgs: []string{"-f", "ogg", "-c:a", "libopus", "-b:a", "32k", "-ac", "1"}},
}
// synthPath normalises TTS_PATH into a leading-slash path with no trailing
// slash, so it concatenates cleanly onto a route's endpoint.
//
// Piper moved synthesis from `POST /` to `POST /synthesize` in 1.6.0, and the
// request body is identical either side of that change. Rather than pinning
// every deployment to one Piper release, the path is configuration: millenia
// keeps the default `/` its installed server expects, and the containerised
// 1.6.0 sidecars on the VPS set `/synthesize`.
func synthPath(p string) string {
p = strings.TrimSpace(p)
if p == "" || p == "/" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return strings.TrimRight(p, "/")
}
// route is the Piper instance and voice id serving one language. Each Piper
// HTTP server loads exactly one model, so distinct languages mean distinct
// endpoints (e.g. English on :5005, Chinese on :5006).
type route struct {
endpoint string // Piper HTTP base URL (no trailing slash)
voice string // Piper voice id (sent in the request; also part of the cache key)
}
// Handler proxies synthesis to Piper and caches the result on disk.
type Handler struct {
routes map[string]route // base language (e.g. "en", "zh") -> Piper instance
synthURI string // path Piper serves synthesis on (see TTSPath)
cacheDir string
format audioFormat
client *http.Client
writes atomic.Uint64 // cache writes since boot; drives the prune throttle
}
// New builds a Handler from config. It returns (nil, false) when TTS_ENDPOINT is
// unset, signalling main to skip mounting the route. An unknown TTS_AUDIO_FORMAT
// falls back to mp3 rather than failing the whole server.
func New(cfg *config.Config) (*Handler, bool) {
if strings.TrimSpace(cfg.TTSEndpoint) == "" {
return nil, false
}
format, ok := formats[strings.ToLower(strings.TrimSpace(cfg.TTSFormat))]
if !ok {
format = formats["mp3"]
}
// Keyed by base language so en-US, en-GB — and pt-PT, pt-BR, bare pt —
// resolve to the one instance that has that language's model loaded (the
// client sends BCP-47 tags, as the old Web Speech path did). Config has
// already dropped any language configured by halves, so an unroutable
// language reaches the client as a 404 and falls back to Web Speech.
routes := map[string]route{}
for lang, v := range cfg.TTSVoices {
routes[lang] = route{endpoint: v.Endpoint, voice: v.Voice}
}
if err := os.MkdirAll(cfg.TTSCacheDir, 0o755); err != nil {
// A missing cache dir isn't fatal — synthesis still works, it just won't
// cache. Disable the feature only on endpoint absence, not this.
fmt.Fprintf(os.Stderr, "tts: cache dir %q: %v\n", cfg.TTSCacheDir, err)
}
return &Handler{
routes: routes,
synthURI: synthPath(cfg.TTSPath),
cacheDir: cfg.TTSCacheDir,
format: format,
client: &http.Client{Timeout: cfg.TTSTimeout},
}, true
}
// Languages lists the base language tags this handler can synthesize, sorted,
// each with the voice serving it — for the startup line, so a deployment says
// which sidecars it actually reached rather than which ones it was configured
// to want.
func (h *Handler) Languages() []string {
out := make([]string, 0, len(h.routes))
for lang, rt := range h.routes {
out = append(out, lang+"="+rt.voice)
}
sort.Strings(out)
return out
}
// Routes mounts the synthesis endpoint. Mount under "/tts" so the full path is
// POST /api/tts.
func (h *Handler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/", h.synth)
return r
}
// synthRequest is the body the editor posts: a passage, the BCP-47 language tag
// it's written in (e.g. "en-US", "zh-CN", "pt-PT"), and whether to say it slowly
// — the replay a learner reaches for when the sentence went past too fast.
type synthRequest struct {
Text string `json:"text"`
Lang string `json:"lang"`
Slow bool `json:"slow"`
}
// synth resolves a voice for the requested language, returns cached audio when
// present, and otherwise asks Piper to synthesize, transcodes if configured,
// caches, and serves. An unconfigured language yields 404 so the client can fall
// back to Web Speech without treating it as an error.
func (h *Handler) synth(w http.ResponseWriter, r *http.Request) {
var req synthRequest
// Cap the raw body generously above maxTextBytes to leave room for JSON
// framing and the lang field; the text itself is truncated after decoding.
if err := json.NewDecoder(io.LimitReader(r.Body, maxTextBytes+1024)).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
text := strings.TrimSpace(req.Text)
if text == "" {
http.Error(w, "empty text", http.StatusBadRequest)
return
}
if len(text) > maxTextBytes {
// Trim back to the last valid rune boundary so we never hand Piper a
// half-encoded multibyte character (common with Chinese, 3 bytes/char).
text = text[:maxTextBytes]
for len(text) > 0 && !utf8.ValidString(text) {
text = text[:len(text)-1]
}
}
rt, ok := h.routes[baseLang(req.Lang)]
if !ok {
// No voice for this language — let the client fall back to Web Speech.
http.Error(w, "no voice for language", http.StatusNotFound)
return
}
scale := lengthScale
if req.Slow {
scale = slowLengthScale
}
// Content-addressed: identical (voice, pace, text) → identical clip. The pace
// belongs in the key — without it the slow replay of a word already heard at
// normal speed would be served from cache at normal speed, which is the one
// request where the difference is the whole point. The format extension keeps
// encodings from colliding in the same dir.
sum := sha256.Sum256([]byte(fmt.Sprintf("%s\n%.3f\n%s", rt.voice, scale, text)))
name := hex.EncodeToString(sum[:])[:32] + h.format.ext
path := filepath.Join(h.cacheDir, name)
if _, err := os.Stat(path); err == nil {
h.serve(w, r, path)
return
}
audio, err := h.synthesize(r.Context(), rt, text, scale)
if err != nil {
http.Error(w, "synthesis failed", http.StatusBadGateway)
fmt.Fprintf(os.Stderr, "tts: synthesize: %v\n", err)
return
}
// Best-effort cache write via a temp file + rename so a concurrent reader
// never sees a half-written clip. A failed write just means no caching.
if h.cacheDir != "" {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, audio, 0o644); err == nil {
_ = os.Rename(tmp, path)
}
h.pruneCache()
}
w.Header().Set("Content-Type", h.format.contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
_, _ = w.Write(audio)
}
// maxCacheBytes bounds the whole clip cache at 512 MiB.
//
// Each clip is small, so nothing about ordinary reading approaches this — a
// year of tapping words is tens of megabytes. What it bounds is the shape of
// the endpoint: the cache key is the *text*, so a client asking for four
// thousand distinct characters at a time writes a new file every request, for
// as long as it cares to. That is an authenticated writer filling the same
// encrypted volume the database lives on, and a full disk is SQLite failing to
// write, not merely read-aloud getting slower.
const maxCacheBytes = 512 << 20
// pruneEvery throttles the sweep: checking the directory on every synthesis
// would stat the whole cache for each new word. Synthesis is already the slow
// path and misses are rare once a writer settles, so one sweep per this many
// cache writes keeps the cost invisible while still converging long before the
// limit means anything.
const pruneEvery = 64
// pruneCache trims the cache back under maxCacheBytes, oldest-first, and is a
// no-op the great majority of the time it is called.
//
// Oldest by modification time is a fair approximation of least-recently-useful
// here: a clip is written once and only ever read afterwards, so its age is how
// long ago someone wanted it. Evicting one costs a re-synthesis, never data —
// which is why this can be as approximate as it likes, and why every error
// along the way is simply given up on.
func (h *Handler) pruneCache() {
if n := h.writes.Add(1); n%pruneEvery != 0 {
return
}
entries, err := os.ReadDir(h.cacheDir)
if err != nil {
return
}
type clip struct {
path string
size int64
mod time.Time
}
var clips []clip
var total int64
for _, e := range entries {
if e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
clips = append(clips, clip{filepath.Join(h.cacheDir, e.Name()), info.Size(), info.ModTime()})
total += info.Size()
}
if total <= maxCacheBytes {
return
}
sort.Slice(clips, func(i, j int) bool { return clips[i].mod.Before(clips[j].mod) })
// Drop to 80% rather than exactly to the line, so the next few hundred
// clips don't each trigger another sweep.
target := int64(maxCacheBytes / 100 * 80)
removed := 0
for _, c := range clips {
if total <= target {
break
}
if os.Remove(c.path) == nil {
total -= c.size
removed++
}
}
fmt.Fprintf(os.Stderr, "tts: cache over %d bytes — evicted %d oldest clip(s)\n", int64(maxCacheBytes), removed)
}
// serve streams a cached clip with a long-lived immutable cache header (the URL
// is content-addressed, so the bytes never change for a given request).
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", h.format.contentType)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeFile(w, r, path)
}
// synthesize POSTs to the route's Piper instance, then transcodes the returned
// WAV when the configured format calls for it.
func (h *Handler) synthesize(ctx context.Context, rt route, text string, scale float64) ([]byte, error) {
body, _ := json.Marshal(map[string]any{
"text": text,
"voice": rt.voice,
"length_scale": scale,
})
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, rt.endpoint+h.synthURI, bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := h.client.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
return nil, fmt.Errorf("piper %d: %s", resp.StatusCode, strings.TrimSpace(string(snippet)))
}
wav, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if h.format.ffmpegArgs == nil {
return wav, nil
}
return transcode(ctx, wav, h.format.ffmpegArgs)
}
// transcode pipes WAV bytes through ffmpeg (stdin → stdout) into the target
// encoding. ffmpeg is assumed on PATH; an error here surfaces as a 502.
func transcode(ctx context.Context, wav []byte, args []string) ([]byte, error) {
// Guard ffmpeg against a hang independent of the HTTP client timeout.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
full := append([]string{"-hide_banner", "-loglevel", "error", "-i", "pipe:0"}, args...)
full = append(full, "pipe:1")
cmd := exec.CommandContext(ctx, "ffmpeg", full...)
cmd.Stdin = bytes.NewReader(wav)
var out, errBuf bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errBuf
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg: %v: %s", err, strings.TrimSpace(errBuf.String()))
}
return out.Bytes(), nil
}
// baseLang reduces a BCP-47 tag to its primary subtag, lowercased: "en-US" → "en",
// "zh-CN" → "zh", "" → "". Mirrors the client's pickVoice base-language matching.
func baseLang(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexAny(tag, "-_"); i >= 0 {
return tag[:i]
}
return tag
}