Files
petal/cmd/server/main.go
T
prosolis 1cf207d73f Phase 16: Petal authenticates for itself
Petal is now an OIDC client in its own right rather than trusting a header
from the proxy. The Phase-0 Resolver seam was the only integration point:
main.go picks the session store when Authentik is configured and the static
local user otherwise, and no handler or query moved for either.

internal/auth gains three pieces. session.go issues an opaque cookie token
and stores only its SHA-256, so a database copy yields nothing usable; the
30-day expiry slides on every request, throttled to one write an hour, and
logout deletes the row rather than just the cookie. oidc.go runs the
authorization-code flow with state, nonce and PKCE, and discovers the
provider lazily and on retry — an Authentik outage should block new logins
without stopping Petal booting or invalidating live sessions. users.go
provisions accounts from the token's claims and gates them on an allowlist
that matches emails as well as subject ids, since a subject is an opaque
uuid that doesn't exist until someone has already logged in once.

Migration 0010 lands sessions, images and users.pair_lang together. The
images table closes the capability-URL hole the Phase-0 audit flagged: a
hash was previously enough to fetch anyone's picture. Rows are keyed
(name, user_id) so one file can have several owners and deduplication
survives; a stranger gets 404 rather than 403, the cache header drops to
private, and files already on disk are claimed at startup or every image
already pasted into a document would 404.

On the frontend a single 401 interceptor feeds a warm bilingual sign-in
overlay, drawn over a still-visible editor because nothing has been taken
away. Behind it is the part that matters: a save that comes back 401
stashes its body to localStorage before anything else and stops the
auto-save loop, and reopening that document after signing in merges the
draft back and saves it. An expired session must not cost writing.

Writing the round-trip test against a stub identity provider turned up a
real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer,
which runs after the redirect has written the response header, so the
clearing Set-Cookie was silently dropped and they lingered for their full
ten minutes.

Also swaps the emoji favicon for a drawn sakura, which renders as Petal's
own rose palette everywhere instead of whatever each platform's font
decides, and doubles as the app tile in Authentik.

Migration 0010 verified against a VACUUM INTO copy of the live millenia
database: counts intact, FTS still matching, the one existing image
claimed.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 07:21:32 -07:00

272 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/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())
// 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)
}
}