The workout, the protein, the calories, the water, and three lines of gratitude. One page per day, a card of seventy-five tiles that fill by how many of the five landed, and a missed day left as a gap rather than a reset. Single Go binary with no dependencies. The log is one JSON file on a mounted volume, written atomically and never silently replaced when it fails to parse, since it is the one thing here that cannot be recreated. Auth is Traefik basic auth at the ingress; the app has no login of its own, so every POST checks Sec-Fetch-Site to stop another origin posting with those credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
202 lines
5.4 KiB
Go
202 lines
5.4 KiB
Go
package web
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"html/template"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/scottyah/tracker/internal/config"
|
|
"github.com/scottyah/tracker/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
|
|
}
|