Files
petal/cmd/server/main.go
T
prosolis 97e9c269ec Phase 20: the dictionary stops being English and Chinese only
Word lookups now come from DreamDict's dict.db for every pair but Chinese —
opened read-only beside petal.db, no service, nothing over the VPN, because a
hover gloss has to answer in milliseconds.

`Provider` is the two questions the popover and the tooltip already asked, so
the embedded *Lexicon satisfies it with no changes at all; Set.For(lang) is the
single place the choice between them is made. The prerequisite in the dreamdict
repo turned out to be two things, not one: the module path was unfetchable
*and* the query layer sat in internal/, which no other module may import
whatever the module is called. Both fixed upstream.

The plan's central assumption did not survive the data. It mapped
Gloss ← Translate(word, "en", L1) one-to-one; against the real 452 MB database
that table answers for 17% of the 2,000 commonest English words into pt-PT.
Wiktionary's translation sections are thin in that direction — "ephemeral",
"think" and "quickly" have no en→pt-PT row at all. Shared WordNet synsets
answer for 61%, so DreamDict gained Equivalents() and Petal glosses through it.
Ordering those was wrong in an instructive way too: sorting by frequency
glosses "think" as lembrar, "remember", because lembrar is the commoner
Portuguese word even though pensar shares six of think's synsets to lembrar's
one. Counting sense agreement first asks the right question.

The same measurement is why zh stays on ECDICT: DreamDict reaches a Chinese
gloss for 53% of those words, ECDICT for nearly all of them. The plan said
converge only if quality holds. It didn't, so nothing converged.

Two decisions about failure worth keeping. A missing dict.db is not an error —
a laptop checkout has never had one — but a present-and-never-imported one is,
because that is a half-finished deploy. And a pt-PT writer with no dictionary
falls back to the embedded datasets with the gloss suppressed, keeping
definitions, synonyms and phonetics rather than blanking the popover: an empty
field reads as "not found", the wrong language reads as broken.

The new fields surface as an etymology line and a three-band chip. Three, not
five: the difficulty score separates "everyday" from "you'll have to explain
this" but cannot rank obfuscate against serendipity, and a finer scale would be
a confident-looking lie. An unscored word gets no chip.

Writing the tests found two bugs first — trimEtymology sliced by byte, which
would have emitted invalid UTF-8 for exactly the Greek and Latin etymologies
the feature exists for, and its ellipsis path overran its own cap.

go build/vet/test, tsc, vite, vitest 96/96 clean; live smoke against the real
dict.db with one instance flipped from zh to pt-PT mid-run.

Not deployed: go.mod still replaces github.com/prosolis/dreamdict with
../dreamdict, so the Docker build needs the two upstream commits pushed and the
replace dropped. The deployed dict.db also predates DreamDict's Spanish data.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 09:38:50 -07:00

298 lines
11 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)
}
// The dictionary behind word lookups. dict.db is DreamDict's built database
// — French, European Portuguese, Spanish and Mandarin in one read-only file
// beside petal.db. It is optional on purpose: a laptop checkout has never
// had one, and the Chinese pair doesn't need one, so its absence downgrades
// lookups rather than stopping Petal. A file that is present but broken is
// a different matter and gets said out loud.
dict, err := lexicon.OpenDreamDict(cfg.DictPath)
if err != nil {
log.Printf("dictionary: %s unusable (%v) — falling back to the embedded datasets", cfg.DictPath, err)
}
defer dict.Close()
lexSet := lexicon.NewSet(dict)
if lexSet.HasDreamDict() {
log.Printf("dictionary: DreamDict open at %s (%v)", cfg.DictPath, dict.Langs())
} else {
log.Printf("dictionary: no dict.db at %s — English/Chinese only", cfg.DictPath)
}
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 gloss-only lookup for the
// inline hover/select tooltip. One handler over one provider Set, so the
// embedded datasets and dict.db are each opened once. Which of them
// answers depends on the caller's language pair — so unlike before, the
// response is no longer identical for everyone, and it stays behind auth
// for that reason as much as for the API surface.
lex := lexicon.NewHandler(database.DB, lexSet)
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)
}
}