Files
petal/cmd/server/main.go
T
prosolis 30d5e691c9 Phase 18: settings that belong to the writer, not the browser
The mute toggle, the falling-petals toggle and the chosen companion lived in
localStorage, which is a property of the machine. Now that two people can sign
in to one Petal, sharing a laptop would have meant sharing a mascot and one
person's silence muting the other. Each key is namespaced by user id.

The awkward part is timing: sounds.ts and petals.ts read their value the moment
they are imported, long before /api/me can have answered. Rather than block
startup on the network for a mute flag, a read before the answer arrives sees
the old un-namespaced key -- on a single-writer browser, exactly the right
value -- and setPrefsScope then adopts it into that account's namespace and
tells every reader to look again. Adoption moves rather than copies, so the
first account inherits what was set before accounts existed and the second
starts from Petal's defaults.

The personal spelling dictionary moves further than that: onto the server. It
is built from her own writing, so it should not be readable by whoever sits
down at the same browser next -- but merely namespacing it would have split the
list she already has between her laptop and her tablet, which is worse than
where we started. A table keyed (user_id, lang, word) follows her instead. The
lang is the dictionary's, not hers: an English exception must not silence a
pt-PT flag once the second pair ships.

Adding a word takes effect in the editor immediately and persists in the
background, so the underline goes away the instant she asks. A browser still
holding the old list hands it over on first load, and only lets go once the
server has taken it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 08:06:08 -07:00

