Files
fit/internal/web/web.go
scott 3168a4ef96
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
Rename the app from tracker to fit
The directory, the repo and the hostname are all fit; the module path,
the image, the namespace and the data file were still tracker. Nothing
is deployed yet, so this is free now and would not be later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 22:03:33 -07:00

202 lines
5.3 KiB
Go

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
}