Merge fix/security-review: refuse to start unauthenticated, and mean it
Petal now fails closed rather than falling back to the `local` user on a public host, serves stored images inert, and keeps its response headers where a route can tighten them instead of where the edge can overwrite them. Plus the smaller findings: allowlist warning, upstream errors kept out of responses, export link schemes, per-account draft rescue, POST-only logout, storage quotas, __Host- session cookie, npm audit. Deploying this needs one check first: if the live .env is missing any of AUTHENTIK_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET, the container will refuse to start — which is the guard working, but better found before the deploy than during it. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
+14
-1
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-1
@@ -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
|
||||
|
||||
+37
-2
@@ -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`)
|
||||
|
||||
+33
-16
@@ -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
|
||||
|
||||
+11
-4
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,9 +198,18 @@ 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) {
|
||||
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: SessionCookie,
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
@@ -169,3 +218,4 @@ func ClearSessionCookie(w http.ResponseWriter, secure bool) {
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+71
-4
@@ -287,7 +287,14 @@ func mdBlock(n pmNode, depth int) string {
|
||||
return "---"
|
||||
case "image":
|
||||
alt := n.attrStr("alt")
|
||||
return fmt.Sprintf("", 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("", alt, src)
|
||||
case "table":
|
||||
return mdTable(n)
|
||||
case "bulletList", "orderedList":
|
||||
@@ -396,7 +403,9 @@ func applyMdMarks(n pmNode) string {
|
||||
if n.hasMark("underline") {
|
||||
t = "<u>" + t + "</u>"
|
||||
}
|
||||
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 <a href>. 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 "<hr>\n"
|
||||
case "image":
|
||||
alt := htmlEscape(n.attrStr("alt"))
|
||||
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\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 "<p>" + alt + "</p>\n"
|
||||
}
|
||||
return fmt.Sprintf("<p><img src=\"%s\" alt=\"%s\"></p>\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 = "<mark>" + t + "</mark>"
|
||||
}
|
||||
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("<a href=\"%s\">%s</a>", 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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 <script>,
|
||||
// and this route serves it from Petal's own origin. Rendered through an
|
||||
// <img> — the only way the editor ever shows one — that script never runs.
|
||||
// Navigated to directly, which is one "open image in new tab" away, it does,
|
||||
// and it runs with the API of whoever opened it.
|
||||
//
|
||||
// So every stored image answers with a CSP that permits nothing at all
|
||||
// except the inline styles an illustration legitimately carries. It costs
|
||||
// pasted SVGs nothing (an <img> was already a script-free context) and
|
||||
// leaves the direct-navigation case inert. nosniff is set at the edge, but
|
||||
// repeated here so the guarantee doesn't depend on Traefik's config.
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
@@ -261,6 +299,29 @@ func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// withinQuota reports whether userID may store one more image of size bytes.
|
||||
//
|
||||
// An image the caller already owns is free: content addressing means re-pasting
|
||||
// the same picture stores nothing new, and charging for it would let a document
|
||||
// that merely repeats one illustration walk into the limit. Deduplication
|
||||
// across *accounts* is not credited the same way — two people each keep their
|
||||
// own claim on a shared file, because either of them deleting it must not
|
||||
// depend on what the other did.
|
||||
func (h *Handler) withinQuota(userID, name string, size int64) (bool, error) {
|
||||
var used, already sql.NullInt64
|
||||
if err := h.db.QueryRow(
|
||||
`SELECT (SELECT COALESCE(SUM(size), 0) FROM images WHERE user_id = ?),
|
||||
(SELECT size FROM images WHERE user_id = ? AND name = ?)`,
|
||||
userID, userID, name,
|
||||
).Scan(&used, &already); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if already.Valid {
|
||||
return true, nil // already stored for this account — costs nothing more
|
||||
}
|
||||
return used.Int64+size <= maxUserBytes, nil
|
||||
}
|
||||
|
||||
// owns reports whether userID has a claim on a stored image.
|
||||
func (h *Handler) owns(name, userID string) bool {
|
||||
var ok bool
|
||||
|
||||
@@ -240,3 +240,85 @@ func TestServeMissing(t *testing.T) {
|
||||
t.Fatalf("expected 404, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// An SVG is a document, not a picture: it can carry <script>, and this route
|
||||
// serves it from Petal's own origin. Rendered through an <img> that script
|
||||
// never runs, but "open image in new tab" is one click away, and there it
|
||||
// would — with the API of whoever opened it. Every stored image therefore
|
||||
// answers with a CSP that permits nothing.
|
||||
func TestStoredImagesAreServedInert(t *testing.T) {
|
||||
_, alice, _ := newStore(t)
|
||||
|
||||
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>fetch('/api/docs')</script></svg>`)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+10
-10
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
<span className="min-w-0 flex-1 truncate" title={account.name}>
|
||||
🌸 {account.name}
|
||||
</span>
|
||||
<a
|
||||
href="/auth/logout"
|
||||
className="shrink-0 font-bold hover:underline"
|
||||
{/* A form, not a link: /auth/logout is POST-only, because with
|
||||
SameSite=Lax a plain GET route would let any page on the internet
|
||||
sign her out of her own draft. The browser still does a normal
|
||||
navigation and follows the redirect home, so this behaves exactly
|
||||
as the link did.
|
||||
|
||||
onSubmit fires before the navigation and clears the drafts this
|
||||
browser is holding for her — signing out of a shared machine
|
||||
should not leave her unsaved sentences behind in it. */}
|
||||
<form
|
||||
method="post"
|
||||
action="/auth/logout"
|
||||
className="shrink-0"
|
||||
onSubmit={() => forgetAllDrafts()}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
className="font-bold hover:underline"
|
||||
style={{ color: 'var(--color-accent-hover)' }}
|
||||
>
|
||||
{t.docs.signOut}
|
||||
</a>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -27,6 +27,20 @@ const listeners = new Set<Listener>()
|
||||
// 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.<id>` 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).
|
||||
|
||||
Reference in New Issue
Block a user