Files
foodster/cmd/foodster/main.go
T
Esa Kataja d15f741ab1 Add dish CRUD to the UI, including inline from Kirjaa
The catalog could only be filled by importing JSON, which is a poor way to
add the one dish you are about to eat.

Ruoat now covers PRD §7.3 in full: add and edit mains with their categories
and has_sides, add and edit sides, and delete either. Deletes are soft, so a
log entry keeps resolving the dish it used and the freed name can be reused.
Validation messages are Finnish and the rejected form comes back filled in
rather than blank. Bulk import moves into a details element, since it is now
the occasional path rather than the only one.

Kirjaa gets the same ability without the detour: a search that finds nothing
offers to add what was typed, and saving creates the dish and continues
straight to the sides step. An empty catalog shows the same card instead of
dead-ending on a link to another tab, and the search box is no longer hidden
behind the empty state.

The importer's own insert is gone; it and the UI both go through createMain
and createSide, so duplicate detection lives in one place and reason() can
match on errNameTaken instead of poking at driver strings.
2026-09-05 18:44:09 +03:00

211 lines
6.1 KiB
Go

// Command foodster is a household dinner log and meal suggester.
//
// Stage 1 (the eating history) is what exists today; the suggester in PRD §8
// arrives once there is history to weight against.
package main
import (
"cmp"
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"embed"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"unicode"
_ "modernc.org/sqlite"
_ "time/tzdata" // the runtime image is FROM scratch and carries no zoneinfo
)
//go:embed static
var staticFS embed.FS
// version is replaced at build time with the CalVer tag (see `make image`).
var version = "dev"
const (
listenAddr = ":8080"
defaultTZ = "Europe/Helsinki"
// Everything SQLite writes — the database plus its -wal and -shm
// companions — lives in one directory, so a deployment mounts a single
// path and a backup copies a single directory.
defaultDB = "./data/foodster.db"
// failDelay throttles password guessing.
// ponytail: a fixed sleep is enough for a LAN-only app; swap in
// golang.org/x/time/rate keyed by IP if this is ever exposed.
failDelay = 500 * time.Millisecond
)
func main() {
if err := run(); err != nil {
log.Fatalf("foodster: %v", err)
}
}
func run() error {
importPath := flag.String("import", "",
"import a JSON dish bundle (PRD §7.3 shape) and exit")
flag.Parse()
db, err := openDB(cmp.Or(os.Getenv("FOODSTER_DB"), defaultDB))
if err != nil {
return err
}
defer db.Close()
// Importing is an offline chore: no password needed, no server started.
if *importPath != "" {
return runImport(db, *importPath)
}
password := os.Getenv("FOODSTER_PASSWORD")
if password == "" {
return errors.New("FOODSTER_PASSWORD is not set")
}
// Fail rather than fall back to UTC: a silently wrong zone shifts logged
// dinners onto the wrong calendar day, which is invisible until the
// history is already corrupt.
loc, err := time.LoadLocation(cmp.Or(os.Getenv("TZ"), defaultTZ))
if err != nil {
return fmt.Errorf("TZ: %w", err)
}
// The container always publishes :8080; FOODSTER_ADDR exists so tests and
// a second local instance can pick another port.
addr := cmp.Or(os.Getenv("FOODSTER_ADDR"), listenAddr)
srv := &http.Server{
Addr: addr,
Handler: routes(db, loc, password),
ReadHeaderTimeout: 10 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdown)
}()
log.Printf("foodster %s listening on %s (%s)", version, addr, loc)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// openDB opens the SQLite file and brings its schema up to date.
func openDB(path string) (*sql.DB, error) {
if dir := filepath.Dir(path); dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
}
dsn := "file:" + path +
"?_pragma=journal_mode(WAL)" +
"&_pragma=foreign_keys(ON)" +
"&_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
// ponytail: one household writing one row a day; a single connection
// sidesteps SQLITE_BUSY entirely. Raise it if reads ever contend.
db.SetMaxOpenConns(1)
if err := migrate(db); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func routes(db *sql.DB, loc *time.Location, password string) http.Handler {
a := &app{db: db, loc: loc}
mux := http.NewServeMux()
mux.Handle("GET /static/", http.FileServerFS(staticFS))
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("POST /kirjaa", a.save)
mux.HandleFunc("POST /lisaa", a.quickAdd)
mux.HandleFunc("POST /poista", a.delete)
mux.HandleFunc("GET /historia", a.history)
mux.HandleFunc("GET /ruoat", a.catalog)
mux.HandleFunc("POST /ruoat/paaruoka", a.saveMain)
mux.HandleFunc("POST /ruoat/lisuke", a.saveSide)
mux.HandleFunc("POST /ruoat/poista", a.deleteDish)
mux.HandleFunc("POST /ruoat/tuonti", a.importDishes)
// /healthz stays outside auth so a monitor or reverse proxy can reach it.
root := http.NewServeMux()
root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, version)
})
root.Handle("/", auth(password, mux))
return root
}
// auth gates everything behind one shared household password. There are no
// accounts, so the username is ignored (PRD §9).
func auth(password string, next http.Handler) http.Handler {
want := sha256.Sum256([]byte(password))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, given, ok := r.BasicAuth()
// Hashing first keeps the comparison a fixed length, so neither the
// password nor its length leaks through timing.
got := sha256.Sum256([]byte(given))
if !ok || subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
time.Sleep(failDelay)
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
// today is the current calendar day in the configured location. Every date in
// this app goes through here rather than time.Local, which would be UTC
// whenever TZ is unset and quietly shift evening entries to the day before.
func today(loc *time.Location) time.Time {
return time.Now().In(loc)
}
// normalizeName collapses whitespace and capitalises the first letter for
// storage (PRD §6): " keitetyt perunat " becomes "Keitetyt perunat".
//
// Sentence case, not title case: Finnish capitalises only the first word of a
// phrase, so "Keitetyt Perunat" would read as an anglicism.
//
// ponytail: everything after the first rune is left exactly as typed. Forcing
// the remainder lower would mangle "BBQ-kylkeä" and "Kotipizza", and the
// household can type what it means.
func normalizeName(s string) string {
name := strings.Join(strings.Fields(s), " ")
if name == "" {
return ""
}
r := []rune(name)
r[0] = unicode.ToUpper(r[0])
return string(r)
}