package web import ( "crypto/sha256" "embed" "encoding/base64" "fmt" "html/template" "log/slog" "net/http" "time" "github.com/scottyah/fit/internal/config" "github.com/scottyah/fit/internal/store" ) //go:embed templates/*.html var templateFS embed.FS //go:embed static/* var staticFS embed.FS type Server struct { cfg *config.Config store *store.Store tpl *template.Template mux *http.ServeMux assetVer string // content hash, appended to static URLs now func() time.Time // swapped in tests; every date derives from it } func New(cfg *config.Config, st *store.Store) (*Server, error) { s := &Server{cfg: cfg, store: st, assetVer: assetVersion(), now: time.Now} tpl, err := template.New("").Funcs(s.templateFuncs()).ParseFS(templateFS, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } s.tpl = tpl s.routes() return s, nil } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost && !sameSite(r) { http.Error(w, "cross-site write refused", http.StatusForbidden) return } s.mux.ServeHTTP(w, r) } // sameSite rejects writes a browser reports as coming from somewhere else. // The only thing guarding this app is basic auth at the ingress, and browsers // attach those credentials to a cross-origin form post, so without this check // a page on any other domain could quietly overwrite the log. Requests with no // Sec-Fetch-Site header are not browsers and are left alone. func sameSite(r *http.Request) bool { switch r.Header.Get("Sec-Fetch-Site") { case "", "same-origin", "none": return true default: return false } } func (s *Server) routes() { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", s.handleHome) mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) mux.HandleFunc("POST /setup", s.handleSetup) mux.HandleFunc("GET /d/{date}", s.handleDay) mux.HandleFunc("POST /d/{date}", s.handleSaveDay) mux.HandleFunc("GET /settings", s.handleSettings) mux.HandleFunc("POST /settings", s.handleSaveSettings) mux.HandleFunc("GET /export.json", s.handleExport) mux.HandleFunc("POST /import", s.handleImport) mux.HandleFunc("GET /manifest.webmanifest", s.handleManifest) // Static assets are immutable in the image, so they cache hard. static := http.FileServer(http.FS(staticFS)) mux.Handle("GET /static/", cacheForever(static)) s.mux = mux } func cacheForever(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Safe to cache hard because every reference carries a content hash; // changing a file changes its URL. w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") h.ServeHTTP(w, r) }) } // assetVersion hashes the embedded static files so a stylesheet edit produces // a new URL. Without it, a deploy would leave the phone on a week-old CSS. func assetVersion() string { sum := sha256.New() entries, err := staticFS.ReadDir("static") if err != nil { return "dev" } for _, entry := range entries { body, err := staticFS.ReadFile("static/" + entry.Name()) if err != nil { continue } sum.Write([]byte(entry.Name())) sum.Write(body) } return base64.RawURLEncoding.EncodeToString(sum.Sum(nil))[:10] } // ---------------------------------------------------------------- view model // dayView is one day as the page needs it: the log itself plus everything // derived from the calendar and the targets. type dayView struct { Date string Time time.Time Number int // 1-based position in the challenge, 0 outside it Marks store.Marks Score int Logged bool IsToday bool IsFuture bool } // meter is one of the three numbers, with everything the band needs to draw // itself. Mode is "floor" for a target to reach and "ceiling" for a budget not // to exceed, which is the difference between protein and calories. type meter struct { Key string Label string Unit string Value *int Target int Mode string Pct int Met bool Over bool Note string } type stats struct { Complete int Logged int Elapsed int // days of the challenge that have started, capped at Length Remaining int Streak int Marks [5]int // how many days each of the five was hit } type pageData struct { Title string Settings store.Settings Day store.Day View dayView Meters []meter Card []dayView Stats stats PrevDate string NextDate string Today string Status string // "before", "during" or "after" the challenge window Flash string Error string } func (s *Server) render(w http.ResponseWriter, status int, name string, data pageData) { data.Today = s.today() if data.Settings.Length == 0 { data.Settings = s.store.Settings() } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) if err := s.tpl.ExecuteTemplate(w, name, data); err != nil { slog.Error("render template", "template", name, "err", err) } } // today is the current date in the configured timezone. Every date in the app // comes from here, so the day rolls over at local midnight rather than UTC's. func (s *Server) today() string { return s.now().In(s.cfg.TZ).Format(store.DateLayout) } // asset stamps a static path with the build's content hash. func (s *Server) asset(path string) string { return path + "?v=" + s.assetVer }