// Package web serves the TodoMVC pages and form actions. // // Every action is an HTML form POST answered with 303 See Other, so the // app works with JavaScript off. The page's JavaScript sends the same // POSTs with an X-Todo-Fragment header and gets the list's HTML back // instead of a redirect. package web import ( "bytes" "context" "crypto/rand" "crypto/sha256" "embed" "encoding/base64" "encoding/hex" "errors" "html/template" "io/fs" "log/slog" "mime" "net/http" "path" "strconv" "todo-go/internal/todo" ) // bumpMarker exists so `make bump` has a constant in this file to change, // forcing the next build to recompile it. It has no effect at run time. const bumpMarker = "steady" const ( cookieName = "todo_list" // Browsers cap cookie lifetimes at 400 days; the page view renews it. cookieMaxAge = 400 * 24 * 60 * 60 secretBytes = 32 // 256 bits of randomness per visitor fragmentHeader = "X-Todo-Fragment" maxFormBytes = 1 << 20 ) //go:embed templates/page.html var templateFS embed.FS //go:embed static var staticFS embed.FS var page = template.Must(template.ParseFS(templateFS, "templates/page.html")) // Config is what a Server needs to know about where it runs. type Config struct { // Origin is this app's own origin, e.g. "https://todo-go.example". // A POST whose Origin header is set to anything else is refused. // Empty means "whatever origin the request's Host names". Origin string // Secure marks the list cookie Secure (the app is served over https). Secure bool // Version is the build version, served at /api/version. Version string Log *slog.Logger } // Server is the app's http.Handler. type Server struct { cfg Config store *todo.Store mux *http.ServeMux static map[string]asset } type asset struct { body []byte ctype string etag string } // New builds the handler. func New(store *todo.Store, cfg Config) *Server { if cfg.Log == nil { cfg.Log = slog.Default() } s := &Server{cfg: cfg, store: store, mux: http.NewServeMux(), static: loadStatic()} s.mux.HandleFunc("GET /{$}", s.page("all")) s.mux.HandleFunc("GET /active", s.page("active")) s.mux.HandleFunc("GET /completed", s.page("completed")) s.mux.HandleFunc("GET /static/{name}", s.serveStatic) s.mux.HandleFunc("GET /healthz", s.healthz) s.mux.HandleFunc("GET /api/version", s.version) s.mux.HandleFunc("POST /todos", s.action(func(r *http.Request, k todo.ListKey) error { return s.store.Add(r.Context(), k, r.PostFormValue("title")) })) s.mux.HandleFunc("POST /todos/toggle-all", s.action(func(r *http.Request, k todo.ListKey) error { return s.store.ToggleAll(r.Context(), k) })) s.mux.HandleFunc("POST /todos/clear-completed", s.action(func(r *http.Request, k todo.ListKey) error { return s.store.ClearCompleted(r.Context(), k) })) s.mux.HandleFunc("POST /todos/{id}/toggle", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { return s.store.Toggle(ctx, k, id) })) s.mux.HandleFunc("POST /todos/{id}/edit", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { return s.store.Rename(ctx, k, id, r.PostFormValue("title")) })) s.mux.HandleFunc("POST /todos/{id}/destroy", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { return s.store.Delete(ctx, k, id) })) return s } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") s.mux.ServeHTTP(w, r) } // filterPath maps a filter name to its page. Anything unknown is "all". func filterPath(filter string) (string, string) { switch filter { case "active": return "active", "/active" case "completed": return "completed", "/completed" } return "all", "/" } // view is everything the page template shows. type view struct { Filter, Path string Items []todo.Todo // the items this filter shows Total int Active int Completed int EditID int64 Version string } func (s *Server) view(ctx context.Context, k todo.ListKey, filter string) (view, error) { all, err := s.store.List(ctx, k) if err != nil { return view{}, err } v := view{Version: s.cfg.Version, Total: len(all)} v.Filter, v.Path = filterPath(filter) for _, t := range all { if t.Completed { v.Completed++ } else { v.Active++ } if v.Filter == "all" || (v.Filter == "completed") == t.Completed { v.Items = append(v.Items, t) } } return v, nil } func (s *Server) page(filter string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { k, err := s.listKey(w, r, true) if err != nil { s.fail(w, r, err) return } v, err := s.view(r.Context(), k, filter) if err != nil { s.fail(w, r, err) return } if id, err := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64); err == nil { v.EditID = id } s.render(w, r, "page.html", v) } } // render executes a template into a buffer first, so a failure can still // become a clean 500 and Content-Length is exact. func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, v view) { var buf bytes.Buffer if err := page.ExecuteTemplate(&buf, name, v); err != nil { s.fail(w, r, err) return } h := w.Header() h.Set("Content-Type", "text/html; charset=utf-8") h.Set("Content-Length", strconv.Itoa(buf.Len())) h.Set("Cache-Control", "no-store") w.Write(buf.Bytes()) } // action wraps a list-wide form POST: origin check, cookie, form parsing, // then a 303 back to the filter (or the list's HTML for app.js). func (s *Server) action(do func(r *http.Request, k todo.ListKey) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.sameOrigin(r) { http.Error(w, "cross-origin request refused", http.StatusForbidden) return } r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) if err := r.ParseForm(); err != nil { status := http.StatusBadRequest if _, ok := errors.AsType[*http.MaxBytesError](err); ok { status = http.StatusRequestEntityTooLarge } http.Error(w, http.StatusText(status), status) return } k, err := s.listKey(w, r, false) if err != nil { s.fail(w, r, err) return } if err := do(r, k); err != nil { if errors.Is(err, todo.ErrNotFound) { http.NotFound(w, r) return } s.fail(w, r, err) return } filter, to := filterPath(r.PostFormValue("filter")) if r.Header.Get(fragmentHeader) == "" { http.Redirect(w, r, to, http.StatusSeeOther) return } v, err := s.view(r.Context(), k, filter) if err != nil { s.fail(w, r, err) return } s.render(w, r, "list", v) } } // itemAction is action for a route naming one todo. An id that doesn't // parse is as unknown as one that isn't in the caller's list. func (s *Server) itemAction(do func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error) http.HandlerFunc { return s.action(func(r *http.Request, k todo.ListKey) error { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { return todo.ErrNotFound } return do(r.Context(), k, id, r) }) } // sameOrigin allows a POST with no Origin header (the contract says so; // SameSite=Lax already keeps cross-site POSTs from carrying the cookie) // or one naming this app's own origin. func (s *Server) sameOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return true } if s.cfg.Origin != "" { return origin == s.cfg.Origin } scheme := "http" if r.TLS != nil { scheme = "https" } return origin == scheme+"://"+r.Host } // listKey reads the visitor's list cookie. A missing cookie, or one the // server never issued, gets a fresh random one, which is a fresh empty // list. When renew is set, a valid cookie is sent again to push its // expiry forward. func (s *Server) listKey(w http.ResponseWriter, r *http.Request, renew bool) (todo.ListKey, error) { ctx := r.Context() if c, err := r.Cookie(cookieName); err == nil { secret, err := base64.RawURLEncoding.DecodeString(c.Value) if err == nil && len(secret) == secretBytes { k := todo.KeyFor(secret) ok, err := s.store.ListExists(ctx, k) if err != nil { return k, err } if ok { if renew { s.setCookie(w, c.Value) } return k, nil } } } secret := make([]byte, secretBytes) rand.Read(secret) // never fails (crypto/rand panics rather than return an error) k := todo.KeyFor(secret) if err := s.store.CreateList(ctx, k); err != nil { return k, err } s.setCookie(w, base64.RawURLEncoding.EncodeToString(secret)) return k, nil } func (s *Server) setCookie(w http.ResponseWriter, value string) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: value, Path: "/", MaxAge: cookieMaxAge, HttpOnly: true, Secure: s.cfg.Secure, SameSite: http.SameSiteLaxMode, }) } func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) { if errors.Is(err, context.Canceled) { return // the client went away } s.cfg.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) http.Error(w, "Something went wrong. Please try again.", http.StatusInternalServerError) } func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { if err := s.store.Ping(r.Context()); err != nil { s.fail(w, r, err) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write([]byte("ok\n")) } func (s *Server) version(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write([]byte(s.cfg.Version)) } // loadStatic reads the embedded assets once, with an ETag for each. func loadStatic() map[string]asset { out := map[string]asset{} sub, _ := fs.Sub(staticFS, "static") fs.WalkDir(sub, ".", func(name string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } body, err := fs.ReadFile(sub, name) if err != nil { return err } sum := sha256.Sum256(body) out[name] = asset{ body: body, ctype: mime.TypeByExtension(path.Ext(name)), etag: `"` + hex.EncodeToString(sum[:8]) + `"`, } return nil }) return out } func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { a, ok := s.static[r.PathValue("name")] if !ok { http.NotFound(w, r) return } h := w.Header() h.Set("Content-Type", a.ctype) h.Set("ETag", a.etag) // Pages link assets with ?v=, so a new build fetches afresh. h.Set("Cache-Control", "public, max-age=86400") if r.Header.Get("If-None-Match") == a.etag { w.WriteHeader(http.StatusNotModified) return } h.Set("Content-Length", strconv.Itoa(len(a.body))) w.Write(a.body) }