carlos / todomvc-bakeoff Public
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>
16 files changed,
+1863
−0
apps/go/.gitignore+5 −0apps/go/DEPS+1 −0apps/go/Makefile+34 −0apps/go/README.md+98 −0apps/go/app_test.go+312 −0apps/go/go.mod+17 −0apps/go/go.sum+50 −0apps/go/handlers.go+244 −0apps/go/main.go+110 −0apps/go/static/app.css+60 −0apps/go/static/app.js+74 −0apps/go/static/base.css+141 −0apps/go/static/index.css+393 −0apps/go/store.go+182 −0apps/go/templates/page.html+84 −0apps/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[:] |
| +} |