diff --git a/.env.example b/.env.example index 836d41f..074cb3c 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,12 @@ TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Pipe # them commented out for local development and Petal runs as the single # hardcoded `local` user, exactly as it did before auth landed. # +# That fallback is scoped to development on purpose. With a BASE_URL naming +# anything but localhost, Petal refuses to start rather than run open — see +# PETAL_REQUIRE_AUTH — because the fallback on a reachable host means every +# anonymous visitor is the `local` user, with full read and write over every +# document in the database. +# # AUTHENTIK_URL is the issuer of the Petal provider in Authentik (the value of # its "OpenID Configuration Issuer" field). The redirect URI to register there # is BASE_URL + /auth/callback. @@ -52,8 +58,15 @@ TTS_AUDIO_FORMAT=mp3 # mp3 | opus | wav — mp3/opus transcode Pipe # AUTHENTIK_CLIENT_SECRET= # # Who may sign in: comma-separated OIDC subject ids and/or email addresses. -# Empty = anyone Authentik authenticates. +# Empty = anyone Authentik authenticates, which is right for a single-household +# instance and wrong the moment the IdP serves a wider audience than Petal. An +# empty list is warned about at every boot rather than assumed either way. # PETAL_ALLOWED_SUBS=her@example.com,me@example.com +# +# Whether a missing OIDC configuration is fatal. Defaults to false for a +# loopback BASE_URL and true for anything else, so neither a laptop nor a +# deployment normally has to name it. +# PETAL_REQUIRE_AUTH=true # --- Deferred (not wired in the local-dev build) --- diff --git a/cmd/server/headers_test.go b/cmd/server/headers_test.go new file mode 100644 index 0000000..f63f328 --- /dev/null +++ b/cmd/server/headers_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// The baseline policy has to actually permit the frontend Vite builds. These +// are the allowances dist/index.html needs; if a future build starts emitting +// an inline script or pulling from a new host, that shows up here rather than +// as a blank page in production. +func TestBaselineCSPCoversTheBuiltFrontend(t *testing.T) { + required := []string{ + "script-src 'self'", // Vite emits no inline script + "'unsafe-inline' https://fonts.googleapis.com", // React style={{…}} + the font link + "https://fonts.gstatic.com", // the font files themselves + "blob:", // read-aloud plays an object URL + "object-src 'none'", + "base-uri 'self'", + "frame-ancestors 'self'", + } + for _, want := range required { + if !strings.Contains(contentSecurityPolicy, want) { + t.Errorf("baseline CSP is missing %q:\n%s", want, contentSecurityPolicy) + } + } +} + +// The middleware is a floor, not a ceiling: it runs *before* the handler +// precisely so a route serving untrusted bytes can overwrite the policy with a +// stricter one. This is the contract the image store depends on, and the reason +// the policy no longer lives in the Traefik labels — customresponseheaders +// would overwrite it in the other direction. +func TestRouteMayTightenTheBaselineCSP(t *testing.T) { + strict := "default-src 'none'; sandbox" + handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Security-Policy", strict) + w.WriteHeader(http.StatusOK) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/images/x.svg", nil)) + + if got := rec.Header().Get("Content-Security-Policy"); got != strict { + t.Fatalf("handler's policy was not honoured: got %q, want %q", got, strict) + } + // The headers it didn't touch still stand. + if rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Error("baseline nosniff was lost") + } +} + +// Every ordinary response carries the baseline. +func TestBaselineHeadersOnAPlainResponse(t *testing.T) { + handler := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil)) + + for header, want := range map[string]string{ + "Content-Security-Policy": contentSecurityPolicy, + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", + } { + if got := rec.Header().Get(header); got != want { + t.Errorf("%s = %q, want %q", header, got, want) + } + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index a3989d1..e35a653 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -64,21 +64,45 @@ func main() { sessions := auth.NewSessionStore(database.DB) users := auth.NewUserStore(database.DB) + // …and the fallback is exactly what must not happen quietly on a public + // host. Refuse to start rather than serve someone's journals to the open + // internet because one environment variable was misspelled. See + // config.RequireAuth for why this defaults on for any non-loopback BASE_URL. + if !cfg.AuthEnabled() && cfg.RequireAuth { + log.Fatalf("auth: refusing to start unauthenticated at %s.\n"+ + " Petal would resolve every anonymous request to the single %q user, with full\n"+ + " read and write over every document in the database.\n"+ + " Set AUTHENTIK_URL, AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET, or set\n"+ + " PETAL_REQUIRE_AUTH=false if this really is a trusted private network.", + cfg.BaseURL, db.LocalUserID) + } + var resolver auth.Resolver = auth.StaticResolver(db.LocalUserID) var oidcClient *auth.OIDC if cfg.AuthEnabled() { + allowed := auth.ParseAllowlist(cfg.AllowedSubs) oidcClient = auth.NewOIDC(context.Background(), auth.Options{ IssuerURL: cfg.AuthentikURL, ClientID: cfg.AuthentikClientID, ClientSecret: cfg.AuthentikClientSecret, BaseURL: cfg.BaseURL, - Allowed: auth.ParseAllowlist(cfg.AllowedSubs), + Allowed: allowed, }, sessions, users) resolver = sessions if n, err := sessions.Prune(); err == nil && n > 0 { log.Printf("auth: pruned %d expired session(s)", n) } log.Printf("auth: OIDC enabled (issuer=%s, redirect=%s)", cfg.AuthentikURL, oidcClient.RedirectURI()) + // An empty allowlist is a legitimate choice for a single-household + // instance and a wide-open door in front of an IdP that fronts anything + // else. Petal cannot tell which it is, so it says so every boot rather + // than assuming. + if len(allowed) == 0 { + log.Printf("auth: WARNING — PETAL_ALLOWED_SUBS is empty, so EVERY account %s "+ + "authenticates may sign in and start writing here. Set it to the "+ + "comma-separated emails (or subject ids) that belong in this Petal.", + cfg.AuthentikURL) + } } else { log.Printf("auth: OIDC not configured — running as the single %q user", db.LocalUserID) } @@ -106,6 +130,7 @@ func main() { r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) + r.Use(securityHeaders) // Build version: a hash of the embedded SPA shell. Vite rewrites index.html // with content-hashed asset names on every build, so this string changes @@ -231,6 +256,47 @@ func main() { } } +// contentSecurityPolicy is the default policy for everything Petal serves. +// +// It lives here rather than in the Traefik labels, and that move is the point: +// Traefik's customResponseHeaders *sets* a header, overwriting whatever the +// application chose, so a policy declared at the edge silently replaces the +// stricter one an individual route needs. Stored images need exactly that (an +// uploaded SVG is a document that can carry script — see internal/images), and +// a rule the edge can quietly undo is not a rule. +// +// The allowances are what the built frontend actually uses, no more: script +// only from Petal itself (Vite emits no inline script — this policy is checked +// against dist/index.html), inline *styles* because React's style={{…}} props +// compile to style attributes, and Google's font hosts because index.html links +// them. object-src and base-uri close the two attribute-injection routes that +// survive HTML escaping. +const contentSecurityPolicy = "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + + "font-src 'self' data: https://fonts.gstatic.com; " + + "img-src 'self' data: blob:; " + + "media-src 'self' data: blob:; " + + "connect-src 'self'; " + + "object-src 'none'; " + + "base-uri 'self'; " + + "form-action 'self'; " + + "frame-ancestors 'self'" + +// securityHeaders lays down the baseline response headers before the handler +// runs, so a route that needs something stricter — the image store — simply +// overwrites its own copy on the way past. Ordering is the mechanism: this is a +// floor, not a ceiling. +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurityPolicy) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "same-origin") + next.ServeHTTP(w, r) + }) +} + // maxAPIBodyBytes caps a JSON API request body at 2 MiB. That's far above any // real document save (the body is text plus lightweight marks; images upload // separately by reference) while still bounding abuse. Exceeding it makes the diff --git a/deploy/README.md b/deploy/README.md index 0d413ba..d566a01 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -233,8 +233,21 @@ unauthenticated `/api/health` router that existed only to escape it: every `/api route now answers 401 without a session, and the only thing an anonymous visitor gets is the app shell and a redirect to sign in. -If you ever run this stack *without* `AUTHENTIK_*` configured — Petal then falls -back to the single `local` user — put the gate back before pointing DNS at it: +Removing that gate meant a missing `AUTHENTIK_*` variable stopped being a +nuisance and became an exposure: Petal would fall back to the single `local` +user and hand every anonymous visitor full read and write over the database, +saying so only in a log line. So **it now refuses to start instead**: + +``` +auth: refusing to start unauthenticated at https://petal.parodia.dev. + Petal would resolve every anonymous request to the single "local" user, ... +``` + +The guard defaults on for any `BASE_URL` that isn't loopback, so a laptop +checkout still runs open and a deployment cannot. If you genuinely want an +unauthenticated instance on a trusted private network, say so out loud with +`PETAL_REQUIRE_AUTH=false` — and if it is reachable from anywhere else, put a +gate back in front of it first: ```yaml traefik.http.routers.petal.middlewares: compression@file,petal-headers,petal-auth @@ -243,6 +256,28 @@ traefik.http.middlewares.petal-auth.basicauth.users: ${PETAL_BASIC_AUTH:?} with `htpasswd -nbB petal 'your-password'` in `.env` as `PETAL_BASIC_AUTH`. +### Who gets in + +`PETAL_ALLOWED_SUBS` is a comma-separated list of emails and/or OIDC subject +ids. **Set it.** Empty means everyone authentik authenticates, and authentik on +this host fronts several applications — a valid account there is not the same as +belonging in someone's private journal. An empty list is legal (a single- +household instance may want it) and warns at every boot: + +``` +auth: WARNING — PETAL_ALLOWED_SUBS is empty, so EVERY account ... may sign in +``` + +### Response headers + +`Content-Security-Policy`, `X-Content-Type-Options` and `Referrer-Policy` are set +by the binary, not by the Traefik labels. Traefik's `customresponseheaders` +*overwrites*, which would silently replace the stricter policy an individual +route picks for itself — the image store serves stored uploads under +`default-src 'none'; sandbox` so that an SVG someone pasted into a document +cannot run as a page on Petal's own origin. Only HSTS stays at the edge, where +TLS is actually terminated. + --- ## 4a. Moving an account (`scripts/migrate_local_user.py`) diff --git a/deploy/petal.env.example b/deploy/petal.env.example index bcc5c93..0a8ce18 100644 --- a/deploy/petal.env.example +++ b/deploy/petal.env.example @@ -20,13 +20,9 @@ TZ=Europe/Lisbon PETAL_UID=1001 PETAL_GID=1001 -# --- Interim edge gate (delete when Phase 16 auth lands) --------------------- -# Petal has no authentication of its own yet — StaticResolver hands every -# request the same local user — so Traefik holds the door with basic auth until -# the OIDC flow exists. user:bcrypt-hash, as produced by: -# htpasswd -nbB petal 'your-password' -# /api/health is deliberately exempt (its own router) so monitoring still works. -PETAL_BASIC_AUTH= +# (The interim PETAL_BASIC_AUTH edge gate is gone: Petal authenticates for +# itself now, and the auth block at the bottom of this file is what holds the +# door. A second password in front of a real login is one more thing to lose.) # --- LLM (millenia, over headscale) ------------------------------------------ # The only cross-VPN dependency. Petal degrades warmly when it's unreachable: @@ -67,16 +63,37 @@ TTS_AUDIO_FORMAT=mp3 TTS_TIMEOUT=15s # --- Auth (Authentik OIDC) --------------------------------------------------- -# Authentik already runs on this host. Set all three and Petal authenticates -# for itself; leave any unset and it falls back to the single `local` user -# (which on a public host means the Traefik basic-auth gate must stay). +# NOT OPTIONAL HERE. Authentik already runs on this host; set all three and +# Petal authenticates for itself. +# +# Leave any of them unset and Petal REFUSES TO START, because the alternative is +# worse: it would otherwise fall back to resolving every anonymous request to +# the single `local` user, handing the open internet full read and write over +# every document in the database. That fallback is right on a laptop and a +# catastrophe on this host, so the guard is on for any non-loopback BASE_URL. +# See PETAL_REQUIRE_AUTH below. # # AUTHENTIK_URL is the provider's issuer, and the redirect URI to register in # Authentik is https://petal.parodia.dev/auth/callback. -# AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/ -# AUTHENTIK_CLIENT_ID=petal -# AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_URL=https://auth.parodia.dev/application/o/petal/ +AUTHENTIK_CLIENT_ID=petal +AUTHENTIK_CLIENT_SECRET= + +# Who may sign in: comma-separated subject ids and/or emails. # -# Who may sign in: comma-separated subject ids and/or emails. Empty = anyone -# Authentik authenticates, which is wider than this instance wants. -# PETAL_ALLOWED_SUBS= +# SET THIS. Empty means everyone Authentik authenticates, and Authentik on this +# host fronts half a dozen applications — being a valid user there is not the +# same as belonging in someone's private journal. An empty value is legal (a +# single-household instance may genuinely want it) and says so loudly in the +# startup log every boot. +# +# An email is knowable in advance; a subject id is an opaque uuid nobody can +# know before that person's first login. Use emails to invite, subject ids to +# pin. +PETAL_ALLOWED_SUBS=her@example.com,me@example.com + +# The guard itself. Defaulted from BASE_URL — loopback origins run open, real +# ones demand a login — so it does not normally need setting. Set it to false +# only for a deployment genuinely reachable from nowhere but a trusted network, +# and understand that it means anyone who reaches Petal is the `local` user. +# PETAL_REQUIRE_AUTH=true diff --git a/docker-compose.yml b/docker-compose.yml index 2ee7996..d959378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,11 +98,18 @@ services: # second password in front of a real login is just one more thing to lose. traefik.http.routers.petal.middlewares: compression@file,petal-headers traefik.http.services.petal.loadbalancer.server.port: "8080" - # Petal is a private writing space: no framing, no sniffing, HSTS on. - traefik.http.middlewares.petal-headers.headers.customresponseheaders.Content-Security-Policy: frame-ancestors 'self' + # HSTS is the edge's business — it is a statement about the TLS + # termination, which happens here and not in the container. + # + # The Content-Security-Policy that used to sit alongside it has moved into + # the app (see securityHeaders in cmd/server/main.go). customresponseheaders + # *overwrites*, so a policy set here would silently replace the stricter + # one an individual route chooses for itself — which is exactly what the + # image store does to keep an uploaded SVG from running as a page. A rule + # the edge can quietly undo is not a rule. X-Content-Type-Options and + # Referrer-Policy moved with it for the same reason: one place to read, + # and no dependence on this file being deployed alongside the binary. traefik.http.middlewares.petal-headers.headers.customresponseheaders.Strict-Transport-Security: max-age=31536000; includeSubDomains - traefik.http.middlewares.petal-headers.headers.customresponseheaders.X-Content-Type-Options: nosniff - traefik.http.middlewares.petal-headers.headers.customresponseheaders.Referrer-Policy: same-origin piper-en: build: diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go index aa07ead..7c9545a 100644 --- a/internal/auth/oidc.go +++ b/internal/auth/oidc.go @@ -129,7 +129,10 @@ func (o *OIDC) Routes() chi.Router { r := chi.NewRouter() r.Get("/login", o.login) r.Get("/callback", o.callback) - r.Get("/logout", o.logout) + // POST only. Signing out is a state change, and SameSite=Lax deliberately + // *does* send the session cookie on a top-level cross-site GET — so a GET + // route here means any page on the internet can sign her out mid-draft by + // linking to it, or embedding it as an image. Small harm, free to remove. r.Post("/logout", o.logout) return r } @@ -275,8 +278,8 @@ func (o *OIDC) callback(w http.ResponseWriter, r *http.Request) { // matters: clearing only the cookie leaves a token that still works if it was // ever captured. func (o *OIDC) logout(w http.ResponseWriter, r *http.Request) { - if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" { - if err := o.sessions.Revoke(c.Value); err != nil { + if token := SessionToken(r); token != "" { + if err := o.sessions.Revoke(token); err != nil { log.Printf("auth: revoke failed: %v", err) } } diff --git a/internal/auth/oidc_test.go b/internal/auth/oidc_test.go index 5965964..3850893 100644 --- a/internal/auth/oidc_test.go +++ b/internal/auth/oidc_test.go @@ -234,7 +234,7 @@ func TestLoginRoundTrip(t *testing.T) { // Signing out revokes server-side, not just in the browser. out := httptest.NewRecorder() - flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodGet, "/logout", nil))) + flow.ServeHTTP(out, jar.attach(httptest.NewRequest(http.MethodPost, "/logout", nil))) if out.Code != http.StatusFound { t.Fatalf("logout status=%d", out.Code) } @@ -243,6 +243,19 @@ func TestLoginRoundTrip(t *testing.T) { } } +// Signing out is a state change, so it must not be reachable by GET: with +// SameSite=Lax the session cookie *is* sent on a top-level cross-site +// navigation, which would let any page on the internet sign her out mid-draft. +func TestLogoutRejectsGET(t *testing.T) { + _, flow, _, _ := newFlow(t, nil) + + out := httptest.NewRecorder() + flow.ServeHTTP(out, httptest.NewRequest(http.MethodGet, "/logout", nil)) + if out.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET /logout status=%d, want 405", out.Code) + } +} + // A callback whose state doesn't match the cookie is a forged one. func TestCallbackRejectsBadState(t *testing.T) { idp, flow, sessions, _ := newFlow(t, nil) diff --git a/internal/auth/session.go b/internal/auth/session.go index 190ea4c..6b79ebd 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -11,8 +11,31 @@ import ( "time" ) -// SessionCookie is the cookie carrying the opaque session token. -const SessionCookie = "petal_session" +// The cookie carrying the opaque session token, in its two spellings. +// +// Over https the name takes the __Host- prefix, which is not decoration: the +// browser will only accept such a cookie if it is Secure, Path=/, and carries +// no Domain attribute — and, crucially, refuses to let any other host set it. +// Without the prefix, anything that can write cookies for a sibling name under +// parodia.dev (another service on the box, a subdomain takeover) can plant a +// session cookie in her browser that Petal will then read as hers. +// +// The prefix is impossible over plain http, because it requires Secure and a +// browser drops a Secure cookie on an insecure origin. So local development +// keeps the bare name, and the name in use follows the same `secure` flag the +// rest of the cookie does. +const ( + SessionCookie = "petal_session" + HostSessionCookie = "__Host-petal_session" +) + +// sessionCookieName is the name to *write* under this scheme. +func sessionCookieName(secure bool) string { + if secure { + return HostSessionCookie + } + return SessionCookie +} const ( // sessionTTL is how long a session lives without use. Thirty days, sliding: @@ -78,11 +101,28 @@ func (s *SessionStore) Create(userID, userAgent string) (string, error) { // Resolve implements [Resolver]: it reads the session cookie, validates it, and // returns the user it belongs to — extending the session's life while it does. func (s *SessionStore) Resolve(r *http.Request) (string, error) { - c, err := r.Cookie(SessionCookie) - if err != nil || c.Value == "" { + token := SessionToken(r) + if token == "" { return "", ErrNoSession } - return s.userFor(c.Value) + return s.userFor(token) +} + +// SessionToken pulls the raw session token out of a request, preferring the +// __Host- spelling. +// +// Both are read because a deployment that was signing people in before the +// prefix existed has browsers holding the old name; those sessions stay valid +// and quietly re-issue under the new name at the next sign-in. The prefixed one +// wins where both are present, since it is the one another host could not have +// planted. +func SessionToken(r *http.Request) string { + for _, name := range []string{HostSessionCookie, SessionCookie} { + if c, err := r.Cookie(name); err == nil && c.Value != "" { + return c.Value + } + } + return "" } // userFor validates a raw token and slides its expiry forward. @@ -146,7 +186,7 @@ func hashToken(token string) string { // browser drop the cookie and silently break local login. func SetSessionCookie(w http.ResponseWriter, token string, secure bool) { http.SetCookie(w, &http.Cookie{ - Name: SessionCookie, + Name: sessionCookieName(secure), Value: token, Path: "/", HttpOnly: true, @@ -158,14 +198,24 @@ func SetSessionCookie(w http.ResponseWriter, token string, secure bool) { // ClearSessionCookie expires the session cookie in the browser. The matching // server-side row must be revoked separately — that's the half that counts. +// +// Both spellings are expired, not just the one currently written: a browser +// carrying a pre-prefix cookie must not be left holding it after signing out, +// which is precisely the case where "clear the cookie" is the part the user can +// see working. func ClearSessionCookie(w http.ResponseWriter, secure bool) { - http.SetCookie(w, &http.Cookie{ - Name: SessionCookie, - Value: "", - Path: "/", - HttpOnly: true, - Secure: secure, - SameSite: http.SameSiteLaxMode, - MaxAge: -1, - }) + for _, name := range []string{HostSessionCookie, SessionCookie} { + if name == HostSessionCookie && !secure { + continue // the browser would reject a non-Secure __Host- cookie + } + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + } } diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go index 7860bf3..ffc7224 100644 --- a/internal/auth/session_test.go +++ b/internal/auth/session_test.go @@ -309,3 +309,67 @@ func TestAllowlist(t *testing.T) { } } } + +// Over https the cookie takes the __Host- prefix, which the browser will only +// accept from the exact host that set it — closing the door on a sibling +// service under the same registrable domain planting a session in her browser. +// Over plain http it cannot: the prefix requires Secure, and a browser drops a +// Secure cookie on an insecure origin, so local development would silently stop +// logging in. +func TestSessionCookieNamePerScheme(t *testing.T) { + secure := httptest.NewRecorder() + SetSessionCookie(secure, "tok", true) + c := secure.Result().Cookies()[0] + if c.Name != HostSessionCookie { + t.Fatalf("https cookie name=%q, want %q", c.Name, HostSessionCookie) + } + // The prefix is a promise about these three attributes; a browser rejects + // the cookie outright if any is wrong. + if !c.Secure || c.Path != "/" || c.Domain != "" { + t.Fatalf("__Host- cookie violates its own contract: %+v", c) + } + + insecure := httptest.NewRecorder() + SetSessionCookie(insecure, "tok", false) + if name := insecure.Result().Cookies()[0].Name; name != SessionCookie { + t.Fatalf("http cookie name=%q, want %q", name, SessionCookie) + } +} + +// A browser holding a cookie issued before the prefix existed must stay signed +// in — and start using the new name at its next sign-in, not be logged out to +// get there. +func TestResolveAcceptsEitherCookieName(t *testing.T) { + store, _, _ := newStores(t) + token, err := store.Create("bob", "test-agent") + if err != nil { + t.Fatal(err) + } + + for _, name := range []string{SessionCookie, HostSessionCookie} { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: name, Value: token}) + got, err := store.Resolve(r) + if err != nil || got != "bob" { + t.Fatalf("%s: resolved to %q (err=%v)", name, got, err) + } + } +} + +// Signing out must not leave the browser holding either spelling. +func TestClearSessionCookieExpiresBothNames(t *testing.T) { + rec := httptest.NewRecorder() + ClearSessionCookie(rec, true) + + cleared := map[string]bool{} + for _, c := range rec.Result().Cookies() { + if c.MaxAge < 0 { + cleared[c.Name] = true + } + } + for _, name := range []string{SessionCookie, HostSessionCookie} { + if !cleared[name] { + t.Errorf("%s was left in the browser after signing out", name) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index fbc448d..16b2dba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,7 +1,9 @@ package config import ( + "net/url" "os" + "strconv" "strings" "time" ) @@ -61,6 +63,22 @@ type Config struct { // 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. @@ -77,9 +95,10 @@ func (c *Config) AuthEnabled() bool { // 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: env("BASE_URL", "http://localhost:8080"), + BaseURL: baseURL, DatabasePath: env("DATABASE_PATH", "./data/petal.db"), ImageDir: env("IMAGE_DIR", "./data/images"), DictPath: env("DICT_PATH", "./data/dict.db"), @@ -101,9 +120,26 @@ func Load() *Config { 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. // @@ -169,6 +205,18 @@ func env(key, fallback string) string { 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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c75d616..e9f2c69 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -86,3 +86,51 @@ func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) { t.Errorf("discovered %v, want none", voices) } } + +// The fallback to the single local user is right on a laptop and a catastrophe +// on a public host, so it is defaulted from the origin Petal knows itself by +// rather than left to be remembered. +func TestRequireAuthDefaultsFromBaseURL(t *testing.T) { + cases := []struct { + baseURL string + want bool + }{ + {"http://localhost:8080", false}, + {"http://127.0.0.1:8080", false}, + {"http://[::1]:8080", false}, + {"", false}, // no BASE_URL set at all: the local-dev default + {"https://petal.parodia.dev", true}, + {"http://petal.parodia.dev", true}, + {"https://petal.example.com/", true}, + } + for _, c := range cases { + t.Setenv("BASE_URL", c.baseURL) + t.Setenv("PETAL_REQUIRE_AUTH", "") + if got := Load().RequireAuth; got != c.want { + t.Errorf("BASE_URL=%q: RequireAuth=%v, want %v", c.baseURL, got, c.want) + } + } +} + +// The default is a default, not a rule: a trusted private network is a real +// deployment shape, and so is wanting the guard on locally. +func TestRequireAuthExplicitOverride(t *testing.T) { + t.Setenv("BASE_URL", "https://petal.parodia.dev") + t.Setenv("PETAL_REQUIRE_AUTH", "false") + if Load().RequireAuth { + t.Error("an explicit false must be honoured on a public origin") + } + + t.Setenv("BASE_URL", "http://localhost:8080") + t.Setenv("PETAL_REQUIRE_AUTH", "true") + if !Load().RequireAuth { + t.Error("an explicit true must be honoured on localhost") + } + + // A typo must not be the thing that disables the guard. + t.Setenv("BASE_URL", "https://petal.parodia.dev") + t.Setenv("PETAL_REQUIRE_AUTH", "nope") + if !Load().RequireAuth { + t.Error("an unparseable value must keep the default, not read as false") + } +} diff --git a/internal/docs/export.go b/internal/docs/export.go index f2c49e7..80838eb 100644 --- a/internal/docs/export.go +++ b/internal/docs/export.go @@ -287,7 +287,14 @@ func mdBlock(n pmNode, depth int) string { return "---" case "image": alt := n.attrStr("alt") - return fmt.Sprintf("![%s](%s)", alt, n.attrStr("src")) + src := safeURL(n.attrStr("src")) + if src == "" { + // Nowhere safe to point. Keep the alt text as plain prose — it is + // the part that carries meaning — rather than emitting an image + // whose destination was rejected. See safeURL. + return alt + } + return fmt.Sprintf("![%s](%s)", alt, src) case "table": return mdTable(n) case "bulletList", "orderedList": @@ -396,7 +403,9 @@ func applyMdMarks(n pmNode) string { if n.hasMark("underline") { t = "" + t + "" } - if href := n.markAttr("link", "href"); href != "" { + // Same rule as the HTML export: plenty of Markdown renderers pass a + // `javascript:` destination straight through into an . See safeURL. + if href := safeURL(n.markAttr("link", "href")); href != "" { t = "[" + t + "](" + href + ")" } return t @@ -518,7 +527,16 @@ func htmlBlock(n pmNode) string { return "
\n" case "image": alt := htmlEscape(n.attrStr("alt")) - return fmt.Sprintf("

