sql.Open is lazy, so a permission problem surfaced from whichever query ran first: "create schema_migrations: unable to open database file (14)", which names neither the file nor the reason. Ping on open and report the path and the effective uid and gid instead. The cause in practice is a bind-mounted ./data that Docker created as root while the container runs as FOODSTER_UID. Documented in the README.
241 lines
6.9 KiB
Go
241 lines
6.9 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"
|
|
"mime"
|
|
"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"
|
|
)
|
|
|
|
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)
|
|
|
|
// sql.Open is lazy, so without this the first failure surfaces from
|
|
// whatever query ran first and says nothing useful. The usual cause is a
|
|
// bind-mounted directory owned by a different user than the container
|
|
// runs as, so name the path and the uid.
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, fmt.Errorf(
|
|
"cannot open %s as uid %d gid %d: %w (is that directory writable by this user?)",
|
|
path, os.Getuid(), os.Getgid(), err)
|
|
}
|
|
|
|
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 {
|
|
// Go's mime table has no entry for .webmanifest, and a manifest served as
|
|
// octet-stream is ignored by the browser.
|
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
|
|
|
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))
|
|
guesses := newThrottle()
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, given, ok := r.BasicAuth()
|
|
|
|
// A request with no Authorization header is the normal browser
|
|
// handshake, not a guess: every session opens with one. Challenge it
|
|
// without spending the address's allowance.
|
|
if !ok {
|
|
challenge(w)
|
|
return
|
|
}
|
|
|
|
// Hashing first keeps the comparison a fixed length, so neither the
|
|
// password nor its length leaks through timing.
|
|
got := sha256.Sum256([]byte(given))
|
|
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
|
if !guesses.allow(clientIP(r)) {
|
|
http.Error(w, "Liikaa yrityksiä.", http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
challenge(w)
|
|
return
|
|
}
|
|
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func challenge(w http.ResponseWriter) {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="Foodster", charset="UTF-8"`)
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
}
|
|
|
|
// 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)
|
|
}
|