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/.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) } }