\"%s\"

\n", htmlEscape(n.attrStr("src")), alt) + src := safeURL(n.attrStr("src")) + if src == "" { + // Nowhere safe to point: keep the alt text, which is the part that + // carries meaning, rather than emitting a broken image. + if alt == "" { + return "" + } + return "

" + alt + "

\n" + } + return fmt.Sprintf("

\"%s\"

\n", htmlEscape(src), alt) case "table": return htmlTable(n) case "bulletList", "orderedList": @@ -617,7 +635,9 @@ func applyHTMLMarks(n pmNode) string { if n.hasMark("highlight") { t = "" + t + "" } - if href := n.markAttr("link", "href"); href != "" { + // An unsafe href is dropped, not the link: the words stay, they just stop + // being clickable. See safeURL. + if href := safeURL(n.markAttr("link", "href")); href != "" { t = fmt.Sprintf("
%s", htmlEscape(href), t) } return t @@ -858,6 +878,53 @@ func htmlEscape(s string) string { return r.Replace(s) } +// safeURLSchemes are the schemes an exported document may point at. Escaping +// makes a URL safe to sit inside an attribute; it says nothing about what +// happens when the attribute is followed, and `javascript:` survives it +// untouched. +// +// The toolbar can't produce one — it prefixes anything it doesn't recognise +// with https:// — but the toolbar is not the only way in: PUT /api/docs/{id} +// stores whatever Tiptap JSON it is given. And an export is the one artifact +// here that is *meant* to leave: the passport and the .html backup are files a +// writer hands to a teacher or an editor, opened on a machine that has no +// reason to trust them. A link that runs code when clicked is not something to +// ship inside one. +// +// Relative and fragment links pass through: they're how a document refers to +// its own headings, and they can't reach anything. +var safeURLSchemes = map[string]bool{ + "http": true, "https": true, "mailto": true, "tel": true, "ftp": true, +} + +// safeURL returns u if it is safe to follow from an exported file, and "" if it +// isn't. A dropped href leaves the link text in place — the reader loses a +// destination, never the writing. +func safeURL(u string) string { + trimmed := strings.TrimSpace(u) + if trimmed == "" { + return "" + } + // A scheme is everything before the first ':', but only when no '/', '?' or + // '#' comes first — otherwise "notes/a:b" would read as the "notes/a" scheme. + // Nothing before a colon means a relative or fragment link, which is fine. + if i := strings.IndexAny(trimmed, ":/?#"); i >= 0 && trimmed[i] == ':' { + // Control characters and whitespace are stripped by browsers *before* + // the scheme is read, so "java\nscript:" is javascript:. Fold them out + // before deciding rather than after. + scheme := strings.Map(func(r rune) rune { + if r <= ' ' || r == 0x7f { + return -1 + } + return r + }, trimmed[:i]) + if !safeURLSchemes[strings.ToLower(scheme)] { + return "" + } + } + return trimmed +} + func xmlEscape(s string) string { r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) return r.Replace(s) diff --git a/internal/docs/export_test.go b/internal/docs/export_test.go index 2aec8b7..9b26662 100644 --- a/internal/docs/export_test.go +++ b/internal/docs/export_test.go @@ -8,6 +8,8 @@ import ( "net/http" "strings" "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" ) // richDocJSON is a Tiptap document exercising headings, marks, and a list — @@ -243,3 +245,73 @@ func TestExportUnsupportedFormat(t *testing.T) { t.Fatalf("expected 400 for unsupported format, got %d", rec.Code) } } + +// Escaping makes a URL safe to sit inside an attribute; it says nothing about +// what happens when the attribute is followed. An export is the one artifact +// here meant to leave — the file handed to a teacher, opened on a machine with +// no reason to trust it — so a destination that runs code is dropped. +func TestExportDropsUnsafeLinkSchemes(t *testing.T) { + unsafe := []string{ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "java\nscript:alert(1)", // browsers strip control characters first + " javascript:alert(1)", + "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", + "vbscript:msgbox(1)", + } + for _, href := range unsafe { + if got := safeURL(href); got != "" { + t.Errorf("safeURL(%q) = %q, want it dropped", href, got) + } + } + + safe := []string{ + "https://example.com/a?b=1#c", + "http://example.com", + "mailto:her@example.com", + "/api/images/abc.png", + "#a-heading", + "notes/chapter:one.md", // a colon that isn't a scheme + } + for _, href := range safe { + if got := safeURL(href); got != href { + t.Errorf("safeURL(%q) = %q, want it kept", href, got) + } + } +} + +// End to end through the renderers: an unsafe href loses its destination, never +// its words. +func TestRenderedExportsCarryNoScriptURLs(t *testing.T) { + doc := db.Document{ + Title: "Notes", + Content: `{"type":"doc","content":[{"type":"paragraph","content":[ + {"type":"text","text":"click me","marks":[{"type":"link","attrs":{"href":"javascript:alert(1)"}}]}]}, + {"type":"image","attrs":{"src":"javascript:alert(2)","alt":"a drawing"}}]}`, + } + + html, err := renderHTMLFile(doc) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(html)), "javascript:") { + t.Fatalf("html export carried a javascript: URL:\n%s", html) + } + if !strings.Contains(string(html), "click me") { + t.Fatal("html export dropped the link text along with the href") + } + if !strings.Contains(string(html), "a drawing") { + t.Fatal("html export dropped the alt text of the rejected image") + } + + md, err := renderMarkdown(doc) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(md)), "javascript:") { + t.Fatalf("markdown export carried a javascript: URL:\n%s", md) + } + if !strings.Contains(string(md), "click me") { + t.Fatal("markdown export dropped the link text along with the href") + } +} diff --git a/internal/httputil/json.go b/internal/httputil/json.go index 006154a..1ca4b33 100644 --- a/internal/httputil/json.go +++ b/internal/httputil/json.go @@ -35,3 +35,18 @@ func ServerError(w http.ResponseWriter, err error) { log.Printf("internal error: %v", err) ErrorJSON(w, http.StatusInternalServerError, "something went wrong") } + +// UpstreamError is ServerError's counterpart for a dependency Petal calls out +// to — the model, chiefly. Same discipline, and for a sharper reason: a dial +// failure's error text contains the endpoint it failed to dial, so relaying it +// hands anyone who can reach Petal the address of the inference box on the far +// side of the VPN, along with which backend is running there. +// +// `what` names the pass for the operator's log ("checkpoint", "chat"). The +// browser is told only that the helper is unreachable, which is all the client +// ever did anything with: every LLM route's 502 renders as the same warm +// "小助手在休息 · Petal's helper is resting". +func UpstreamError(w http.ResponseWriter, what string, err error) { + log.Printf("upstream error (%s): %v", what, err) + ErrorJSON(w, http.StatusBadGateway, "Petal's helper is out of reach right now") +} diff --git a/internal/images/handler.go b/internal/images/handler.go index b9d184a..2e4af1e 100644 --- a/internal/images/handler.go +++ b/internal/images/handler.go @@ -37,6 +37,17 @@ import ( // small enough to keep a careless paste from filling the disk. const maxUploadBytes = 10 << 20 +// maxUserBytes caps what one account may keep stored, at 1 GiB. The per-upload +// limit bounds a single careless paste; nothing bounded ten thousand of them, +// and Petal's data directory is an 8 GiB encrypted volume shared with the +// database, the backups and the TTS cache — the disk filling is the database +// losing writes, not just images failing. +// +// A tenth of the volume per writer is far past any real use: a heavily +// illustrated journal is tens of megabytes. It is a runaway backstop, and it is +// deliberately generous enough that nobody writing normally will ever meet it. +const maxUserBytes = 1 << 30 + // extByContentType maps the image types we accept to a canonical extension. The // allowlist doubles as validation: anything not here is rejected. var extByContentType = map[string]string{ @@ -177,6 +188,19 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) { name := hex.EncodeToString(sum[:])[:32] + ext path := filepath.Join(h.dir, name) + userID := auth.UserID(r.Context()) + within, err := h.withinQuota(userID, name, int64(len(data))) + if err != nil { + log.Printf("images: quota check failed for %s: %v", userID, err) + http.Error(w, "could not store image", http.StatusInternalServerError) + return + } + if !within { + http.Error(w, "you've filled Petal's picture store — delete a few images and try again", + http.StatusInsufficientStorage) + return + } + // Skip the write if this exact content is already stored. if _, statErr := os.Stat(path); errors.Is(statErr, os.ErrNotExist) { if err := os.WriteFile(path, data, 0o644); err != nil { @@ -190,7 +214,7 @@ func (h *Handler) upload(w http.ResponseWriter, r *http.Request) { if _, err := h.db.Exec( `INSERT INTO images (name, user_id, content_type, size) VALUES (?, ?, ?, ?) ON CONFLICT (name, user_id) DO NOTHING`, - name, auth.UserID(r.Context()), ct, len(data), + name, userID, ct, len(data), ); err != nil { log.Printf("images: could not record ownership of %s: %v", name, err) http.Error(w, "could not store image", http.StatusInternalServerError) @@ -221,6 +245,20 @@ func (h *Handler) serve(w http.ResponseWriter, r *http.Request) { } // Private: a shared cache must never hand one writer's image to another. w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + + // SVG is a document format wearing an image's name: it can carry `) + rec := httptest.NewRecorder() + alice.ServeHTTP(rec, uploadReq(t, "image", svg)) + if rec.Code != http.StatusOK { + t.Fatalf("svg upload code=%d body=%s", rec.Code, rec.Body) + } + var resp struct{ URL string } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + name := strings.TrimPrefix(resp.URL, "/api/images/") + + got := get(t, alice, name) + if got.Code != http.StatusOK { + t.Fatalf("serve code=%d", got.Code) + } + csp := got.Header().Get("Content-Security-Policy") + if !strings.Contains(csp, "default-src 'none'") || !strings.Contains(csp, "sandbox") { + t.Fatalf("CSP %q does not neutralize the response", csp) + } + if got.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("stored images must be served nosniff") + } +} + +// A per-upload cap bounds one careless paste; nothing bounded ten thousand of +// them, on the same volume the database lives on. +func TestUploadQuota(t *testing.T) { + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + if _, err := database.Exec( + `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`, + "bob", "bob@petal.local", "Bob", + ); err != nil { + t.Fatalf("seed second user: %v", err) + } + h, err := New(t.TempDir(), database.DB, db.LocalUserID) + if err != nil { + t.Fatalf("new store: %v", err) + } + alice := auth.Middleware(auth.StaticResolver(db.LocalUserID))(h.Routes()) + bob := auth.Middleware(auth.StaticResolver("bob"))(h.Routes()) + + // Fill Alice's allowance by hand — uploading a gibibyte in a test would be + // absurd, and what's under test is the accounting, not the arithmetic. + name := upload(t, alice, pngBytes) + if _, err := database.Exec( + `UPDATE images SET size = ? WHERE user_id = ? AND name = ?`, + int64(maxUserBytes), db.LocalUserID, name, + ); err != nil { + t.Fatal(err) + } + + // Re-storing something she already has costs nothing, so it still works. + if again := upload(t, alice, pngBytes); again != name { + t.Fatalf("a re-upload of an owned image should dedupe, got %q", again) + } + + // Anything new does not. + rec := httptest.NewRecorder() + alice.ServeHTTP(rec, uploadReq(t, "image", otherPNG)) + if rec.Code != http.StatusInsufficientStorage { + t.Fatalf("over-quota upload code=%d, want 507", rec.Code) + } + + // And it is *her* allowance, not the store's: Bob is unaffected. + if got := upload(t, bob, otherPNG); got == "" { + t.Fatal("one writer's quota must not stop another writing") + } +} diff --git a/internal/lexicon/handlers.go b/internal/lexicon/handlers.go index 1ba5cd8..1943410 100644 --- a/internal/lexicon/handlers.go +++ b/internal/lexicon/handlers.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "log" "net/http" "net/url" @@ -95,10 +96,15 @@ func (h *Handler) gloss(w http.ResponseWriter, r *http.Request) { writeLookup(w, res) } +// writeLookupErr answers a failed lookup. The real error is a dictionary or +// database fault — a file path, a SQLite message — and belongs in the log, not +// in a tooltip. The client treats any non-200 the same way, so nothing is lost +// by saying less. func writeLookupErr(w http.ResponseWriter, err error) { + log.Printf("lexicon: lookup failed: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "lookup failed"}) } func writeLookup(w http.ResponseWriter, v any) { diff --git a/internal/suggestions/chat.go b/internal/suggestions/chat.go index 5218d74..8e29a34 100644 --- a/internal/suggestions/chat.go +++ b/internal/suggestions/chat.go @@ -75,7 +75,7 @@ func (h *Handler) chat(w http.ResponseWriter, r *http.Request) { if err != nil { // The stream never opened (e.g. LLM unreachable) — a normal JSON error is // still appropriate since we haven't written SSE headers yet. - httputil.ErrorJSON(w, http.StatusBadGateway, "chat failed: "+err.Error()) + httputil.UpstreamError(w, "chat", err) return } diff --git a/internal/suggestions/handlers.go b/internal/suggestions/handlers.go index c3edd62..bf1dfaa 100644 --- a/internal/suggestions/handlers.go +++ b/internal/suggestions/handlers.go @@ -283,7 +283,7 @@ func (h *Handler) runPass(w http.ResponseWriter, r *http.Request, limiter *llm.R // the per-document slot for the full interval — stranding the frontend's // auto-retry on the throttle path. Release it so a retry can re-run. limiter.Release(docID, slotAt) - httputil.ErrorJSON(w, http.StatusBadGateway, "llm pass failed: "+err.Error()) + httputil.UpstreamError(w, "pass", err) return } diff --git a/internal/suggestions/rewrite.go b/internal/suggestions/rewrite.go index 6104214..ba9ee77 100644 --- a/internal/suggestions/rewrite.go +++ b/internal/suggestions/rewrite.go @@ -69,7 +69,7 @@ func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request) { out, err := llm.RunRewrite(r.Context(), h.Client, text, body.Style) if err != nil { - httputil.ErrorJSON(w, http.StatusBadGateway, "rewrite failed: "+err.Error()) + httputil.UpstreamError(w, "rewrite", err) return } diff --git a/internal/suggestions/translate.go b/internal/suggestions/translate.go index 21d56c5..6b35779 100644 --- a/internal/suggestions/translate.go +++ b/internal/suggestions/translate.go @@ -51,7 +51,7 @@ func (h *Handler) translate(w http.ResponseWriter, r *http.Request) { out, err := llm.RunTranslate(r.Context(), h.Client, explanation, llm.LangFor(pairLang)) if err != nil { - httputil.ErrorJSON(w, http.StatusBadGateway, "translate failed: "+err.Error()) + httputil.UpstreamError(w, "translate", err) return } diff --git a/internal/tts/handler.go b/internal/tts/handler.go index 26f7060..2d5099e 100644 --- a/internal/tts/handler.go +++ b/internal/tts/handler.go @@ -21,6 +21,7 @@ import ( "path/filepath" "sort" "strings" + "sync/atomic" "time" "unicode/utf8" @@ -97,6 +98,7 @@ type Handler struct { 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 @@ -233,6 +235,7 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) { if err := os.WriteFile(tmp, audio, 0o644); err == nil { _ = os.Rename(tmp, path) } + h.pruneCache() } w.Header().Set("Content-Type", h.format.contentType) @@ -240,6 +243,80 @@ func (h *Handler) synth(w http.ResponseWriter, r *http.Request) { _, _ = 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) { diff --git a/web/package-lock.json b/web/package-lock.json index 65cd522..53be6fa 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2928,9 +2928,9 @@ } }, "node_modules/linkify-it": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", - "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", "funding": [ { "type": "github", @@ -3019,9 +3019,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -3104,9 +3104,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -3124,7 +3124,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx index 82ffb3b..9e54fb2 100644 --- a/web/src/components/DocList/DocList.tsx +++ b/web/src/components/DocList/DocList.tsx @@ -5,6 +5,7 @@ import { SearchBox } from './SearchBox' import { TagChip } from './TagChip' import { LanguagePicker } from './LanguagePicker' import { usePack, type Pack } from '../../i18n' +import { forgetAllDrafts } from '../../lib/drafts' interface Props { docs: DocSummary[] @@ -172,13 +173,29 @@ export function DocList({ 🌸 {account.name} - forgetAllDrafts()} > - {t.docs.signOut} - + + )} diff --git a/web/src/lib/drafts.test.ts b/web/src/lib/drafts.test.ts index edc9a60..6832d5d 100644 --- a/web/src/lib/drafts.test.ts +++ b/web/src/lib/drafts.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { clearDraft, peekDraft, stashDraft, takeDraft } from './drafts' +import { clearDraft, forgetAllDrafts, peekDraft, stashDraft, takeDraft } from './drafts' +import { resetPrefsScopeForTests, setPrefsScope } from './prefs' // The draft stash is the last thing between an expired session and lost // writing, so these tests care about two things above all: that a rescued body @@ -22,11 +23,13 @@ function fakeStorage(): Storage { beforeEach(() => { vi.stubGlobal('localStorage', fakeStorage()) + resetPrefsScopeForTests() }) afterEach(() => { vi.unstubAllGlobals() vi.useRealTimers() + resetPrefsScopeForTests() }) describe('draft rescue', () => { @@ -76,6 +79,51 @@ describe('draft rescue', () => { expect(peekDraft('doc-1')).toBeNull() }) + // A rescue is unsaved writing, so it belongs to the writer, not to the + // browser profile two people may be sharing. + describe('per account', () => { + it('keeps two writers apart on one browser', () => { + setPrefsScope('claire') + stashDraft('doc-1', { content_text: 'hers' }) + + resetPrefsScopeForTests() + setPrefsScope('wei') + expect(peekDraft('doc-1')).toBeNull() + + stashDraft('doc-1', { content_text: 'his' }) + expect(peekDraft('doc-1')?.body.content_text).toBe('his') + + resetPrefsScopeForTests() + setPrefsScope('claire') + expect(peekDraft('doc-1')?.body.content_text).toBe('hers') + }) + + // A draft stashed before this browser knew who was writing still has to + // reach her — an in-flight rescue must survive the upgrade that introduced + // namespacing. + it('adopts a pre-account draft for the first writer to sign in', () => { + stashDraft('doc-1', { content_text: 'stashed before login' }) + setPrefsScope('claire') + expect(peekDraft('doc-1')?.body.content_text).toBe('stashed before login') + expect(localStorage.getItem('petal.draft.doc-1')).toBeNull() + }) + + it('forgets only the signed-in writer’s drafts on sign-out', () => { + setPrefsScope('claire') + stashDraft('doc-1', { content_text: 'hers' }) + resetPrefsScopeForTests() + setPrefsScope('wei') + stashDraft('doc-2', { content_text: 'his' }) + + forgetAllDrafts() + expect(peekDraft('doc-2')).toBeNull() + + resetPrefsScopeForTests() + setPrefsScope('claire') + expect(peekDraft('doc-1')?.body.content_text).toBe('hers') + }) + }) + // Storage can be full, disabled, or absent. Losing the safety net is bad; // throwing from inside a failed save is worse. it('survives storage that refuses to write', () => { diff --git a/web/src/lib/drafts.ts b/web/src/lib/drafts.ts index 1771806..4eda9ce 100644 --- a/web/src/lib/drafts.ts +++ b/web/src/lib/drafts.ts @@ -13,6 +13,7 @@ // server, and the entry is cleared the moment a normal save succeeds. import type { DocUpdate } from '../api/client' +import { forgetScopedKeys, scopedKey } from './prefs' const PREFIX = 'petal.draft.' @@ -27,8 +28,21 @@ export interface StashedDraft { // week later is more likely to be a surprise than a save. const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 +// Namespaced by account, like every other thing this browser holds on a +// writer's behalf (see prefs). A draft is the heaviest of them: not a +// preference but unsaved writing, sitting in a profile two people may share. +// Keying it on the document id alone meant one person's rescue could surface in +// the other's editor the moment they opened the same document id — and, more +// plainly, that her sentences stayed in the browser under a name anyone looking +// could read. function key(docId: string): string { - return PREFIX + docId + return scopedKey(PREFIX + docId) +} + +// forgetAllDrafts drops every draft this browser is holding for the signed-in +// writer. Called on the way out — see the sign-out control in DocList. +export function forgetAllDrafts(): void { + forgetScopedKeys(PREFIX) } // stashDraft records the unsaved body for a document, replacing any earlier one diff --git a/web/src/lib/prefs.ts b/web/src/lib/prefs.ts index 4eb1035..6df1b58 100644 --- a/web/src/lib/prefs.ts +++ b/web/src/lib/prefs.ts @@ -27,6 +27,20 @@ const listeners = new Set() // moment the scope becomes known. const SCOPED_KEYS = ['petal.sound', 'petal.petals', 'petal.companion'] as const +// Families of keys whose names aren't known ahead of time — the draft rescue is +// one per document id — but which follow the account for the same reason. Held +// here so adoption can sweep them, and so there is one list of "what belongs to +// a writer in this browser" rather than two. +export const SCOPED_PREFIXES = ['petal.draft.'] as const + +// isLegacyKey spots a pre-namespacing key under one of those prefixes. A scoped +// key always carries the `.u.` suffix that scopedKey adds, and a document id +// never contains it, so its absence is what marks the key as belonging to the +// era before accounts. +function isLegacyKey(key: string): boolean { + return SCOPED_PREFIXES.some((p) => key.startsWith(p)) && !key.includes('.u.') +} + // scopedKey is the storage key actually used for `base` right now. Before the // caller is known it is the legacy key, so a reload keeps working offline and // pre-login reads see the browser's existing preference. @@ -80,11 +94,53 @@ function adoptLegacy(): void { } localStorage.removeItem(base) } + + // The same move for the prefixed families. Snapshot the key list first: + // removing while iterating localStorage by index skips entries. + const legacyKeys: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (k && isLegacyKey(k)) legacyKeys.push(k) + } + for (const k of legacyKeys) { + const value = localStorage.getItem(k) + if (value === null) continue + if (localStorage.getItem(scopedKey(k)) === null) { + localStorage.setItem(scopedKey(k), value) + } + localStorage.removeItem(k) + } } catch { /* storage unavailable — nothing to adopt, and nothing breaks */ } } +// forgetScopedKeys removes everything this browser is holding for the current +// account under the given prefix — and anything still sitting un-namespaced, +// which on a browser that has only ever had one writer is the same content. +// +// Sign-out is the moment this matters. Everything else here is a preference; +// the draft stash is unsaved *writing*, and leaving it in localStorage after +// someone has deliberately signed out of a shared machine is the one case where +// the rescue net becomes the leak. +export function forgetScopedKeys(prefix: string): void { + try { + const doomed: string[] = [] + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i) + if (!k || !k.startsWith(prefix)) continue + // Un-namespaced keys predate accounts, so on this browser they are ours; + // namespaced ones are ours only if they carry our id. Another writer's + // rescued draft on a shared laptop is not ours to throw away. + const ours = !k.includes('.u.') || (userID !== null && k.endsWith(`.u.${userID}`)) + if (ours) doomed.push(k) + } + doomed.forEach((k) => localStorage.removeItem(k)) + } catch { + /* storage unavailable — there is nothing held to forget */ + } +} + // resetPrefsScopeForTests unbinds the account again. Exported for tests only; // the app sets the scope once and never clears it (signing out leaves the // editor mounted, and the same person usually signs back in).