feat(go): server-rendered TodoMVC in Go with a fetch layer

Stdlib net/http and html/template, modernc.org/sqlite in WAL mode,
HMAC-checked anonymous list cookies, and a small script that replays
the page's own forms. Passes the shared suite locally (67 passed,
3 skipped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 09eb61cf88fb725c122055088a8b047df4ccd30c parent 67a0ec5
16 files changed, +1863 −0
  • apps/go/.gitignore +5 −0
  • apps/go/DEPS +1 −0
  • apps/go/Makefile +34 −0
  • apps/go/README.md +98 −0
  • apps/go/app_test.go +312 −0
  • apps/go/go.mod +17 −0
  • apps/go/go.sum +50 −0
  • apps/go/handlers.go +244 −0
  • apps/go/main.go +110 −0
  • apps/go/static/app.css +60 −0
  • apps/go/static/app.js +74 −0
  • apps/go/static/base.css +141 −0
  • apps/go/static/index.css +393 −0
  • apps/go/store.go +182 −0
  • apps/go/templates/page.html +84 −0
  • apps/go/visitor.go +58 −0
diff --git a/apps/go/.gitignore b/apps/go/.gitignore
new file mode 100644
index 0000000..51ec8e1
--- /dev/null
+++ b/apps/go/.gitignore
@@ -0,0 +1,5 @@
+dist/
+.cache/
+*.db
+*.db-shm
+*.db-wal
diff --git a/apps/go/DEPS b/apps/go/DEPS
new file mode 100644
index 0000000..345662d
--- /dev/null
+++ b/apps/go/DEPS
@@ -0,0 +1 @@
+modernc.org/sqlite
diff --git a/apps/go/Makefile b/apps/go/Makefile
new file mode 100644
index 0000000..6f5435c
--- /dev/null
+++ b/apps/go/Makefile
@@ -0,0 +1,34 @@
+# TodoMVC in Go for the CARLOS bakeoff. See README.md.
+VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+LDFLAGS := -s -w -X main.version=$(VERSION)
+GOFLAGS_COLD := $(if $(COLD),-a,)
+APP := todo-go
+ACCOUNT := bfj
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy
+
+build:
+ CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/app .
+
+build-arm64:
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(GOFLAGS_COLD) -trimpath -ldflags '$(LDFLAGS)' -o dist/app-linux-arm64 .
+
+test:
+ CGO_ENABLED=0 go vet ./...
+ CGO_ENABLED=0 go test ./...
+
+clean:
+ rm -rf dist
+
+# Go keeps compiled packages in the shared GOCACHE, which this app never
+# changes or deletes; `make build-arm64 COLD=1` rebuilds everything instead.
+clean-deps: clean
+
+bump:
+ sed -i 's/^const serverName = "todo-go"$$/const serverName = "todo-go (bumped)"/' handlers.go
+
+unbump:
+ sed -i 's/^const serverName = "todo-go (bumped)"$$/const serverName = "todo-go"/' handlers.go
+
+deploy:
+ carlos deploy --account $(ACCOUNT) --app $(APP) --target linux-arm64 --message "$(or $(MESSAGE),Deploy $(VERSION))" dist/app-linux-arm64
diff --git a/apps/go/README.md b/apps/go/README.md
new file mode 100644
index 0000000..5a51740
--- /dev/null
+++ b/apps/go/README.md
@@ -0,0 +1,98 @@
+# 🤖 todo-go
+
+TodoMVC for the CARLOS bakeoff, written in Go and live at
+[https://todo-go.bfj.oncarlos.com](https://todo-go.bfj.oncarlos.com).
+The server renders every page and handles every action as a plain form
+POST, so the app works fully with JavaScript off. A small script then
+makes it behave like the reference TodoMVC when JavaScript is on.
+
+## The stack, and why
+
+- **Standard library HTTP.** Go 1.22's `net/http` router matches methods
+ and path parameters (`POST /todos/{id}/toggle`), which is all this app
+ needs. A third-party router would add a dependency and save nothing.
+- **`html/template`** for the pages. It escapes by context, so a title
+ like `<img onerror=…>` is shown as text without any extra care. One
+ template file holds the page and the list fragment, and the
+ fragment is reused for JavaScript updates, so there's one renderer.
+- **`modernc.org/sqlite`** for storage, the only third-party dependency.
+ It's SQLite translated to pure Go, so the binary builds with
+ `CGO_ENABLED=0` and cross-compiles to linux-arm64 with no C toolchain,
+ and the result is fully static. The cost is speed: it's slower than
+ the C library through cgo, which doesn't matter for a todo list.
+ The app opens the file twice: a writer pool of one connection, so
+ writes queue in Go rather than retrying on `SQLITE_BUSY`, and a
+ read-only pool that WAL lets read alongside it.
+- **Plain JavaScript, no framework** (`static/app.js`, about 80 lines).
+ It doesn't re-implement the app. It intercepts the page's own form
+ submissions, sends them with `fetch` and an `X-Todo-Fragment` header,
+ and swaps in the `section.main` and `footer.footer` the server sends
+ back. Double-click, Escape and blur-to-save are the only behaviour
+ that exists only in the script.
+- **`log/slog`** for logs, and `signal.NotifyContext` plus
+ `http.Server.Shutdown` for a clean exit on SIGTERM.
+
+The whole app is five Go files: `main.go` (flags, listening, shutdown),
+`handlers.go` (routes and rendering), `store.go` (schema and queries),
+`visitor.go` (the list cookie) and `app_test.go`.
+
+## How it keeps the contract
+
+- **Each visitor's list.** The cookie holds 32 random bytes and a
+ 16-byte HMAC of them, keyed by a secret the app creates in its own
+ database on first boot. Only tokens the app issued pass the check;
+ anything else, garbled or forged, gets a new token and an empty list.
+ Because the token checks itself, a visitor who only looks at the page
+ causes no database write. The database stores the SHA-256 of the
+ token, never the token, and every query on a todo is filtered by it.
+ A todo id that isn't in the caller's list answers 404.
+- **Cross-site POSTs.** A POST whose `Origin` header isn't the app's own
+ origin gets a 403. The origin is `https://$CARLOS_HOST` on Carloku and
+ `http://<listen address>` locally. Run with `--socket` and no
+ `CARLOS_HOST`, the app has no origin of its own, so it refuses every
+ POST that carries an `Origin` header.
+- **Without JavaScript**, a transparent submit button covers each
+ checkbox and the toggle-all arrow, so clicking the same spot does the
+ same thing, and an "edit" link opens `?edit=<id>`. A line of inline
+ script marks the page `js` before first paint, which hides those
+ extras; the Content-Security-Policy allows that one script by its hash.
+- **Migrations** are a numbered list in `store.go`, applied once each in
+ a transaction, and they only ever add.
+
+## Honest trade-offs
+
+- **Trust class C (2026-10-23).** Todo titles are stored as plain text
+ in SQLite, and the server, the platform and its backups can read them.
+ It's an anonymous public demo with no accounts, so there's no key to
+ encrypt to. Don't put anything private in it.
+- One instance serves every visitor, so a crash affects everyone at once.
+- A list lives only as long as its cookie. Clear cookies and it's gone
+ from your view, though the rows stay in the database.
+
+## Build, run, test, deploy
+
+Put the toolchain on `PATH` first with `source ../../scripts/env.sh`.
+
+| Command | Does |
+|---|---|
+| `make build` | Builds for this machine into `dist/app` |
+| `make build-arm64` | Builds the static linux-arm64 binary, `dist/app-linux-arm64`. `COLD=1` rebuilds every package (`go build -a`) |
+| `make test` | `go vet` and the Go tests |
+| `make clean` | Removes `dist/` |
+| `make clean-deps` | Same as `clean`: Go keeps compiled packages in the shared `GOCACHE`, which this app never changes; use `COLD=1` for a from-scratch build |
+| `make bump` / `make unbump` | Edits the `serverName` constant in `handlers.go` and puts it back |
+| `make deploy` | `carlos deploy` of `dist/app-linux-arm64` to `todo-go` on account `bfj`. Set `MESSAGE="…"` to describe the change |
+
+The build stamps the git short SHA into the binary, and
+`GET /api/version` reports it.
+
+Run it locally:
+
+ dist/app --listen 127.0.0.1:8080 --db $TMPDIR/go.db
+
+Run the shared behaviour suite against it, from the repo root:
+
+ scripts/test-app.sh http://127.0.0.1:8080 $TMPDIR/go-suite.json
+
+On Carloku the platform starts it as
+`app --socket <path> --db <path>` with `CARLOS_HOST` set.
diff --git a/apps/go/app_test.go b/apps/go/app_test.go
new file mode 100644
index 0000000..21d61b2
--- /dev/null
+++ b/apps/go/app_test.go
@@ -0,0 +1,312 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+const testOrigin = "http://127.0.0.1:1"
+
+func newTestApp(t *testing.T) (*httptest.Server, *Store) {
+ t.Helper()
+ store, err := openStore(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { store.Close() })
+ key, err := store.Secret(t.Context())
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := &app{store: store, visitors: &visitors{key: key}, origin: testOrigin, version: "test",
+ log: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ srv := httptest.NewServer(a.routes())
+ t.Cleanup(srv.Close)
+ return srv, store
+}
+
+// visitor is one browser: its own cookie jar, never following redirects.
+type visitor struct {
+ t *testing.T
+ base string
+ c *http.Client
+}
+
+func newVisitor(t *testing.T, srv *httptest.Server) *visitor {
+ jar, _ := cookiejar.New(nil)
+ return &visitor{t: t, base: srv.URL, c: &http.Client{Jar: jar,
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
+}
+
+func (v *visitor) get(path string) (int, string) {
+ v.t.Helper()
+ res, err := v.c.Get(v.base + path)
+ if err != nil {
+ v.t.Fatal(err)
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ return res.StatusCode, string(body)
+}
+
+func (v *visitor) post(path string, form url.Values, header ...string) *http.Response {
+ v.t.Helper()
+ req, _ := http.NewRequest("POST", v.base+path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for i := 0; i+1 < len(header); i += 2 {
+ req.Header.Set(header[i], header[i+1])
+ }
+ res, err := v.c.Do(req)
+ if err != nil {
+ v.t.Fatal(err)
+ }
+ io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ return res
+}
+
+var idRe = regexp.MustCompile(`data-id="(\d+)"`)
+
+func (v *visitor) ids(path string) []string {
+ _, body := v.get(path)
+ var out []string
+ for _, m := range idRe.FindAllStringSubmatch(body, -1) {
+ out = append(out, m[1])
+ }
+ return out
+}
+
+func TestStaticCSSMatchesShared(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ for _, name := range []string{"base.css", "index.css"} {
+ want, err := os.ReadFile(filepath.Join("..", "..", "shared", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, got := v.get("/static/" + name); got != string(want) {
+ t.Errorf("/static/%s differs from shared/%s", name, name)
+ }
+ }
+}
+
+func TestPlatformRoutes(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ if code, _ := v.get("/healthz"); code != 200 {
+ t.Errorf("healthz = %d", code)
+ }
+ if code, body := v.get("/api/version"); code != 200 || body != "test" {
+ t.Errorf("version = %d %q", code, body)
+ }
+ if code, _ := v.get("/nope"); code != 404 {
+ t.Errorf("unknown path = %d", code)
+ }
+}
+
+func TestActionsRedirectToFilter(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
+ res := v.post("/todos", url.Values{"title": {"x"}, "filter": {filter}})
+ if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want {
+ t.Errorf("filter %q: %d %q, want 303 %q", filter, res.StatusCode, res.Header.Get("Location"), want)
+ }
+ }
+}
+
+func TestTodoLifecycle(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ v.post("/todos", url.Values{"title": {" one "}})
+ v.post("/todos", url.Values{"title": {" "}})
+ v.post("/todos", url.Values{"title": {"two"}})
+ ids := v.ids("/")
+ if len(ids) != 2 {
+ t.Fatalf("want 2 todos, got %v", ids)
+ }
+ if _, body := v.get("/"); !strings.Contains(body, "<label>one</label>") || !strings.Contains(body, "<strong>2</strong> items left") {
+ t.Errorf("page missing trimmed title or counter:\n%s", body)
+ }
+
+ v.post("/todos/"+ids[0]+"/toggle", nil)
+ if got := v.ids("/active"); len(got) != 1 || got[0] != ids[1] {
+ t.Errorf("active = %v", got)
+ }
+ if got := v.ids("/completed"); len(got) != 1 || got[0] != ids[0] {
+ t.Errorf("completed = %v", got)
+ }
+
+ v.post("/todos/toggle-all", nil) // one was done: now all done
+ if got := v.ids("/active"); len(got) != 0 {
+ t.Errorf("after toggle-all, active = %v", got)
+ }
+ v.post("/todos/toggle-all", nil) // all were done: now all active
+ if got := v.ids("/completed"); len(got) != 0 {
+ t.Errorf("after second toggle-all, completed = %v", got)
+ }
+
+ v.post("/todos/"+ids[1]+"/edit", url.Values{"title": {" renamed "}})
+ if _, body := v.get("/"); !strings.Contains(body, "<label>renamed</label>") {
+ t.Error("rename not shown")
+ }
+ v.post("/todos/"+ids[1]+"/edit", url.Values{"title": {" "}})
+ if got := v.ids("/"); len(got) != 1 {
+ t.Errorf("empty edit should delete, have %v", got)
+ }
+
+ v.post("/todos/"+ids[0]+"/toggle", nil)
+ v.post("/todos/clear-completed", nil)
+ if got := v.ids("/"); len(got) != 0 {
+ t.Errorf("clear-completed left %v", got)
+ }
+}
+
+func TestEditView(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ v.post("/todos", url.Values{"title": {"a"}})
+ id := v.ids("/")[0]
+ _, body := v.get("/active?edit=" + id)
+ if !strings.Contains(body, `data-id="`+id+`" class="editing"`) || !strings.Contains(body, `action="/todos/`+id+`/edit"`) {
+ t.Errorf("edit view wrong:\n%s", body)
+ }
+ if !strings.Contains(body, `href="/active?edit=`+id+`"`) {
+ t.Error("edit link should stay on the current filter")
+ }
+}
+
+func TestEscapesAndKeepsUnicode(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ title := `<img src=x onerror=alert(1)> & "q" naïve 🚀 日本語`
+ v.post("/todos", url.Values{"title": {title}})
+ _, body := v.get("/")
+ if strings.Contains(body, "<img") {
+ t.Error("title rendered as HTML")
+ }
+ if !strings.Contains(body, "naïve 🚀 日本語") {
+ t.Error("unicode not kept")
+ }
+ long := strings.Repeat("x", 10000)
+ v.post("/todos", url.Values{"title": {long}})
+ if _, body := v.get("/"); !strings.Contains(body, "<label>"+long+"</label>") {
+ t.Error("10,000-character title lost")
+ }
+}
+
+func TestListsAreIsolated(t *testing.T) {
+ srv, _ := newTestApp(t)
+ a, b := newVisitor(t, srv), newVisitor(t, srv)
+ a.post("/todos", url.Values{"title": {"mine"}})
+ id := a.ids("/")[0]
+ if got := b.ids("/"); len(got) != 0 {
+ t.Fatalf("b sees a's list: %v", got)
+ }
+ for _, action := range []string{"toggle", "edit", "destroy"} {
+ if res := b.post("/todos/"+id+"/"+action, url.Values{"title": {"hijack"}}); res.StatusCode != 404 {
+ t.Errorf("b %s on a's todo = %d, want 404", action, res.StatusCode)
+ }
+ }
+ if res := a.post("/todos/abc/toggle", nil); res.StatusCode != 404 {
+ t.Errorf("non-numeric id = %d", res.StatusCode)
+ }
+ _, body := a.get("/")
+ if !strings.Contains(body, "<label>mine</label>") || strings.Contains(body, `class="completed"`) {
+ t.Error("a's todo changed")
+ }
+}
+
+func TestUnknownCookiesGetAFreshList(t *testing.T) {
+ srv, _ := newTestApp(t)
+ a := newVisitor(t, srv)
+ a.post("/todos", url.Values{"title": {"mine"}})
+ u, _ := url.Parse(srv.URL)
+ real := a.c.Jar.Cookies(u)[0].Value
+ // Garbled, well-formed but forged, and a real token with a flipped bit.
+ flipped := []byte(real)
+ flipped[10] ^= 1
+ for _, value := range []string{"%%%", strings.Repeat("A", len(real)), string(flipped)} {
+ req, _ := http.NewRequest("GET", srv.URL+"/", nil)
+ req.AddCookie(&http.Cookie{Name: cookieName, Value: value})
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ if res.StatusCode != 200 || idRe.Match(body) {
+ t.Errorf("cookie %q: status %d, list not empty", value, res.StatusCode)
+ }
+ var fresh *http.Cookie
+ for _, c := range res.Cookies() {
+ if c.Name == cookieName {
+ fresh = c
+ }
+ }
+ if fresh == nil || fresh.Value == value || !fresh.HttpOnly || fresh.SameSite != http.SameSiteLaxMode || fresh.Path != "/" {
+ t.Errorf("cookie %q: no proper fresh cookie: %+v", value, fresh)
+ }
+ }
+}
+
+func TestOriginCheck(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ if res := v.post("/todos", url.Values{"title": {"evil"}}, "Origin", "https://evil.example"); res.StatusCode != 403 {
+ t.Errorf("foreign origin = %d", res.StatusCode)
+ }
+ if res := v.post("/todos", url.Values{"title": {"ok"}}, "Origin", testOrigin); res.StatusCode != 303 {
+ t.Errorf("own origin = %d", res.StatusCode)
+ }
+ if got := v.ids("/"); len(got) != 1 {
+ t.Errorf("want only the same-origin todo, got %v", got)
+ }
+}
+
+func TestFragmentForScript(t *testing.T) {
+ srv, _ := newTestApp(t)
+ v := newVisitor(t, srv)
+ req, _ := http.NewRequest("POST", srv.URL+"/todos", strings.NewReader("title=frag&filter=active"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set(fragmentHeader, "1")
+ res, err := v.c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ if res.StatusCode != 200 || !bytes.HasPrefix(body, []byte(`<section class="main">`)) ||
+ !bytes.Contains(body, []byte("<label>frag</label>")) || !bytes.Contains(body, []byte(`class="selected" href="/active"`)) {
+ t.Errorf("fragment: %d\n%s", res.StatusCode, body)
+ }
+ if res.Header.Get("Content-Length") == "" {
+ t.Error("no Content-Length")
+ }
+}
+
+func TestMigrationsAreRepeatable(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "m.db")
+ for range 2 {
+ s, err := openStore(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var mode string
+ s.r.QueryRow(`PRAGMA journal_mode`).Scan(&mode)
+ if mode != "wal" {
+ t.Errorf("journal_mode = %q", mode)
+ }
+ s.Close()
+ }
+}
diff --git a/apps/go/go.mod b/apps/go/go.mod
new file mode 100644
index 0000000..15dbdda
--- /dev/null
+++ b/apps/go/go.mod
@@ -0,0 +1,17 @@
+module todo-go
+
+go 1.26.10
+
+require modernc.org/sqlite v1.61.0
+
+require (
+ github.com/dustin/go-humanize v1.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/mattn/go-isatty v0.0.24 // indirect
+ github.com/ncruces/go-strftime v1.1.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.49.0 // indirect
+ modernc.org/libc v1.79.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.12.1 // indirect
+)
diff --git a/apps/go/go.sum b/apps/go/go.sum
new file mode 100644
index 0000000..f39acd7
--- /dev/null
+++ b/apps/go/go.sum
@@ -0,0 +1,50 @@
+github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
+github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
+github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
+github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
+golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
+golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
+golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
+golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
+golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
+golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
+modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
+modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
+modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
+modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
+modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
+modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
+modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/apps/go/handlers.go b/apps/go/handlers.go
new file mode 100644
index 0000000..c745ef6
--- /dev/null
+++ b/apps/go/handlers.go
@@ -0,0 +1,244 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "embed"
+ "encoding/base64"
+ "errors"
+ "html/template"
+ "io/fs"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+// serverName is sent as the Server header. `make bump` edits it to force
+// this file to recompile.
+const serverName = "todo-go"
+
+// fragmentHeader marks a request from static/app.js. With it, an action
+// answers 200 with the re-rendered list instead of a 303.
+const fragmentHeader = "X-Todo-Fragment"
+
+// markJS runs before first paint so the JavaScript-off controls never flash.
+const markJS = `document.documentElement.className = 'js';`
+
+//go:embed templates/page.html
+var pageHTML string
+
+//go:embed static
+var staticFiles embed.FS
+
+var page = template.Must(template.New("page").Parse(pageHTML))
+
+var csp = "default-src 'self'; script-src 'self' 'sha256-" + func() string {
+ sum := sha256.Sum256([]byte(markJS))
+ return base64.StdEncoding.EncodeToString(sum[:])
+}() + "'; style-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
+
+// filters maps the form's filter field to the page it redirects to.
+var filters = map[string]string{"all": "/", "active": "/active", "completed": "/completed"}
+
+type app struct {
+ store *Store
+ visitors *visitors
+ origin string // the only Origin a POST may carry
+ version string
+ log *slog.Logger
+}
+
+func (a *app) routes() http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /{$}", a.show("all"))
+ mux.HandleFunc("GET /active", a.show("active"))
+ mux.HandleFunc("GET /completed", a.show("completed"))
+ static, _ := fs.Sub(staticFiles, "static")
+ files := http.FileServerFS(static)
+ mux.Handle("GET /static/", http.StripPrefix("/static/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "" || strings.HasSuffix(r.URL.Path, "/") {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "public, max-age=3600")
+ files.ServeHTTP(w, r)
+ })))
+ mux.HandleFunc("GET /healthz", a.healthz)
+ mux.HandleFunc("GET /api/version", func(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(a.version))
+ })
+
+ mux.HandleFunc("POST /todos", a.act(func(ctx context.Context, list []byte, r *http.Request) error {
+ if title := strings.TrimSpace(r.PostFormValue("title")); title != "" {
+ return a.store.Add(ctx, list, title)
+ }
+ return nil
+ }))
+ mux.HandleFunc("POST /todos/toggle-all", a.act(func(ctx context.Context, list []byte, r *http.Request) error {
+ return a.store.ToggleAll(ctx, list)
+ }))
+ mux.HandleFunc("POST /todos/clear-completed", a.act(func(ctx context.Context, list []byte, r *http.Request) error {
+ return a.store.ClearCompleted(ctx, list)
+ }))
+ mux.HandleFunc("POST /todos/{id}/toggle", a.actOn(func(ctx context.Context, list []byte, id int64, r *http.Request) error {
+ return a.store.Toggle(ctx, list, id)
+ }))
+ mux.HandleFunc("POST /todos/{id}/edit", a.actOn(func(ctx context.Context, list []byte, id int64, r *http.Request) error {
+ if title := strings.TrimSpace(r.PostFormValue("title")); title != "" {
+ return a.store.Rename(ctx, list, id, title)
+ }
+ return a.store.Delete(ctx, list, id)
+ }))
+ mux.HandleFunc("POST /todos/{id}/destroy", a.actOn(func(ctx context.Context, list []byte, id int64, r *http.Request) error {
+ return a.store.Delete(ctx, list, id)
+ }))
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ h.Set("Server", serverName)
+ h.Set("Content-Security-Policy", csp)
+ h.Set("X-Content-Type-Options", "nosniff")
+ h.Set("Referrer-Policy", "same-origin")
+ if r.Method == http.MethodPost {
+ if o := r.Header.Get("Origin"); o != "" && o != a.origin {
+ http.Error(w, "cross-origin request refused", http.StatusForbidden)
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ }
+ mux.ServeHTTP(w, r)
+ })
+}
+
+func (a *app) healthz(w http.ResponseWriter, r *http.Request) {
+ if err := a.store.Ping(r.Context()); err != nil {
+ http.Error(w, "database unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ w.Write([]byte("ok\n"))
+}
+
+// view is what the templates render.
+type view struct {
+ MarkJS template.JS
+ Filter, Path string
+ EditID int64
+ Items []item
+ Total, Active, Done int
+ AllDone bool
+}
+
+type item struct {
+ Todo
+ Class string
+ Editing bool
+}
+
+func (a *app) view(ctx context.Context, list []byte, filter string, editID int64) (*view, error) {
+ todos, err := a.store.List(ctx, list)
+ if err != nil {
+ return nil, err
+ }
+ v := &view{MarkJS: markJS, Filter: filter, Path: filters[filter], EditID: editID, Total: len(todos)}
+ for _, t := range todos {
+ if t.Completed {
+ v.Done++
+ } else {
+ v.Active++
+ }
+ if (filter == "active" && t.Completed) || (filter == "completed" && !t.Completed) {
+ continue
+ }
+ it := item{Todo: t, Editing: t.ID == editID}
+ var class []string
+ if t.Completed {
+ class = append(class, "completed")
+ }
+ if it.Editing {
+ class = append(class, "editing")
+ }
+ it.Class = strings.Join(class, " ")
+ v.Items = append(v.Items, it)
+ }
+ v.AllDone = v.Total > 0 && v.Done == v.Total
+ return v, nil
+}
+
+func (a *app) show(filter string) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ list := a.visitors.listKey(w, r)
+ editID, _ := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64)
+ v, err := a.view(r.Context(), list, filter, editID)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.render(w, r, "page", v)
+ }
+}
+
+func (a *app) render(w http.ResponseWriter, r *http.Request, name string, v *view) {
+ var buf bytes.Buffer
+ if err := page.ExecuteTemplate(&buf, name, v); err != nil {
+ a.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())
+}
+
+// act wraps a change to the caller's list and answers the way the contract
+// asks: 303 back to the filter, or the fresh list for app.js.
+func (a *app) act(change func(ctx context.Context, list []byte, r *http.Request) error) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ list := a.visitors.listKey(w, r)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad form", http.StatusBadRequest)
+ return
+ }
+ filter := r.PostFormValue("filter")
+ if _, ok := filters[filter]; !ok {
+ filter = "all"
+ }
+ if err := change(r.Context(), list, r); errors.Is(err, errNotFound) {
+ http.Error(w, "no such todo in your list", http.StatusNotFound)
+ return
+ } else if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ if r.Header.Get(fragmentHeader) == "" {
+ http.Redirect(w, r, filters[filter], http.StatusSeeOther)
+ return
+ }
+ v, err := a.view(r.Context(), list, filter, 0)
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.render(w, r, "lists", v)
+ }
+}
+
+// actOn is act for a change to one todo, named by the {id} in the path.
+func (a *app) actOn(change func(ctx context.Context, list []byte, id int64, r *http.Request) error) http.HandlerFunc {
+ return a.act(func(ctx context.Context, list []byte, r *http.Request) error {
+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
+ if err != nil {
+ return errNotFound
+ }
+ return change(ctx, list, id, r)
+ })
+}
+
+func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) {
+ a.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
+ http.Error(w, "something went wrong", http.StatusInternalServerError)
+}
diff --git a/apps/go/main.go b/apps/go/main.go
new file mode 100644
index 0000000..74ce7db
--- /dev/null
+++ b/apps/go/main.go
@@ -0,0 +1,110 @@
+// Command app serves TodoMVC for the CARLOS bakeoff.
+//
+// app --socket <path> --db <path> on Carloku
+// app --listen 127.0.0.1:<port> --db <path> locally and in tests
+package main
+
+import (
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// version is the build version, set with -ldflags "-X main.version=<sha>".
+var version = "dev"
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, "app:", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ socket := flag.String("socket", "", "unix socket to listen on")
+ listen := flag.String("listen", "", "TCP address to listen on instead, 127.0.0.1 only")
+ dbPath := flag.String("db", "", "SQLite database file")
+ flag.Parse()
+
+ log := slog.New(slog.NewTextHandler(os.Stderr, nil))
+ if *dbPath == "" || (*socket == "") == (*listen == "") {
+ flag.Usage()
+ return errors.New("need --db and exactly one of --socket or --listen")
+ }
+
+ var ln net.Listener
+ var err error
+ origin := ""
+ if *socket != "" {
+ os.Remove(*socket) // a stale socket from a previous run
+ ln, err = net.Listen("unix", *socket)
+ } else {
+ host, _, splitErr := net.SplitHostPort(*listen)
+ if splitErr != nil || host != "127.0.0.1" {
+ return fmt.Errorf("--listen must be 127.0.0.1:<port>, got %q", *listen)
+ }
+ ln, err = net.Listen("tcp", *listen)
+ if err == nil {
+ origin = "http://" + ln.Addr().String()
+ }
+ }
+ if err != nil {
+ return err
+ }
+ host := os.Getenv("CARLOS_HOST")
+ if host != "" {
+ origin = "https://" + host
+ }
+
+ store, err := openStore(*dbPath)
+ if err != nil {
+ ln.Close()
+ return fmt.Errorf("open %s: %w", *dbPath, err)
+ }
+ defer store.Close()
+ key, err := store.Secret(context.Background())
+ if err != nil {
+ return fmt.Errorf("cookie key: %w", err)
+ }
+
+ a := &app{
+ store: store,
+ visitors: &visitors{key: key, secure: strings.HasPrefix(origin, "https://")},
+ origin: origin,
+ version: version,
+ log: log,
+ }
+ srv := &http.Server{
+ Handler: a.routes(),
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 30 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 120 * time.Second,
+ ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn),
+ }
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
+ defer stop()
+ served := make(chan error, 1)
+ go func() { served <- srv.Serve(ln) }()
+ log.Info("serving", "addr", ln.Addr().String(), "origin", origin, "version", version)
+
+ select {
+ case err := <-served:
+ return err
+ case <-ctx.Done():
+ }
+ log.Info("shutting down")
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ return srv.Shutdown(shutdownCtx)
+}
diff --git a/apps/go/static/app.css b/apps/go/static/app.css
new file mode 100644
index 0000000..c66d7b9
--- /dev/null
+++ b/apps/go/static/app.css
@@ -0,0 +1,60 @@
+/* What this app adds to TodoMVC's own stylesheets. */
+
+[hidden] {
+ display: none !important;
+}
+
+/* The forms wrap controls without taking part in the layout, so the page
+ lays out exactly like the reference template. */
+.todoapp form {
+ display: contents;
+}
+
+/* With JavaScript on, the page is the reference page: the controls below
+ exist only for JavaScript-off visitors. */
+.js .nojs {
+ display: none;
+}
+
+/* Without JavaScript, a submit button sits over each checkbox and over the
+ toggle-all arrow, so the same spot on the page does the same thing. */
+.nojs-toggle,
+.nojs-toggle-all {
+ position: absolute;
+ z-index: 1;
+ cursor: pointer;
+}
+
+.nojs-toggle {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+}
+
+.nojs-toggle-all {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+.nojs-edit {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 56px;
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.nojs-edit:hover {
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 110px;
+}
diff --git a/apps/go/static/app.js b/apps/go/static/app.js
new file mode 100644
index 0000000..9c98d9f
--- /dev/null
+++ b/apps/go/static/app.js
@@ -0,0 +1,74 @@
+// With JavaScript on, the page's own forms are sent with fetch instead of
+// a page load, and the server's freshly rendered list (section.main and
+// footer.footer) is swapped in. The server stays the only renderer.
+
+const app = document.querySelector('.todoapp');
+let queue = Promise.resolve();
+
+// Sends one form, in order after any still in flight.
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue.then(async () => {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Todo-Fragment': '1' },
+ });
+ if (!res.ok) {
+ location.reload();
+ return;
+ }
+ const fresh = document.createElement('template');
+ fresh.innerHTML = await res.text();
+ for (const part of ['.main', '.footer']) {
+ app.querySelector(part).replaceWith(fresh.content.querySelector(part));
+ }
+ }).catch(() => location.reload());
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const edit = form.querySelector('.edit');
+ if (edit) {
+ if (edit.dataset.closed) return;
+ edit.dataset.closed = '1';
+ edit.closest('li').classList.remove('editing');
+ }
+ send(form);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+});
+
+// The checkboxes stand in for the toggle buttons hidden with JavaScript on.
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle')) {
+ e.target.closest('li').querySelector('[data-action="toggle"]').form.requestSubmit();
+ } else if (e.target.matches('.toggle-all')) {
+ app.querySelector('[data-action="toggle-all"]').form.requestSubmit();
+ }
+});
+
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ delete input.dataset.closed;
+ li.classList.add('editing');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const input = e.target;
+ input.dataset.closed = '1';
+ input.value = input.defaultValue;
+ input.closest('li').classList.remove('editing');
+});
+
+// Leaving the editor saves it, as Enter does.
+app.addEventListener('focusout', (e) => {
+ if (e.target.matches('.edit') && !e.target.dataset.closed) e.target.form.requestSubmit();
+});
diff --git a/apps/go/static/base.css b/apps/go/static/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/apps/go/static/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/apps/go/static/index.css b/apps/go/static/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/apps/go/static/index.css
@@ -0,0 +1,393 @@
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
diff --git a/apps/go/store.go b/apps/go/store.go
new file mode 100644
index 0000000..85354c6
--- /dev/null
+++ b/apps/go/store.go
@@ -0,0 +1,182 @@
+package main
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "errors"
+ "fmt"
+ "net/url"
+ "runtime"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+// errNotFound means the todo isn't in the caller's list, whether or not it
+// exists in someone else's.
+var errNotFound = errors.New("todo not found")
+
+// migrations only ever add. Each entry runs once, in order, and its index+1
+// is recorded in schema_migrations.
+var migrations = []string{
+ `CREATE TABLE todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ list_key BLOB NOT NULL,
+ title TEXT NOT NULL,
+ completed INTEGER NOT NULL DEFAULT 0,
+ created_at INTEGER NOT NULL
+ );
+ CREATE INDEX todos_by_list ON todos (list_key, id);`,
+ `CREATE TABLE meta (
+ name TEXT PRIMARY KEY,
+ value BLOB NOT NULL
+ );`,
+}
+
+type Todo struct {
+ ID int64
+ Title string
+ Completed bool
+}
+
+// Store holds two pools over one WAL-mode file: a single writer, so writes
+// queue in Go rather than spinning on SQLITE_BUSY, and several read-only
+// readers that WAL lets run beside it.
+type Store struct {
+ w, r *sql.DB
+}
+
+func openStore(path string) (*Store, error) {
+ dsn := func(extra string) string {
+ q := url.Values{}
+ q.Add("_pragma", "journal_mode(WAL)")
+ q.Add("_pragma", "busy_timeout(5000)")
+ q.Add("_pragma", "synchronous(NORMAL)")
+ q.Add("_pragma", "foreign_keys(ON)")
+ if extra != "" {
+ q.Add("_pragma", extra)
+ }
+ return "file:" + path + "?" + q.Encode()
+ }
+ w, err := sql.Open("sqlite", dsn("")+"&_txlock=immediate")
+ if err != nil {
+ return nil, err
+ }
+ w.SetMaxOpenConns(1)
+ if err := migrate(w); err != nil {
+ w.Close()
+ return nil, err
+ }
+ r, err := sql.Open("sqlite", dsn("query_only(1)"))
+ if err != nil {
+ w.Close()
+ return nil, err
+ }
+ r.SetMaxOpenConns(max(4, runtime.NumCPU()))
+ return &Store{w: w, r: r}, nil
+}
+
+func migrate(db *sql.DB) error {
+ if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`); err != nil {
+ return fmt.Errorf("migrations table: %w", err)
+ }
+ var have int
+ if err := db.QueryRow(`SELECT COALESCE(MAX(version), 0) FROM schema_migrations`).Scan(&have); err != nil {
+ return err
+ }
+ for i := have; i < len(migrations); i++ {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ if _, err := tx.Exec(migrations[i]); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("migration %d: %w", i+1, err)
+ }
+ if _, err := tx.Exec(`INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, i+1, time.Now().Unix()); err != nil {
+ tx.Rollback()
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *Store) Close() error {
+ return errors.Join(s.r.Close(), s.w.Close())
+}
+
+func (s *Store) Ping(ctx context.Context) error { return s.r.PingContext(ctx) }
+
+// Secret returns the instance's cookie-signing key, creating it on first use.
+func (s *Store) Secret(ctx context.Context) ([]byte, error) {
+ key := make([]byte, 32)
+ rand.Read(key)
+ if _, err := s.w.ExecContext(ctx, `INSERT INTO meta (name, value) VALUES ('cookie_key', ?) ON CONFLICT DO NOTHING`, key); err != nil {
+ return nil, err
+ }
+ err := s.w.QueryRowContext(ctx, `SELECT value FROM meta WHERE name = 'cookie_key'`).Scan(&key)
+ return key, err
+}
+
+func (s *Store) List(ctx context.Context, list []byte) ([]Todo, error) {
+ rows, err := s.r.QueryContext(ctx, `SELECT id, title, completed FROM todos WHERE list_key = ? ORDER BY id`, list)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Todo
+ for rows.Next() {
+ var t Todo
+ if err := rows.Scan(&t.ID, &t.Title, &t.Completed); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+func (s *Store) Add(ctx context.Context, list []byte, title string) error {
+ _, err := s.w.ExecContext(ctx, `INSERT INTO todos (list_key, title, created_at) VALUES (?, ?, ?)`, list, title, time.Now().Unix())
+ return err
+}
+
+// one runs a statement that must touch exactly the caller's todo.
+func (s *Store) one(ctx context.Context, query string, args ...any) error {
+ res, err := s.w.ExecContext(ctx, query, args...)
+ if err != nil {
+ return err
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ return errNotFound
+ }
+ return nil
+}
+
+func (s *Store) Toggle(ctx context.Context, list []byte, id int64) error {
+ return s.one(ctx, `UPDATE todos SET completed = 1 - completed WHERE id = ? AND list_key = ?`, id, list)
+}
+
+func (s *Store) Rename(ctx context.Context, list []byte, id int64, title string) error {
+ return s.one(ctx, `UPDATE todos SET title = ? WHERE id = ? AND list_key = ?`, title, id, list)
+}
+
+func (s *Store) Delete(ctx context.Context, list []byte, id int64) error {
+ return s.one(ctx, `DELETE FROM todos WHERE id = ? AND list_key = ?`, id, list)
+}
+
+// ToggleAll completes every item, or reopens them all if all were complete.
+func (s *Store) ToggleAll(ctx context.Context, list []byte) error {
+ _, err := s.w.ExecContext(ctx, `UPDATE todos SET completed =
+ (SELECT CASE WHEN COUNT(*) > 0 AND MIN(completed) = 1 THEN 0 ELSE 1 END FROM todos WHERE list_key = ?1)
+ WHERE list_key = ?1`, list)
+ return err
+}
+
+func (s *Store) ClearCompleted(ctx context.Context, list []byte) error {
+ _, err := s.w.ExecContext(ctx, `DELETE FROM todos WHERE list_key = ? AND completed = 1`, list)
+ return err
+}
diff --git a/apps/go/templates/page.html b/apps/go/templates/page.html
new file mode 100644
index 0000000..30222d3
--- /dev/null
+++ b/apps/go/templates/page.html
@@ -0,0 +1,84 @@
+{{define "page" -}}
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <script>{{.MarkJS}}</script>
+ <link rel="stylesheet" href="/static/base.css">
+ <link rel="stylesheet" href="/static/index.css">
+ <link rel="stylesheet" href="/static/app.css">
+ <script type="module" src="/static/app.js"></script>
+ </head>
+ <body>
+ <section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ <form method="post" action="/todos">
+ <input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{{if not .EditID}} autofocus{{end}}>
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ </form>
+ </header>
+ {{template "lists" .}}
+ </section>
+ <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Go</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
+{{end}}
+
+{{define "lists" -}}
+<section class="main"{{if not .Total}} hidden{{end}}>
+ <input id="toggle-all" class="toggle-all" type="checkbox"{{if .AllDone}} checked{{end}}>
+ <label for="toggle-all">Mark all as complete</label>
+ <form class="nojs" method="post" action="/todos/toggle-all">
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ <button class="nojs-toggle-all" data-action="toggle-all" aria-label="Mark all as complete"></button>
+ </form>
+ <ul class="todo-list">
+ {{- range .Items}}
+ <li data-id="{{.ID}}"{{with .Class}} class="{{.}}"{{end}}>
+ <div class="view">
+ <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}>
+ <label>{{.Title}}</label>
+ <form class="nojs" method="post" action="/todos/{{.ID}}/toggle">
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ <button class="nojs-toggle" data-action="toggle" aria-label="{{if .Completed}}Mark as active{{else}}Mark as complete{{end}}"></button>
+ </form>
+ <a class="nojs nojs-edit" data-action="edit" href="{{$.Path}}?edit={{.ID}}">edit</a>
+ <form method="post" action="/todos/{{.ID}}/destroy">
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ <button class="destroy" aria-label="Delete"></button>
+ </form>
+ </div>
+ <form method="post" action="/todos/{{.ID}}/edit">
+ <input class="edit" name="title" value="{{.Title}}" autocomplete="off"{{if .Editing}} autofocus{{end}}>
+ <input type="hidden" name="filter" value="{{$.Filter}}">
+ </form>
+ </li>
+ {{- end}}
+ </ul>
+ </section>
+ <footer class="footer"{{if not .Total}} hidden{{end}}>
+ <span class="todo-count"><strong>{{.Active}}</strong> {{if eq .Active 1}}item{{else}}items{{end}} left</span>
+ <ul class="filters">
+ <li>
+ <a{{if eq .Filter "all"}} class="selected"{{end}} href="/">All</a>
+ </li>
+ <li>
+ <a{{if eq .Filter "active"}} class="selected"{{end}} href="/active">Active</a>
+ </li>
+ <li>
+ <a{{if eq .Filter "completed"}} class="selected"{{end}} href="/completed">Completed</a>
+ </li>
+ </ul>
+ <form method="post" action="/todos/clear-completed">
+ <input type="hidden" name="filter" value="{{.Filter}}">
+ <button class="clear-completed"{{if not .Done}} hidden{{end}}>Clear completed</button>
+ </form>
+ </footer>
+{{- end}}
diff --git a/apps/go/visitor.go b/apps/go/visitor.go
new file mode 100644
index 0000000..8627f36
--- /dev/null
+++ b/apps/go/visitor.go
@@ -0,0 +1,58 @@
+package main
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+)
+
+const (
+ cookieName = "todo_list"
+ tokenRandom = 32 // bytes of randomness in a visitor token
+ tokenMAC = 16 // bytes of truncated HMAC-SHA256 after them
+)
+
+// visitors hands out and recognises the anonymous list cookie. A token is
+// random bytes followed by a MAC over them, so the app recognises only
+// tokens it issued without writing anything when it issues one: a visitor
+// who only looks at the page costs no database write. The database never
+// sees a token, only its SHA-256 (the list key).
+type visitors struct {
+ key []byte
+ secure bool // add Secure to the cookie (served over https)
+}
+
+func (v *visitors) mac(random []byte) []byte {
+ m := hmac.New(sha256.New, v.key)
+ m.Write(random)
+ return m.Sum(nil)[:tokenMAC]
+}
+
+// listKey returns the caller's list key, issuing a fresh token (and so a
+// fresh, empty list) when the cookie is missing or not one of ours.
+func (v *visitors) listKey(w http.ResponseWriter, r *http.Request) []byte {
+ if c, err := r.Cookie(cookieName); err == nil {
+ raw, err := base64.RawURLEncoding.DecodeString(c.Value)
+ if err == nil && len(raw) == tokenRandom+tokenMAC &&
+ hmac.Equal(raw[tokenRandom:], v.mac(raw[:tokenRandom])) {
+ sum := sha256.Sum256(raw)
+ return sum[:]
+ }
+ }
+ raw := make([]byte, tokenRandom, tokenRandom+tokenMAC)
+ rand.Read(raw)
+ raw = append(raw, v.mac(raw)...)
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: base64.RawURLEncoding.EncodeToString(raw),
+ Path: "/",
+ MaxAge: 400 * 24 * 60 * 60,
+ HttpOnly: true,
+ Secure: v.secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+ sum := sha256.Sum256(raw)
+ return sum[:]
+}