carlos / todomvc-bakeoff Public

Clone
git clone https://amadan.net/carlos/todomvc-bakeoff

Plain git — no account needed to clone.

Download

Download this file

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.
7package web
8
9import (
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.
31const bumpMarker = "steady"
32
33const (
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
43var templateFS embed.FS
44
45//go:embed static
46var staticFS embed.FS
47
48var page = template.Must(template.ParseFS(templateFS, "templates/page.html"))
49
50// Config is what a Server needs to know about where it runs.
51type 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.
64type Server struct {
65 cfg Config
66 store *todo.Store
67 mux *http.ServeMux
68 static map[string]asset
69}
70
71type asset struct {
72 body []byte
73 ctype string
74 etag string
75}
76
77// New builds the handler.
78func 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
112func (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".
121func 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.
132type 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
142func (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
162func (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.
183func (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).
198func (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.
242func (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.
255func (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.
274func (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
302func (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
314func (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
322func (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
331func (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.
338func 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
360func (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