It logged dictionary.Langs(), which is a compile-time constant of the languages DreamDict *supports*. The database deployed until today supported Spanish and contained none of it, so the line printed a confident "[en fr pt-PT es zh]" over a file where every Spanish lookup came back empty — the exact failure the line exists to catch, reported as success. Contents() counts rows per language instead. For a file somebody has to copy onto the box by hand, "what is in it" is the only question worth asking, and the answer is now en=136615 es=102971 fr=56096 pt-PT=136300 zh=120883. Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
298 lines
11 KiB
Go
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 (%s)", cfg.DictPath, lexSet.Contents())
|
|
} 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)
|
|
}
|
|
}
|