Files
petal/internal/config/config.go
T
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

228 lines
8.7 KiB
Go

package config
import (
"net/url"
"os"
"strconv"
"strings"
"time"
)
// Config holds all runtime configuration, loaded from environment variables.
// See .env.example for the full list and defaults.
type Config struct {
Port string
BaseURL string
DatabasePath string
ImageDir string // on-disk store for editor image uploads
// DictPath is DreamDict's built dict.db, read-only, sitting beside
// petal.db. It is what gives Petal French, European Portuguese and Spanish
// word lookups; without it only the embedded English/Chinese datasets
// exist, which is exactly how a laptop checkout runs. A missing file is
// therefore not an error — see lexicon.OpenDreamDict.
DictPath string
// LLM
LLMBackend string // "vllm" | "ollama"
LLMEndpoint string
LLMModel string // checkpoint model (small, fast)
LLMChatModel string // Ask Petal model; falls back to LLMModel if empty
LLMTimeout time.Duration
// TTS (read-aloud). Off unless TTSEndpoint is set — when empty, the /api/tts
// route isn't mounted and the frontend falls back to the browser's Web Speech
// API. Endpoint points at a local Piper HTTP server.
TTSEndpoint string // Piper instance serving the English voice; also the on/off switch
// TTSVoices is every language Petal can read aloud, keyed by base language
// tag ("en", "zh", "pt", …). Each Piper server loads exactly one model, so
// a language *is* an instance — and the instances are discovered from the
// environment rather than named in this struct: one
// TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair per language, so the fr and es
// pairs cost a compose service and two lines of .env rather than a code
// change. English keeps the unsuffixed TTS_ENDPOINT/TTS_VOICE_EN it has
// always had.
TTSVoices map[string]TTSVoice
// TTSPath is the path Piper serves synthesis on. Piper moved it from "/" to
// "/synthesize" in 1.6.0 with an unchanged request body, so this is a
// version knob, not a feature: millenia's older server keeps the default,
// the containerised sidecars set "/synthesize".
TTSPath string
TTSCacheDir string // on-disk store for synthesized clips (content-addressed)
TTSTimeout time.Duration
TTSFormat string // mp3 | opus | wav — mp3/opus transcode Piper's WAV via ffmpeg
// Auth. OIDC against Authentik. Login is enabled only when the issuer, the
// client id and the secret are all present; with any of them missing Petal
// falls back to the single hardcoded local user, which is what local
// development wants and what every deployment did before Phase 16.
AuthentikURL string // issuer URL of the Petal provider in Authentik
AuthentikClientID string
AuthentikClientSecret string
// AllowedSubs gates who may sign in, as a comma-separated list of OIDC
// subject ids and/or email addresses. Empty means everyone Authentik
// authenticates — right for a single-household instance, wrong the moment
// the IdP serves an audience wider than Petal's.
AllowedSubs string
// RequireAuth refuses to start when OIDC isn't configured, instead of
// falling back to the single local user.
//
// The fallback is the right behaviour on a laptop and a catastrophe on a
// public host: a typo in AUTHENTIK_CLIENT_SECRET turns every anonymous
// visitor into the `local` user, with full read and write over someone's
// private journals, and says so only in a log line nobody is reading. The
// Traefik basic-auth gate that used to stand behind that mistake was
// removed when Petal learned to authenticate for itself, so nothing catches
// it now.
//
// Defaulted from BASE_URL rather than declared: a Petal that knows itself by
// a real public origin has no business running open, and one on localhost
// has no business demanding an IdP. Set PETAL_REQUIRE_AUTH explicitly to
// override in either direction.
RequireAuth bool
}
// TTSVoice is one Piper instance and the single voice it has loaded.
type TTSVoice struct {
Endpoint string
Voice string
}
// AuthEnabled reports whether real logins are configured. When false, Petal
// resolves every request to the local user.
func (c *Config) AuthEnabled() bool {
return c.AuthentikURL != "" && c.AuthentikClientID != "" && c.AuthentikClientSecret != ""
}
// Load reads configuration from the environment, applying sane local-dev defaults.
func Load() *Config {
baseURL := env("BASE_URL", "http://localhost:8080")
return &Config{
Port: env("PORT", "8080"),
BaseURL: baseURL,
DatabasePath: env("DATABASE_PATH", "./data/petal.db"),
ImageDir: env("IMAGE_DIR", "./data/images"),
DictPath: env("DICT_PATH", "./data/dict.db"),
LLMBackend: env("LLM_BACKEND", "vllm"),
LLMEndpoint: env("LLM_ENDPOINT", "http://localhost:8000"),
LLMModel: env("LLM_MODEL", ""),
LLMChatModel: env("LLM_CHAT_MODEL", ""),
LLMTimeout: envDuration("LLM_TIMEOUT", 30*time.Second),
TTSEndpoint: env("TTS_ENDPOINT", ""),
TTSVoices: ttsVoices(os.Environ()),
TTSPath: env("TTS_PATH", "/"),
TTSCacheDir: env("TTS_CACHE_DIR", "./data/tts"),
TTSTimeout: envDuration("TTS_TIMEOUT", 15*time.Second),
TTSFormat: env("TTS_AUDIO_FORMAT", "mp3"),
AuthentikURL: env("AUTHENTIK_URL", ""),
AuthentikClientID: env("AUTHENTIK_CLIENT_ID", ""),
AuthentikClientSecret: env("AUTHENTIK_CLIENT_SECRET", ""),
AllowedSubs: env("PETAL_ALLOWED_SUBS", ""),
RequireAuth: envBool("PETAL_REQUIRE_AUTH", !isLoopbackOrigin(baseURL)),
}
}
// isLoopbackOrigin reports whether a base URL names this machine — the shape a
// development checkout has, and the only shape where running without a login is
// a reasonable default. Anything else (a hostname, a public origin) is a
// deployment, however small.
func isLoopbackOrigin(baseURL string) bool {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return false
}
switch strings.ToLower(u.Hostname()) {
case "localhost", "127.0.0.1", "::1", "":
return true
}
return false
}
// ttsVoices reads the Piper instances out of an environment slice (as returned
// by os.Environ) into a map keyed by base language tag.
//
// English is the unsuffixed pair, TTS_ENDPOINT + TTS_VOICE_EN, because that is
// what every deployment already sets and read-aloud has always been English
// first. Every other language is a TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG> pair,
// discovered rather than enumerated — TTS_ENDPOINT_ZH is what millenia and the
// VPS already use, and TTS_ENDPOINT_PT is all the Portuguese pair needs.
//
// <LANG> is the *base* tag: an environment variable name cannot hold the hyphen
// in "pt-PT", and the handler routes on the base tag anyway (a request for
// pt-PT, pt-BR or bare pt reaches the same instance, because there is only one
// Portuguese voice loaded). A pair is ignored unless both halves are set: half
// a configuration should read as "no voice for this language" and fall back to
// the browser, not as an instance that answers every request with an error.
func ttsVoices(environ []string) map[string]TTSVoice {
vals := make(map[string]string, len(environ))
for _, kv := range environ {
if k, v, ok := strings.Cut(kv, "="); ok {
vals[k] = v
}
}
voices := map[string]TTSVoice{}
add := func(lang, endpoint, voice string) {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
voice = strings.TrimSpace(voice)
if endpoint == "" || voice == "" {
return
}
voices[lang] = TTSVoice{Endpoint: endpoint, Voice: voice}
}
// The two languages that shipped before this was a map keep their voice
// defaults, so an existing deployment that names only the endpoints (as
// millenia's unit does) sounds exactly as it did.
voiceOr := func(key, fallback string) string {
if v := strings.TrimSpace(vals[key]); v != "" {
return v
}
return fallback
}
add("en", vals["TTS_ENDPOINT"], voiceOr("TTS_VOICE_EN", "en_US-amy-medium"))
for k, endpoint := range vals {
suffix, ok := strings.CutPrefix(k, "TTS_ENDPOINT_")
if !ok || suffix == "" {
continue
}
voice := vals["TTS_VOICE_"+suffix]
if suffix == "ZH" {
voice = voiceOr("TTS_VOICE_ZH", "zh_CN-huayan-medium")
}
add(strings.ToLower(suffix), endpoint, voice)
}
return voices
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// envBool reads a boolean knob. Anything unparseable keeps the default rather
// than silently reading as false — a mistyped PETAL_REQUIRE_AUTH must not be the
// thing that turns the guard off.
func envBool(key string, fallback bool) bool {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return fallback
}
func envDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}