278 lines
10 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/config"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/docs"
"gitea.parodia.dev/drwily/petal/internal/images"
"gitea.parodia.dev/drwily/petal/internal/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm"
"gitea.parodia.dev/drwily/petal/internal/spell"
"gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/internal/tts"
"gitea.parodia.dev/drwily/petal/internal/vocab"
"gitea.parodia.dev/drwily/petal/web"
)
func main() {
backupTo := flag.String("backup", "",
"write a consistent copy of the database to this path and exit (no server)")
flag.Parse()
cfg := config.Load()
// Backup mode short-circuits before anything else starts: no migrations, no
// seed, no listener. It runs against the live database safely (VACUUM INTO
// takes only a read transaction), so the nightly job is
// docker compose exec petal /app/petal -backup /data/backups/<name>.db
// against the running container rather than a copy of three WAL files.
if *backupTo != "" {
if err := db.Backup(cfg.DatabasePath, *backupTo); err != nil {
log.Fatalf("backup: %v", err)
}
log.Printf("backup written to %s", *backupTo)
return
}
database, err := db.Open(cfg.DatabasePath)
if err != nil {
log.Fatalf("database: %v", err)
}
defer database.Close()
log.Printf("database ready at %s", cfg.DatabasePath)
// Identity. With Authentik configured, Petal is an OIDC client in its own
// right: /auth/login starts a real login and the session cookie it issues is
// what every API request is resolved from. Without it — local development,
// and every deployment before auth landed — StaticResolver hands out the
// single hardcoded local user, so nothing about running Petal on a laptop
// changes.
sessions := auth.NewSessionStore(database.DB)
users := auth.NewUserStore(database.DB)
var resolver auth.Resolver = auth.StaticResolver(db.LocalUserID)
var oidcClient *auth.OIDC
if cfg.AuthEnabled() {
oidcClient = auth.NewOIDC(context.Background(), auth.Options{
IssuerURL: cfg.AuthentikURL,
ClientID: cfg.AuthentikClientID,
ClientSecret: cfg.AuthentikClientSecret,
BaseURL: cfg.BaseURL,
Allowed: auth.ParseAllowlist(cfg.AllowedSubs),
}, 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())
} else {
log.Printf("auth: OIDC not configured — running as the single %q user", db.LocalUserID)
}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// 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
// exactly when a new frontend is deployed — the client polls it to know when
// to offer a refresh.
version := buildVersion()
log.Printf("frontend build version %s", version)
r.Route("/api", func(api chi.Router) {
// Cap request bodies so a runaway or hostile client can't stream an
// unbounded payload into a JSON decoder. Image uploads carry their own
// (larger) limit inside the images handler, so they're exempt here.
api.Use(limitBody(maxAPIBodyBytes, "/api/images"))
api.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
api.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Never cache: a stale cached version would defeat the whole check.
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(`{"version":"` + version + `"}`))
})
// Everything below serves or mutates a particular user's data, so it sits
// behind the auth middleware. /health and /version deliberately stay
// outside it: they carry no user data, and a monitoring probe (or the
// client's update poll) must not need a session to reach them.
//
// The middleware resolves the caller once and hands handlers the answer via
// auth.UserID(r.Context()). Which resolver it runs is the only thing that
// changed when auth landed: the session store in a deployment with
// Authentik configured, the static local user otherwise. No handler or
// query moved for either.
api.Group(func(pr chi.Router) {
pr.Use(auth.Middleware(resolver))
// Who am I? The frontend namespaces its per-account browser state by
// this id and shows the signed-in writer.
pr.Get("/me", users.MeHandler())
llmClient := llm.NewLLMClient(cfg)
sug := suggestions.New(database, llmClient)
// Document CRUD plus the doc-scoped checkpoint/list suggestion routes,
// both under /api/docs.
docsHandler := docs.New(database)
docsRouter := docsHandler.Routes()
sug.RegisterDocRoutes(docsRouter)
pr.Mount("/docs", docsRouter)
// Tag management (the roster) and cross-document full-text search.
pr.Mount("/tags", docsHandler.TagRoutes())
pr.Mount("/search", docsHandler.SearchRoutes())
// Per-suggestion actions (accept/dismiss) under /api/suggestions.
pr.Mount("/suggestions", sug.Routes())
// Offline lexicon: full word lookups (gloss + definition + synonyms) for
// the right-click popover, and the lightweight Chinese-only gloss for the
// inline hover/select tooltip. One handler so the datasets load once.
// The dataset is static and identical for everyone, but it stays behind
// auth so the API surface has no unauthenticated read holes.
lex := lexicon.NewHandler()
pr.Mount("/word", lex.Routes())
pr.Mount("/gloss", lex.GlossRoutes())
// Vocabulary garden: words the writer looks up are captured here and
// surfaced for gentle spaced-repetition review.
pr.Mount("/vocab", vocab.New(database).Routes())
// The personal spelling dictionary — the words she's told Petal to stop
// flagging. Kept server-side (rather than in the browser) so it belongs
// to her account and follows her between devices.
pr.Mount("/spell", spell.New(database).Routes())
// Editor image uploads, stored on disk and served back by content hash
// to whoever owns them. Files already on disk from before ownership
// existed are claimed for the local user at startup.
imgHandler, err := images.New(cfg.ImageDir, database.DB, db.LocalUserID)
if err != nil {
log.Fatalf("image store: %v", err)
}
pr.Mount("/images", imgHandler.Routes())
// Read-aloud: proxy short passages to a local Piper TTS server. Only
// mounted when TTS_ENDPOINT is configured; otherwise the frontend falls
// back to the browser's Web Speech API on its own.
if ttsHandler, ok := tts.New(cfg); ok {
pr.Mount("/tts", ttsHandler.Routes())
log.Printf("read-aloud enabled (TTS endpoint=%s)", cfg.TTSEndpoint)
}
})
})
// Login lives outside /api: these are browser navigations, and they must be
// reachable without a session — that is their entire job.
if oidcClient != nil {
r.Mount("/auth", oidcClient.Routes())
}
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
r.NotFound(spaHandler())
addr := ":" + cfg.Port
log.Printf("petal listening on %s (LLM backend=%s)", addr, cfg.LLMBackend)
if err := http.ListenAndServe(addr, r); err != nil {
log.Fatalf("server error: %v", err)
}
}
// 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
// handler's json.Decode fail, which surfaces as a 400.
const maxAPIBodyBytes = 2 << 20
// limitBody wraps each request body in an http.MaxBytesReader so handlers can't
// be made to read an unbounded payload. Paths under any of exemptPrefixes are
// left alone (e.g. image uploads, which set their own, larger limit).
func limitBody(max int64, exemptPrefixes ...string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, p := range exemptPrefixes {
if strings.HasPrefix(r.URL.Path, p) {
next.ServeHTTP(w, r)
return
}
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, max)
}
next.ServeHTTP(w, r)
})
}
}
// buildVersion derives a short, stable identifier for the currently embedded
// frontend by hashing dist/index.html. Vite stamps content-hashed asset names
// into that file each build, so the digest is a reliable "did the deploy
// change?" signal. Falls back to "dev" when the frontend hasn't been built.
func buildVersion() string {
data, err := fs.ReadFile(web.DistFS, "dist/index.html")
if err != nil {
return "dev"
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])[:12]
}
// spaHandler serves the embedded web/dist as a single-page app: static files
// when they exist, falling back to index.html for unknown paths. If the
// frontend hasn't been built yet, it returns a friendly dev hint instead.
func spaHandler() http.HandlerFunc {
sub, err := fs.Sub(web.DistFS, "dist")
if err != nil {
log.Fatalf("embed sub: %v", err)
}
if _, err := fs.Stat(sub, "index.html"); errors.Is(err, fs.ErrNotExist) {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Petal backend is running, but the frontend isn't built yet.\n" +
"Run `npm run build` in web/, or use `npm run dev` for the dev server on :5173.\n"))
}
}
fileServer := http.FileServer(http.FS(sub))
return func(w http.ResponseWriter, req *http.Request) {
p := strings.TrimPrefix(req.URL.Path, "/")
if p == "" {
p = "index.html"
}
if _, err := fs.Stat(sub, p); errors.Is(err, fs.ErrNotExist) {
// Unknown path → let the SPA router handle it.
req2 := new(http.Request)
*req2 = *req
req2.URL.Path = "/"
fileServer.ServeHTTP(w, req2)
return
}
fileServer.ServeHTTP(w, req)
}
}