| 1 | // Package web serves the TodoMVC pages and form actions. |
| 2 | // |
| 3 | // Every action is an HTML form POST answered with 303 See Other, so the |
| 4 | // app works with JavaScript off. The page's JavaScript sends the same |
| 5 | // POSTs with an X-Todo-Fragment header and gets the list's HTML back |
| 6 | // instead of a redirect. |
| 7 | package web |
| 8 | |
| 9 | import ( |
| 10 | "bytes" |
| 11 | "context" |
| 12 | "crypto/rand" |
| 13 | "crypto/sha256" |
| 14 | "embed" |
| 15 | "encoding/base64" |
| 16 | "encoding/hex" |
| 17 | "errors" |
| 18 | "html/template" |
| 19 | "io/fs" |
| 20 | "log/slog" |
| 21 | "mime" |
| 22 | "net/http" |
| 23 | "path" |
| 24 | "strconv" |
| 25 | |
| 26 | "todo-go/internal/todo" |
| 27 | ) |
| 28 | |
| 29 | // bumpMarker exists so `make bump` has a constant in this file to change, |
| 30 | // forcing the next build to recompile it. It has no effect at run time. |
| 31 | const bumpMarker = "steady" |
| 32 | |
| 33 | const ( |
| 34 | cookieName = "todo_list" |
| 35 | // Browsers cap cookie lifetimes at 400 days; the page view renews it. |
| 36 | cookieMaxAge = 400 * 24 * 60 * 60 |
| 37 | secretBytes = 32 // 256 bits of randomness per visitor |
| 38 | fragmentHeader = "X-Todo-Fragment" |
| 39 | maxFormBytes = 1 << 20 |
| 40 | ) |
| 41 | |
| 42 | //go:embed templates/page.html |
| 43 | var templateFS embed.FS |
| 44 | |
| 45 | //go:embed static |
| 46 | var staticFS embed.FS |
| 47 | |
| 48 | var page = template.Must(template.ParseFS(templateFS, "templates/page.html")) |
| 49 | |
| 50 | // Config is what a Server needs to know about where it runs. |
| 51 | type Config struct { |
| 52 | // Origin is this app's own origin, e.g. "https://todo-go.example". |
| 53 | // A POST whose Origin header is set to anything else is refused. |
| 54 | // Empty means "whatever origin the request's Host names". |
| 55 | Origin string |
| 56 | // Secure marks the list cookie Secure (the app is served over https). |
| 57 | Secure bool |
| 58 | // Version is the build version, served at /api/version. |
| 59 | Version string |
| 60 | Log *slog.Logger |
| 61 | } |
| 62 | |
| 63 | // Server is the app's http.Handler. |
| 64 | type Server struct { |
| 65 | cfg Config |
| 66 | store *todo.Store |
| 67 | mux *http.ServeMux |
| 68 | static map[string]asset |
| 69 | } |
| 70 | |
| 71 | type asset struct { |
| 72 | body []byte |
| 73 | ctype string |
| 74 | etag string |
| 75 | } |
| 76 | |
| 77 | // New builds the handler. |
| 78 | func New(store *todo.Store, cfg Config) *Server { |
| 79 | if cfg.Log == nil { |
| 80 | cfg.Log = slog.Default() |
| 81 | } |
| 82 | s := &Server{cfg: cfg, store: store, mux: http.NewServeMux(), static: loadStatic()} |
| 83 | |
| 84 | s.mux.HandleFunc("GET /{$}", s.page("all")) |
| 85 | s.mux.HandleFunc("GET /active", s.page("active")) |
| 86 | s.mux.HandleFunc("GET /completed", s.page("completed")) |
| 87 | s.mux.HandleFunc("GET /static/{name}", s.serveStatic) |
| 88 | s.mux.HandleFunc("GET /healthz", s.healthz) |
| 89 | s.mux.HandleFunc("GET /api/version", s.version) |
| 90 | |
| 91 | s.mux.HandleFunc("POST /todos", s.action(func(r *http.Request, k todo.ListKey) error { |
| 92 | return s.store.Add(r.Context(), k, r.PostFormValue("title")) |
| 93 | })) |
| 94 | s.mux.HandleFunc("POST /todos/toggle-all", s.action(func(r *http.Request, k todo.ListKey) error { |
| 95 | return s.store.ToggleAll(r.Context(), k) |
| 96 | })) |
| 97 | s.mux.HandleFunc("POST /todos/clear-completed", s.action(func(r *http.Request, k todo.ListKey) error { |
| 98 | return s.store.ClearCompleted(r.Context(), k) |
| 99 | })) |
| 100 | s.mux.HandleFunc("POST /todos/{id}/toggle", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| 101 | return s.store.Toggle(ctx, k, id) |
| 102 | })) |
| 103 | s.mux.HandleFunc("POST /todos/{id}/edit", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| 104 | return s.store.Rename(ctx, k, id, r.PostFormValue("title")) |
| 105 | })) |
| 106 | s.mux.HandleFunc("POST /todos/{id}/destroy", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| 107 | return s.store.Delete(ctx, k, id) |
| 108 | })) |
| 109 | return s |
| 110 | } |
| 111 | |
| 112 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| 113 | h := w.Header() |
| 114 | h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") |
| 115 | h.Set("X-Content-Type-Options", "nosniff") |
| 116 | h.Set("Referrer-Policy", "same-origin") |
| 117 | s.mux.ServeHTTP(w, r) |
| 118 | } |
| 119 | |
| 120 | // filterPath maps a filter name to its page. Anything unknown is "all". |
| 121 | func filterPath(filter string) (string, string) { |
| 122 | switch filter { |
| 123 | case "active": |
| 124 | return "active", "/active" |
| 125 | case "completed": |
| 126 | return "completed", "/completed" |
| 127 | } |
| 128 | return "all", "/" |
| 129 | } |
| 130 | |
| 131 | // view is everything the page template shows. |
| 132 | type view struct { |
| 133 | Filter, Path string |
| 134 | Items []todo.Todo // the items this filter shows |
| 135 | Total int |
| 136 | Active int |
| 137 | Completed int |
| 138 | EditID int64 |
| 139 | Version string |
| 140 | } |
| 141 | |
| 142 | func (s *Server) view(ctx context.Context, k todo.ListKey, filter string) (view, error) { |
| 143 | all, err := s.store.List(ctx, k) |
| 144 | if err != nil { |
| 145 | return view{}, err |
| 146 | } |
| 147 | v := view{Version: s.cfg.Version, Total: len(all)} |
| 148 | v.Filter, v.Path = filterPath(filter) |
| 149 | for _, t := range all { |
| 150 | if t.Completed { |
| 151 | v.Completed++ |
| 152 | } else { |
| 153 | v.Active++ |
| 154 | } |
| 155 | if v.Filter == "all" || (v.Filter == "completed") == t.Completed { |
| 156 | v.Items = append(v.Items, t) |
| 157 | } |
| 158 | } |
| 159 | return v, nil |
| 160 | } |
| 161 | |
| 162 | func (s *Server) page(filter string) http.HandlerFunc { |
| 163 | return func(w http.ResponseWriter, r *http.Request) { |
| 164 | k, err := s.listKey(w, r, true) |
| 165 | if err != nil { |
| 166 | s.fail(w, r, err) |
| 167 | return |
| 168 | } |
| 169 | v, err := s.view(r.Context(), k, filter) |
| 170 | if err != nil { |
| 171 | s.fail(w, r, err) |
| 172 | return |
| 173 | } |
| 174 | if id, err := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64); err == nil { |
| 175 | v.EditID = id |
| 176 | } |
| 177 | s.render(w, r, "page.html", v) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | // render executes a template into a buffer first, so a failure can still |
| 182 | // become a clean 500 and Content-Length is exact. |
| 183 | func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, v view) { |
| 184 | var buf bytes.Buffer |
| 185 | if err := page.ExecuteTemplate(&buf, name, v); err != nil { |
| 186 | s.fail(w, r, err) |
| 187 | return |
| 188 | } |
| 189 | h := w.Header() |
| 190 | h.Set("Content-Type", "text/html; charset=utf-8") |
| 191 | h.Set("Content-Length", strconv.Itoa(buf.Len())) |
| 192 | h.Set("Cache-Control", "no-store") |
| 193 | w.Write(buf.Bytes()) |
| 194 | } |
| 195 | |
| 196 | // action wraps a list-wide form POST: origin check, cookie, form parsing, |
| 197 | // then a 303 back to the filter (or the list's HTML for app.js). |
| 198 | func (s *Server) action(do func(r *http.Request, k todo.ListKey) error) http.HandlerFunc { |
| 199 | return func(w http.ResponseWriter, r *http.Request) { |
| 200 | if !s.sameOrigin(r) { |
| 201 | http.Error(w, "cross-origin request refused", http.StatusForbidden) |
| 202 | return |
| 203 | } |
| 204 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) |
| 205 | if err := r.ParseForm(); err != nil { |
| 206 | status := http.StatusBadRequest |
| 207 | if _, ok := errors.AsType[*http.MaxBytesError](err); ok { |
| 208 | status = http.StatusRequestEntityTooLarge |
| 209 | } |
| 210 | http.Error(w, http.StatusText(status), status) |
| 211 | return |
| 212 | } |
| 213 | k, err := s.listKey(w, r, false) |
| 214 | if err != nil { |
| 215 | s.fail(w, r, err) |
| 216 | return |
| 217 | } |
| 218 | if err := do(r, k); err != nil { |
| 219 | if errors.Is(err, todo.ErrNotFound) { |
| 220 | http.NotFound(w, r) |
| 221 | return |
| 222 | } |
| 223 | s.fail(w, r, err) |
| 224 | return |
| 225 | } |
| 226 | filter, to := filterPath(r.PostFormValue("filter")) |
| 227 | if r.Header.Get(fragmentHeader) == "" { |
| 228 | http.Redirect(w, r, to, http.StatusSeeOther) |
| 229 | return |
| 230 | } |
| 231 | v, err := s.view(r.Context(), k, filter) |
| 232 | if err != nil { |
| 233 | s.fail(w, r, err) |
| 234 | return |
| 235 | } |
| 236 | s.render(w, r, "list", v) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // itemAction is action for a route naming one todo. An id that doesn't |
| 241 | // parse is as unknown as one that isn't in the caller's list. |
| 242 | func (s *Server) itemAction(do func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error) http.HandlerFunc { |
| 243 | return s.action(func(r *http.Request, k todo.ListKey) error { |
| 244 | id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 245 | if err != nil { |
| 246 | return todo.ErrNotFound |
| 247 | } |
| 248 | return do(r.Context(), k, id, r) |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | // sameOrigin allows a POST with no Origin header (the contract says so; |
| 253 | // SameSite=Lax already keeps cross-site POSTs from carrying the cookie) |
| 254 | // or one naming this app's own origin. |
| 255 | func (s *Server) sameOrigin(r *http.Request) bool { |
| 256 | origin := r.Header.Get("Origin") |
| 257 | if origin == "" { |
| 258 | return true |
| 259 | } |
| 260 | if s.cfg.Origin != "" { |
| 261 | return origin == s.cfg.Origin |
| 262 | } |
| 263 | scheme := "http" |
| 264 | if r.TLS != nil { |
| 265 | scheme = "https" |
| 266 | } |
| 267 | return origin == scheme+"://"+r.Host |
| 268 | } |
| 269 | |
| 270 | // listKey reads the visitor's list cookie. A missing cookie, or one the |
| 271 | // server never issued, gets a fresh random one, which is a fresh empty |
| 272 | // list. When renew is set, a valid cookie is sent again to push its |
| 273 | // expiry forward. |
| 274 | func (s *Server) listKey(w http.ResponseWriter, r *http.Request, renew bool) (todo.ListKey, error) { |
| 275 | ctx := r.Context() |
| 276 | if c, err := r.Cookie(cookieName); err == nil { |
| 277 | secret, err := base64.RawURLEncoding.DecodeString(c.Value) |
| 278 | if err == nil && len(secret) == secretBytes { |
| 279 | k := todo.KeyFor(secret) |
| 280 | ok, err := s.store.ListExists(ctx, k) |
| 281 | if err != nil { |
| 282 | return k, err |
| 283 | } |
| 284 | if ok { |
| 285 | if renew { |
| 286 | s.setCookie(w, c.Value) |
| 287 | } |
| 288 | return k, nil |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | secret := make([]byte, secretBytes) |
| 293 | rand.Read(secret) // never fails (crypto/rand panics rather than return an error) |
| 294 | k := todo.KeyFor(secret) |
| 295 | if err := s.store.CreateList(ctx, k); err != nil { |
| 296 | return k, err |
| 297 | } |
| 298 | s.setCookie(w, base64.RawURLEncoding.EncodeToString(secret)) |
| 299 | return k, nil |
| 300 | } |
| 301 | |
| 302 | func (s *Server) setCookie(w http.ResponseWriter, value string) { |
| 303 | http.SetCookie(w, &http.Cookie{ |
| 304 | Name: cookieName, |
| 305 | Value: value, |
| 306 | Path: "/", |
| 307 | MaxAge: cookieMaxAge, |
| 308 | HttpOnly: true, |
| 309 | Secure: s.cfg.Secure, |
| 310 | SameSite: http.SameSiteLaxMode, |
| 311 | }) |
| 312 | } |
| 313 | |
| 314 | func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) { |
| 315 | if errors.Is(err, context.Canceled) { |
| 316 | return // the client went away |
| 317 | } |
| 318 | s.cfg.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) |
| 319 | http.Error(w, "Something went wrong. Please try again.", http.StatusInternalServerError) |
| 320 | } |
| 321 | |
| 322 | func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { |
| 323 | if err := s.store.Ping(r.Context()); err != nil { |
| 324 | s.fail(w, r, err) |
| 325 | return |
| 326 | } |
| 327 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 328 | w.Write([]byte("ok\n")) |
| 329 | } |
| 330 | |
| 331 | func (s *Server) version(w http.ResponseWriter, r *http.Request) { |
| 332 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| 333 | w.Header().Set("Cache-Control", "no-store") |
| 334 | w.Write([]byte(s.cfg.Version)) |
| 335 | } |
| 336 | |
| 337 | // loadStatic reads the embedded assets once, with an ETag for each. |
| 338 | func loadStatic() map[string]asset { |
| 339 | out := map[string]asset{} |
| 340 | sub, _ := fs.Sub(staticFS, "static") |
| 341 | fs.WalkDir(sub, ".", func(name string, d fs.DirEntry, err error) error { |
| 342 | if err != nil || d.IsDir() { |
| 343 | return err |
| 344 | } |
| 345 | body, err := fs.ReadFile(sub, name) |
| 346 | if err != nil { |
| 347 | return err |
| 348 | } |
| 349 | sum := sha256.Sum256(body) |
| 350 | out[name] = asset{ |
| 351 | body: body, |
| 352 | ctype: mime.TypeByExtension(path.Ext(name)), |
| 353 | etag: `"` + hex.EncodeToString(sum[:8]) + `"`, |
| 354 | } |
| 355 | return nil |
| 356 | }) |
| 357 | return out |
| 358 | } |
| 359 | |
| 360 | func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { |
| 361 | a, ok := s.static[r.PathValue("name")] |
| 362 | if !ok { |
| 363 | http.NotFound(w, r) |
| 364 | return |
| 365 | } |
| 366 | h := w.Header() |
| 367 | h.Set("Content-Type", a.ctype) |
| 368 | h.Set("ETag", a.etag) |
| 369 | // Pages link assets with ?v=<build>, so a new build fetches afresh. |
| 370 | h.Set("Cache-Control", "public, max-age=86400") |
| 371 | if r.Header.Get("If-None-Match") == a.etag { |
| 372 | w.WriteHeader(http.StatusNotModified) |
| 373 | return |
| 374 | } |
| 375 | h.Set("Content-Length", strconv.Itoa(len(a.body))) |
| 376 | w.Write(a.body) |
| 377 | } |
| 378 | |