Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 2m44s
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>
202 lines
5.3 KiB
Go
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
|
|
}
|