Deploy plumbing so Petal can run on the public VPS behind the Traefik already on that box, with vLLM reached over headscale. - Dockerfile: node build -> go build -> alpine runtime. CGO stays off (modernc SQLite is pure Go), so the runtime layer exists only for ffmpeg (read-aloud transcodes Piper's WAV) and tzdata (the companion's bedtime nag and night mode read the local clock). Runs as uid 10001 with /data as the single writable mount. - docker-compose.yml: Traefik labels following this host's convention (external `traefik` network, `web-secure` entrypoint, `default` cert resolver). Petal publishes no host port. ./data is a bind mount, not a named volume, so the nightly backup and a restore are reachable from the host. - Piper runs as two sibling containers rather than host systemd units. The plan assumed Piper was already installed on the VPS; it is not, the host has no lingering user session to keep user units alive, and containers keep the TTS ports on an internal network unreachable from anywhere but Petal. One image, voice chosen per service, model cached in a shared volume -- so the pt-PT voice is a new service, not a new image. - db.Backup + a `-backup` flag: VACUUM INTO, not a file copy. Petal runs in WAL mode, so the newest committed pages may live in petal.db-wal; copying the three files separately can capture a torn mid-checkpoint state. VACUUM INTO reads one coherent snapshot without taking a write lock, and emits a single file with no -wal/-shm companions. Refuses an existing destination so a failed run can't destroy the last good backup. - deploy/backup-petal.sh: nightly snapshot, compress, push to millenia over headscale with a post-transfer size check, prune both sides. - deploy/petal.env.example: LLM_TIMEOUT raised 30s -> 90s for the WAN+VPN round trip, since the voice and collocation passes send a whole document and the timeout is a hard deadline on Complete.
232 lines
8.5 KiB
Go
232 lines
8.5 KiB
Go
package main
|
|
|
|
import (
|
|
"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)
|
|
|
|
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()), replacing the db.LocalUserID constant those
|
|
// queries used to name directly. Petal is still single-user — StaticResolver
|
|
// returns that same local user for every request — but the identity now
|
|
// travels the same path a real one will. Swapping this line for an Authentik
|
|
// session resolver is the whole remaining change; no handler or query moves.
|
|
api.Group(func(pr chi.Router) {
|
|
pr.Use(auth.Middleware(auth.StaticResolver(db.LocalUserID)))
|
|
|
|
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.
|
|
imgHandler, err := images.New(cfg.ImageDir)
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
// 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)
|
|
}
|
|
}
|