carlos / todomvc-bakeoff Public
feat(go): server-rendered TodoMVC with progressive JavaScript
net/http + html/template + modernc.org/sqlite, one static binary. Every action is a form POST answered with 303; app.js sends the same POSTs with X-Todo-Fragment and swaps in the list HTML.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
17 files changed,
+2136
−0
apps/go/DEPS+1 −0apps/go/Makefile+53 −0apps/go/README.md+94 −0apps/go/go.mod+17 −0apps/go/go.sum+50 −0apps/go/internal/todo/store.go+205 −0apps/go/internal/todo/store_test.go+151 −0apps/go/main.go+118 −0apps/go/main_test.go+19 −0apps/go/web/static/app.css+22 −0apps/go/web/static/app.js+115 −0apps/go/web/static/base.css+141 −0apps/go/web/static/index.css+393 −0apps/go/web/static/nojs.css+51 −0apps/go/web/templates/page.html+83 −0apps/go/web/web.go+356 −0apps/go/web/web_test.go+267 −0
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..3e68811 |
| --- /dev/null |
| +++ b/apps/go/Makefile |
| @@ -0,0 +1,53 @@ |
| +# todo-go: the CARLOS bakeoff's TodoMVC in Go. See README.md. |
| + |
| +VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev) |
| +LDFLAGS := -s -w -X main.version=$(VERSION) |
| +# make build-arm64 COLD=1 rebuilds every package, dependencies included, |
| +# without touching the shared GOCACHE. |
| +GOFLAGS_COLD := $(if $(COLD),-a,) |
| + |
| +.PHONY: build build-arm64 test clean clean-deps bump unbump deploy css-check |
| + |
| +build: dist/app |
| +build-arm64: dist/app-linux-arm64 |
| + |
| +# Both binaries are always handed to `go build`, which decides from its own |
| +# cache what needs compiling, so a new VERSION or COLD=1 always takes effect. |
| +dist/app: FORCE |
| + CGO_ENABLED=0 go build $(GOFLAGS_COLD) -trimpath -ldflags '$(LDFLAGS)' -o $@ . |
| + |
| +dist/app-linux-arm64: FORCE |
| + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(GOFLAGS_COLD) -trimpath -ldflags '$(LDFLAGS)' -o $@ . |
| + |
| +FORCE: |
| + |
| +test: css-check |
| + go vet ./... |
| + CGO_ENABLED=0 go test ./... |
| + |
| +# The served stylesheets must stay byte-for-byte copies of shared/. |
| +css-check: |
| + cmp ../../shared/base.css web/static/base.css |
| + cmp ../../shared/index.css web/static/index.css |
| + |
| +clean: |
| + rm -rf dist |
| + |
| +# Go keeps compiled packages in the shared GOCACHE, which this repo never |
| +# changes or clears. Downloaded sources stay in the module cache; use |
| +# `make build-arm64 COLD=1` to force every package to recompile. |
| +clean-deps: clean |
| + |
| +bump: |
| + sed -i 's/^const bumpMarker = "steady"$$/const bumpMarker = "bumped"/' web/web.go |
| + grep -q '^const bumpMarker = "bumped"$$' web/web.go |
| + |
| +unbump: |
| + sed -i 's/^const bumpMarker = "bumped"$$/const bumpMarker = "steady"/' web/web.go |
| + grep -q '^const bumpMarker = "steady"$$' web/web.go |
| + |
| +# Ships the binary `make build-arm64` made; it doesn't rebuild. |
| +deploy: |
| + test -f dist/app-linux-arm64 || { echo 'run make build-arm64 first' >&2; exit 1; } |
| + carlos deploy --account bfj --app todo-go --target linux-arm64 --no-prompt \ |
| + --version $(VERSION) --message "$(or $(MESSAGE),Deploy todo-go $(VERSION))" dist/app-linux-arm64 |
diff --git a/apps/go/README.md b/apps/go/README.md| new file mode 100644 |
| index 0000000..54655b3 |
| --- /dev/null |
| +++ b/apps/go/README.md |
| @@ -0,0 +1,94 @@ |
| +# 🤖 todo-go |
| + |
| +TodoMVC for the CARLOS bakeoff, written in Go. It's live at |
| +[https://todo-go.bfj.oncarlos.com](https://todo-go.bfj.oncarlos.com). |
| + |
| +The server renders every page, and every change is a plain form POST, |
| +so the whole app works with JavaScript turned off. One small ES module |
| +(`web/static/app.js`) makes it behave like the reference TodoMVC when |
| +JavaScript is on: double-click to edit, Escape to cancel, and checkboxes |
| +that act without a page load. |
| + |
| +## The stack, and why |
| + |
| +| Piece | Choice | Why | |
| +|---|---|---| |
| +| HTTP server and routing | `net/http` from the standard library | Since Go 1.22 the standard `ServeMux` matches methods and path parameters (`POST /todos/{id}/toggle`), which is all this app needs. A router library would add a dependency and nothing else | |
| +| Templates | `html/template` | Escapes by context, so a todo titled `<img onerror=…>` shows as text. Templates and static files are embedded in the binary with `embed` | |
| +| SQLite | `modernc.org/sqlite` | A pure-Go translation of SQLite, so the binary builds with `CGO_ENABLED=0` and cross-compiles to linux-arm64 with no C toolchain. It's the driver the CARLOS family uses | |
| +| Database access | `database/sql`, hand-written SQL | Five short queries. Two pools: one connection for writes (SQLite allows one writer at a time, so writes queue in Go instead of fighting over the lock) and a few read-only connections | |
| +| Migrations | A Go slice of SQL strings, counted by `PRAGMA user_version` | Append-only by construction. No migration tool needed for one table | |
| +| JavaScript | One ES module, no framework, no build step | It sends the same form POSTs with an `X-Todo-Fragment` header and swaps in the HTML the server returns, so all rendering stays in one template | |
| +| Logging | `log/slog` | Standard library, structured, to stderr | |
| + |
| +The only third-party dependency is `modernc.org/sqlite` (see `DEPS`); the |
| +rest of `go.sum` is what it pulls in. |
| + |
| +## How it fits the platform |
| + |
| +- The binary is started as `todo-go --socket <path> --db <path>` and |
| + listens on that unix socket. For local runs it takes |
| + `--listen 127.0.0.1:<port>` instead, and refuses any address that |
| + isn't loopback. |
| +- It reads its hostname from `CARLOS_HOST`. With it set, POSTs must come |
| + from `https://$CARLOS_HOST` (or carry no `Origin` at all) and the |
| + cookie is marked `Secure`. |
| +- `GET /healthz` checks the database answers; `GET /api/version` returns |
| + the git short SHA baked in at build time. |
| +- SQLite runs in WAL mode in one file. Startup is a few milliseconds, |
| + and SIGTERM finishes in-flight requests, closes the database and |
| + exits 0. |
| + |
| +## Each visitor's list |
| + |
| +A visitor's first request sets a `todo_list` cookie holding 32 random |
| +bytes (`HttpOnly; SameSite=Lax; Path=/`, plus `Secure` on Carloku). The |
| +database keys each todo by the SHA-256 of that cookie, never the cookie |
| +itself. A cookie the app can't parse just gets a new one and an empty |
| +list. An id that isn't in your list answers 404, whoever it belongs to. |
| + |
| +## Honest trade-offs |
| + |
| +- **Trust class C (2026-10-23).** Todo titles are stored as plain text. |
| + The server, and anyone with a copy of the database or its replicas, |
| + can read them. That's a deliberate choice for a public demo. |
| +- A list lasts as long as its cookie. Clearing cookies loses it, and |
| + there's no way to carry a list to another browser. |
| + |
| +## Build, run, test, deploy |
| + |
| +Put the toolchain on `PATH` first: `source ../../scripts/env.sh`. |
| + |
| +| Command | What it does | |
| +|---|---| |
| +| `make build` | Builds for this machine into `dist/app` | |
| +| `make build-arm64` | Builds the static linux-arm64 binary into `dist/app-linux-arm64`. `COLD=1` passes `-a` to recompile every package | |
| +| `make test` | Checks the stylesheets still match `shared/`, then runs `go vet` and `go test` | |
| +| `make clean` | Removes `dist/` | |
| +| `make clean-deps` | The same as `clean`. Go keeps compiled packages in the shared `GOCACHE`, which this repo leaves alone; use `COLD=1` for a from-scratch build | |
| +| `make bump` / `make unbump` | Changes a constant in `web/web.go` (the file with the `GET /` handler) and puts it back | |
| +| `make deploy` | Ships `dist/app-linux-arm64` with `carlos deploy`. Set `MESSAGE="…"` to describe the change | |
| + |
| +Run it locally: |
| + |
| +```sh |
| +make build |
| +./dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/todo-go.db" |
| +``` |
| + |
| +Then run the shared behaviour suite against it from the repo root: |
| +`scripts/test-app.sh http://127.0.0.1:8080`. |
| + |
| +Before a deploy, `scripts/boot-check.sh dist/app-linux-arm64` starts the |
| +arm64 binary under qemu on a unix socket and checks it answers and exits |
| +cleanly. |
| + |
| +## Layout |
| + |
| +| Path | What | |
| +|---|---| |
| +| `main.go` | Flags, listener, signal handling | |
| +| `internal/todo/` | The SQLite store, its migrations, and its tests | |
| +| `web/web.go` | Routes, handlers, cookie and origin checks | |
| +| `web/templates/page.html` | The page, and the `list` fragment app.js swaps in | |
| +| `web/static/` | TodoMVC's CSS (copied byte for byte from `shared/`), `app.css`, `nojs.css` (loaded only inside `<noscript>`) and `app.js` | |
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/internal/todo/store.go b/apps/go/internal/todo/store.go| new file mode 100644 |
| index 0000000..f83ae5a |
| --- /dev/null |
| +++ b/apps/go/internal/todo/store.go |
| @@ -0,0 +1,205 @@ |
| +// Package todo keeps every visitor's todo list in one SQLite file. |
| +// |
| +// A list is identified by a ListKey: the SHA-256 of the visitor's cookie. |
| +// Every query that reads or changes a todo names the list, so an id that |
| +// belongs to someone else behaves exactly like an id that doesn't exist. |
| +package todo |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "net/url" |
| + "runtime" |
| + "strings" |
| + |
| + _ "modernc.org/sqlite" // registers the "sqlite" driver; pure Go, no cgo |
| +) |
| + |
| +// ErrNotFound means the id isn't in the caller's list. |
| +var ErrNotFound = errors.New("todo: not found") |
| + |
| +// ListKey names one visitor's list. |
| +type ListKey [sha256.Size]byte |
| + |
| +// KeyFor derives the list key from the visitor's secret cookie bytes. The |
| +// database only ever sees the hash, never the cookie. |
| +func KeyFor(secret []byte) ListKey { return sha256.Sum256(secret) } |
| + |
| +// Todo is one item in a list. |
| +type Todo struct { |
| + ID int64 |
| + Title string |
| + Completed bool |
| +} |
| + |
| +// migrations only ever grow. Each entry runs once, in order, and |
| +// PRAGMA user_version records how many have run. Never edit or remove |
| +// one that has shipped; append a new one. |
| +var migrations = []string{ |
| + `CREATE TABLE todos ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + list BLOB NOT NULL, |
| + title TEXT NOT NULL, |
| + completed INTEGER NOT NULL DEFAULT 0, |
| + created INTEGER NOT NULL DEFAULT (unixepoch()) |
| + ); |
| + CREATE INDEX todos_by_list ON todos (list, id);`, |
| +} |
| + |
| +// Store is the todo database: one connection for writes, a small pool |
| +// for reads. SQLite allows a single writer at a time, so funnelling |
| +// writes through one connection turns lock contention into a queue. |
| +type Store struct { |
| + w, r *sql.DB |
| +} |
| + |
| +// Open opens (creating if needed) the database at path in WAL mode and |
| +// brings its schema up to date. |
| +func Open(ctx context.Context, path string) (*Store, error) { |
| + // Every connection the pools open runs these pragmas first. |
| + dsn := func(pragmas ...string) string { |
| + q := url.Values{} |
| + for _, p := range append([]string{"busy_timeout(5000)", "synchronous(NORMAL)"}, pragmas...) { |
| + q.Add("_pragma", p) |
| + } |
| + return "file:" + path + "?" + q.Encode() |
| + } |
| + w, err := sql.Open("sqlite", dsn("journal_mode(WAL)")+"&_txlock=immediate") |
| + if err != nil { |
| + return nil, err |
| + } |
| + w.SetMaxOpenConns(1) |
| + r, err := sql.Open("sqlite", dsn("query_only(1)")) |
| + if err != nil { |
| + w.Close() |
| + return nil, err |
| + } |
| + r.SetMaxOpenConns(max(4, runtime.NumCPU())) |
| + s := &Store{w: w, r: r} |
| + if err := s.migrate(ctx); err != nil { |
| + s.Close() |
| + return nil, err |
| + } |
| + return s, nil |
| +} |
| + |
| +func (s *Store) migrate(ctx context.Context) error { |
| + tx, err := s.w.BeginTx(ctx, nil) |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + var have int |
| + if err := tx.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&have); err != nil { |
| + return err |
| + } |
| + if have > len(migrations) { |
| + return fmt.Errorf("todo: database is at schema %d, this build knows %d", have, len(migrations)) |
| + } |
| + for i := have; i < len(migrations); i++ { |
| + if _, err := tx.ExecContext(ctx, migrations[i]); err != nil { |
| + return fmt.Errorf("todo: migration %d: %w", i+1, err) |
| + } |
| + } |
| + if _, err := tx.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, len(migrations))); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| +// Close checkpoints and closes the database. |
| +func (s *Store) Close() error { |
| + return errors.Join(s.r.Close(), s.w.Close()) |
| +} |
| + |
| +// Ping checks that the database answers. |
| +func (s *Store) Ping(ctx context.Context) error { return s.r.PingContext(ctx) } |
| + |
| +// List returns every todo in the list, oldest first. |
| +func (s *Store) List(ctx context.Context, k ListKey) ([]Todo, error) { |
| + rows, err := s.r.QueryContext(ctx, |
| + `SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id`, k[:]) |
| + 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() |
| +} |
| + |
| +// CleanTitle trims a submitted title and replaces any invalid UTF-8. |
| +// An empty result means "no title". |
| +func CleanTitle(title string) string { |
| + return strings.TrimSpace(strings.ToValidUTF8(title, "�")) |
| +} |
| + |
| +// Add appends a todo. A title that is empty after cleaning adds nothing. |
| +func (s *Store) Add(ctx context.Context, k ListKey, title string) error { |
| + if title = CleanTitle(title); title == "" { |
| + return nil |
| + } |
| + _, err := s.w.ExecContext(ctx, `INSERT INTO todos (list, title) VALUES (?, ?)`, k[:], title) |
| + return err |
| +} |
| + |
| +// Toggle flips one todo between active and completed. |
| +func (s *Store) Toggle(ctx context.Context, k ListKey, id int64) error { |
| + return one(s.w.ExecContext(ctx, |
| + `UPDATE todos SET completed = 1 - completed WHERE id = ? AND list = ?`, id, k[:])) |
| +} |
| + |
| +// Rename sets a todo's title. A title that is empty after cleaning |
| +// deletes the todo instead, as TodoMVC does. |
| +func (s *Store) Rename(ctx context.Context, k ListKey, id int64, title string) error { |
| + if title = CleanTitle(title); title == "" { |
| + return s.Delete(ctx, k, id) |
| + } |
| + return one(s.w.ExecContext(ctx, |
| + `UPDATE todos SET title = ? WHERE id = ? AND list = ?`, title, id, k[:])) |
| +} |
| + |
| +// Delete removes one todo. |
| +func (s *Store) Delete(ctx context.Context, k ListKey, id int64) error { |
| + return one(s.w.ExecContext(ctx, `DELETE FROM todos WHERE id = ? AND list = ?`, id, k[:])) |
| +} |
| + |
| +// ToggleAll marks every todo completed, or, if every one already is, |
| +// marks them all active. |
| +func (s *Store) ToggleAll(ctx context.Context, k ListKey) error { |
| + _, err := s.w.ExecContext(ctx, |
| + `UPDATE todos |
| + SET completed = EXISTS (SELECT 1 FROM todos WHERE list = ?1 AND completed = 0) |
| + WHERE list = ?1`, k[:]) |
| + return err |
| +} |
| + |
| +// ClearCompleted deletes every completed todo. |
| +func (s *Store) ClearCompleted(ctx context.Context, k ListKey) error { |
| + _, err := s.w.ExecContext(ctx, `DELETE FROM todos WHERE list = ? AND completed = 1`, k[:]) |
| + return err |
| +} |
| + |
| +// one turns "no row matched" into ErrNotFound. |
| +func one(res sql.Result, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return ErrNotFound |
| + } |
| + return nil |
| +} |
diff --git a/apps/go/internal/todo/store_test.go b/apps/go/internal/todo/store_test.go| new file mode 100644 |
| index 0000000..e132a70 |
| --- /dev/null |
| +++ b/apps/go/internal/todo/store_test.go |
| @@ -0,0 +1,151 @@ |
| +package todo |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func open(t *testing.T) *Store { |
| + t.Helper() |
| + s, err := Open(context.Background(), filepath.Join(t.TempDir(), "todo.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { s.Close() }) |
| + return s |
| +} |
| + |
| +func titles(t *testing.T, s *Store, k ListKey) string { |
| + t.Helper() |
| + items, err := s.List(context.Background(), k) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var b strings.Builder |
| + for _, it := range items { |
| + if it.Completed { |
| + b.WriteString("x:") |
| + } |
| + b.WriteString(it.Title + ";") |
| + } |
| + return b.String() |
| +} |
| + |
| +func TestAddTrimsAndSkipsBlank(t *testing.T) { |
| + s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) |
| + for _, title := range []string{" one ", " ", "", "two"} { |
| + if err := s.Add(ctx, k, title); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got := titles(t, s, k); got != "one;two;" { |
| + t.Fatalf("got %q", got) |
| + } |
| +} |
| + |
| +func TestToggleAllAndClear(t *testing.T) { |
| + s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) |
| + for _, title := range []string{"a", "b", "c"} { |
| + s.Add(ctx, k, title) |
| + } |
| + items, _ := s.List(ctx, k) |
| + if err := s.Toggle(ctx, k, items[0].ID); err != nil { |
| + t.Fatal(err) |
| + } |
| + s.ToggleAll(ctx, k) // some active: all become completed |
| + if got := titles(t, s, k); got != "x:a;x:b;x:c;" { |
| + t.Fatalf("after first toggle-all: %q", got) |
| + } |
| + s.ToggleAll(ctx, k) // all completed: all become active |
| + if got := titles(t, s, k); got != "a;b;c;" { |
| + t.Fatalf("after second toggle-all: %q", got) |
| + } |
| + s.Toggle(ctx, k, items[1].ID) |
| + s.ClearCompleted(ctx, k) |
| + if got := titles(t, s, k); got != "a;c;" { |
| + t.Fatalf("after clear: %q", got) |
| + } |
| +} |
| + |
| +func TestRenameEmptyDeletes(t *testing.T) { |
| + s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) |
| + s.Add(ctx, k, "a") |
| + s.Add(ctx, k, "b") |
| + items, _ := s.List(ctx, k) |
| + if err := s.Rename(ctx, k, items[0].ID, " renamed "); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.Rename(ctx, k, items[1].ID, " "); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := titles(t, s, k); got != "renamed;" { |
| + t.Fatalf("got %q", got) |
| + } |
| +} |
| + |
| +// Another list's id must look exactly like a missing one, and change nothing. |
| +func TestListsAreIsolated(t *testing.T) { |
| + s, ctx := open(t), context.Background() |
| + mine, theirs := KeyFor([]byte("mine")), KeyFor([]byte("theirs")) |
| + s.Add(ctx, mine, "private") |
| + items, _ := s.List(ctx, mine) |
| + id := items[0].ID |
| + |
| + for name, err := range map[string]error{ |
| + "toggle": s.Toggle(ctx, theirs, id), |
| + "rename": s.Rename(ctx, theirs, id, "hacked"), |
| + "empty": s.Rename(ctx, theirs, id, ""), |
| + "delete": s.Delete(ctx, theirs, id), |
| + } { |
| + if !errors.Is(err, ErrNotFound) { |
| + t.Errorf("%s: got %v, want ErrNotFound", name, err) |
| + } |
| + } |
| + s.ToggleAll(ctx, theirs) |
| + s.ClearCompleted(ctx, theirs) |
| + if got := titles(t, s, mine); got != "private;" { |
| + t.Fatalf("my list changed: %q", got) |
| + } |
| + if got := titles(t, s, theirs); got != "" { |
| + t.Fatalf("their list sees: %q", got) |
| + } |
| +} |
| + |
| +func TestKeepsUnicodeAndLongTitles(t *testing.T) { |
| + s, ctx, k := open(t), context.Background(), KeyFor([]byte("a")) |
| + long := strings.Repeat("x", 10_000) |
| + s.Add(ctx, k, "naïve 🚀 日本語") |
| + s.Add(ctx, k, long) |
| + items, _ := s.List(ctx, k) |
| + if items[0].Title != "naïve 🚀 日本語" || items[1].Title != long { |
| + t.Fatalf("titles changed: %q, %d chars", items[0].Title, len(items[1].Title)) |
| + } |
| +} |
| + |
| +// Reopening an existing database runs no migration twice and keeps data. |
| +func TestReopenKeepsData(t *testing.T) { |
| + path := filepath.Join(t.TempDir(), "todo.db") |
| + ctx, k := context.Background(), KeyFor([]byte("a")) |
| + s, err := Open(ctx, path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s.Add(ctx, k, "kept") |
| + s.Close() |
| + s, err = Open(ctx, path) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer s.Close() |
| + if got := titles(t, s, k); got != "kept;" { |
| + t.Fatalf("got %q", got) |
| + } |
| + var mode string |
| + s.r.QueryRow(`PRAGMA journal_mode`).Scan(&mode) |
| + if mode != "wal" { |
| + t.Fatalf("journal_mode = %q, want wal", mode) |
| + } |
| +} |
diff --git a/apps/go/main.go b/apps/go/main.go| new file mode 100644 |
| index 0000000..f527963 |
| --- /dev/null |
| +++ b/apps/go/main.go |
| @@ -0,0 +1,118 @@ |
| +// Command todo-go is the CARLOS bakeoff's TodoMVC, in Go. |
| +// |
| +// todo-go --socket <path> --db <path> on Carloku |
| +// todo-go --listen 127.0.0.1:<port> --db <path> locally |
| +// |
| +// It serves until SIGTERM or SIGINT, then finishes in-flight requests, |
| +// closes the database and exits 0. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "os" |
| + "os/signal" |
| + "syscall" |
| + "time" |
| + |
| + "todo-go/internal/todo" |
| + "todo-go/web" |
| +) |
| + |
| +// version is the build version, set with -ldflags "-X main.version=<sha>". |
| +var version = "dev" |
| + |
| +func main() { |
| + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| + if err := run(os.Args[1:], log); err != nil { |
| + log.Error("exiting", "err", err) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +func run(args []string, log *slog.Logger) error { |
| + fset := flag.NewFlagSet("todo-go", flag.ContinueOnError) |
| + socket := fset.String("socket", "", "unix socket to listen on (Carloku)") |
| + listen := fset.String("listen", "", "127.0.0.1:<port> to listen on instead of a socket (local runs)") |
| + dbPath := fset.String("db", "", "SQLite database file") |
| + if err := fset.Parse(args); err != nil { |
| + return err |
| + } |
| + if *dbPath == "" || (*socket == "") == (*listen == "") { |
| + return errors.New("usage: todo-go (--socket <path> | --listen 127.0.0.1:<port>) --db <path>") |
| + } |
| + |
| + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) |
| + defer stop() |
| + |
| + cfg := web.Config{Version: version, Log: log} |
| + var ln net.Listener |
| + var err error |
| + if *socket != "" { |
| + // The platform hands us our hostname; never derive it from the socket. |
| + if host := os.Getenv("CARLOS_HOST"); host != "" { |
| + cfg.Origin = "https://" + host |
| + cfg.Secure = true |
| + } |
| + os.Remove(*socket) // a stale socket from a previous run |
| + ln, err = net.Listen("unix", *socket) |
| + } else { |
| + if err := checkLoopback(*listen); err != nil { |
| + return err |
| + } |
| + ln, err = net.Listen("tcp", *listen) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + if *listen != "" { |
| + cfg.Origin = "http://" + ln.Addr().String() // the real port, even for :0 |
| + } |
| + |
| + store, err := todo.Open(ctx, *dbPath) |
| + if err != nil { |
| + ln.Close() |
| + return err |
| + } |
| + defer store.Close() |
| + |
| + srv := &http.Server{ |
| + Handler: web.New(store, cfg), |
| + ReadHeaderTimeout: 10 * time.Second, |
| + ReadTimeout: 30 * time.Second, |
| + WriteTimeout: 30 * time.Second, |
| + IdleTimeout: 2 * time.Minute, |
| + ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), |
| + } |
| + served := make(chan error, 1) |
| + go func() { served <- srv.Serve(ln) }() |
| + log.Info("serving", "addr", ln.Addr().String(), "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) |
| +} |
| + |
| +// checkLoopback refuses any --listen address that isn't a loopback IP, |
| +// so a local run can never be reached from the network. |
| +func checkLoopback(addr string) error { |
| + host, _, err := net.SplitHostPort(addr) |
| + if err != nil { |
| + return fmt.Errorf("--listen %q: %w", addr, err) |
| + } |
| + if ip := net.ParseIP(host); ip == nil || !ip.IsLoopback() { |
| + return fmt.Errorf("--listen %q: only a loopback address such as 127.0.0.1 is allowed", addr) |
| + } |
| + return nil |
| +} |
diff --git a/apps/go/main_test.go b/apps/go/main_test.go| new file mode 100644 |
| index 0000000..b748bbb |
| --- /dev/null |
| +++ b/apps/go/main_test.go |
| @@ -0,0 +1,19 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestCheckLoopback(t *testing.T) { |
| + for addr, ok := range map[string]bool{ |
| + "127.0.0.1:8080": true, |
| + "[::1]:8080": true, |
| + "0.0.0.0:8080": false, |
| + ":8080": false, |
| + "10.0.0.5:8080": false, |
| + "localhost:8080": false, // a name could resolve anywhere |
| + "127.0.0.1": false, |
| + } { |
| + if err := checkLoopback(addr); (err == nil) != ok { |
| + t.Errorf("checkLoopback(%q) = %v, want ok=%v", addr, err, ok) |
| + } |
| + } |
| +} |
diff --git a/apps/go/web/static/app.css b/apps/go/web/static/app.css| new file mode 100644 |
| index 0000000..6c5cb33 |
| --- /dev/null |
| +++ b/apps/go/web/static/app.css |
| @@ -0,0 +1,22 @@ |
| +/* Layered on TodoMVC's own base.css and index.css. Nothing here changes how |
| + the page looks with JavaScript on; nojs.css, loaded only inside |
| + <noscript>, shows the plain-form controls. */ |
| + |
| +/* Forms wrap the controls but take no box of their own, so the markup |
| + lays out exactly like TodoMVC's template. */ |
| +form.inline { |
| + display: contents; |
| +} |
| + |
| +/* The submit buttons and edit link that drive the page without |
| + JavaScript. app.js drives the checkboxes and double-click instead. */ |
| +.toggle-submit, |
| +.toggle-all-submit, |
| +.edit-link { |
| + display: none; |
| +} |
| + |
| +/* Keyboard users can reach the delete button, not just mouse users. */ |
| +.todo-list li:focus-within .destroy { |
| + display: block; |
| +} |
diff --git a/apps/go/web/static/app.js b/apps/go/web/static/app.js| new file mode 100644 |
| index 0000000..7ad8bd6 |
| --- /dev/null |
| +++ b/apps/go/web/static/app.js |
| @@ -0,0 +1,115 @@ |
| +// Makes the server-rendered TodoMVC behave like the reference app. |
| +// |
| +// Every change is still the form POST the page uses with JavaScript off. |
| +// This module sends that same POST in the background with an |
| +// X-Todo-Fragment header, and the server answers with fresh HTML for the |
| +// list and footer instead of a redirect. The server stays the only place |
| +// that decides what the page shows. |
| + |
| +const app = document.querySelector('.todoapp'); |
| +const newTodo = app.querySelector('.new-todo'); |
| + |
| +// Requests run one at a time, so their answers land in the order they |
| +// were made. |
| +let queue = Promise.resolve(); |
| + |
| +function send(form) { |
| + const body = new URLSearchParams(new FormData(form)); |
| + const url = form.action; |
| + queue = queue |
| + .then(async () => { |
| + const res = await fetch(url, { |
| + method: 'POST', |
| + body, |
| + headers: { 'X-Todo-Fragment': '1' }, |
| + }); |
| + if (!res.ok) throw new Error(`${res.status} from ${url}`); |
| + swap(await res.text()); |
| + }) |
| + .catch((err) => { |
| + // Something is out of step (an expired list, a lost connection). |
| + // The server's page is the truth, so load it. |
| + console.error(err); |
| + location.reload(); |
| + }); |
| +} |
| + |
| +// Replaces section.main and footer.footer with the server's HTML, keeping |
| +// keyboard focus on the same control where it still exists. |
| +function swap(html) { |
| + const again = focusSelector(document.activeElement); |
| + for (const el of app.querySelectorAll(':scope > .main, :scope > .footer')) el.remove(); |
| + const t = document.createElement('template'); |
| + t.innerHTML = html; |
| + app.append(t.content); |
| + if (again) app.querySelector(again)?.focus(); |
| +} |
| + |
| +function focusSelector(el) { |
| + if (!el || !app.contains(el) || el === newTodo) return null; |
| + if (el.id) return `#${CSS.escape(el.id)}`; |
| + const li = el.closest('li[data-id]'); |
| + if (li && el.classList.length) { |
| + return `li[data-id="${CSS.escape(li.dataset.id)}"] .${CSS.escape(el.classList[0])}`; |
| + } |
| + return null; |
| +} |
| + |
| +// Adding, deleting, clearing completed, and saving an edit with Enter all |
| +// arrive here as ordinary form submissions. |
| +app.addEventListener('submit', (e) => { |
| + const form = e.target; |
| + e.preventDefault(); |
| + const edit = form.querySelector('.edit'); |
| + if (edit) { |
| + saveEdit(edit); |
| + return; |
| + } |
| + send(form); |
| + if (form.contains(newTodo)) newTodo.value = ''; |
| +}); |
| + |
| +// The checkboxes have no submit button of their own: a change submits the |
| +// form they sit in. |
| +app.addEventListener('change', (e) => { |
| + if (e.target.matches('.toggle, .toggle-all')) send(e.target.form); |
| +}); |
| + |
| +// Double-click a title to edit it. |
| +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'); |
| + li.classList.add('editing'); |
| + input.focus(); |
| + input.setSelectionRange(input.value.length, input.value.length); |
| +}); |
| + |
| +// Escape cancels the edit; leaving the field saves it. |
| +app.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || !e.target.matches('.edit')) return; |
| + const input = e.target; |
| + input.value = input.defaultValue; |
| + input.closest('li').classList.remove('editing'); |
| +}); |
| + |
| +app.addEventListener('focusout', (e) => { |
| + if (e.target.matches('.edit')) saveEdit(e.target); |
| +}); |
| + |
| +// Saves an edit once, however many ways it was finished (Enter, then the |
| +// blur that follows when the item is redrawn). |
| +function saveEdit(input) { |
| + const li = input.closest('li'); |
| + if (!li.classList.contains('editing') || li.dataset.saving) return; |
| + li.dataset.saving = '1'; |
| + if (input.value.trim() === input.defaultValue) { |
| + // Unchanged: nothing to send, just leave edit mode. |
| + input.value = input.defaultValue; |
| + li.classList.remove('editing'); |
| + delete li.dataset.saving; |
| + return; |
| + } |
| + send(input.form); |
| +} |
diff --git a/apps/go/web/static/base.css b/apps/go/web/static/base.css| new file mode 100644 |
| index 0000000..da65968 |
| --- /dev/null |
| +++ b/apps/go/web/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/web/static/index.css b/apps/go/web/static/index.css| new file mode 100644 |
| index 0000000..2c0b4b6 |
| --- /dev/null |
| +++ b/apps/go/web/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/web/static/nojs.css b/apps/go/web/static/nojs.css| new file mode 100644 |
| index 0000000..c1971c4 |
| --- /dev/null |
| +++ b/apps/go/web/static/nojs.css |
| @@ -0,0 +1,51 @@ |
| +/* Loaded only when JavaScript is off (inside <noscript>). The checkboxes |
| + can't submit anything by themselves, so transparent submit buttons sit |
| + over them, and each item gets a visible "edit" link in place of |
| + double-click. */ |
| + |
| +.todo-list li .toggle { |
| + display: none; |
| +} |
| + |
| +.todo-list li .toggle-submit { |
| + display: block; |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| + cursor: pointer; |
| +} |
| + |
| +.toggle-all-submit { |
| + display: block; |
| + position: absolute; |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| + cursor: pointer; |
| +} |
| + |
| +.todo-list li label { |
| + padding-right: 100px; |
| +} |
| + |
| +.todo-list li .edit-link { |
| + display: block; |
| + position: absolute; |
| + top: 0; |
| + bottom: 0; |
| + right: 55px; |
| + height: 20px; |
| + margin: auto 0; |
| + font-size: 14px; |
| + line-height: 20px; |
| + color: #949494; |
| + text-decoration: none; |
| +} |
| + |
| +.todo-list li .edit-link:hover { |
| + color: #484848; |
| + text-decoration: underline; |
| +} |
diff --git a/apps/go/web/templates/page.html b/apps/go/web/templates/page.html| new file mode 100644 |
| index 0000000..e5bc3a2 |
| --- /dev/null |
| +++ b/apps/go/web/templates/page.html |
| @@ -0,0 +1,83 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| + <head> |
| + <meta charset="utf-8"> |
| + <meta name="viewport" content="width=device-width, initial-scale=1"> |
| + <title>TodoMVC</title> |
| + <link rel="stylesheet" href="/static/base.css?v={{.Version}}"> |
| + <link rel="stylesheet" href="/static/index.css?v={{.Version}}"> |
| + <link rel="stylesheet" href="/static/app.css?v={{.Version}}"> |
| + <noscript><link rel="stylesheet" href="/static/nojs.css?v={{.Version}}"></noscript> |
| + <script type="module" src="/static/app.js?v={{.Version}}"></script> |
| + </head> |
| + <body> |
| + <section class="todoapp"> |
| + <header class="header"> |
| + <h1>todos</h1> |
| + <form class="inline" method="post" action="/todos"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{{if not .EditID}} autofocus{{end}}> |
| + </form> |
| + </header> |
| +{{template "list" .}} |
| + </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> |
| +{{define "list"}}{{if .Total}} |
| + <section class="main"> |
| + <form class="inline" method="post" action="/todos/toggle-all"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <input id="toggle-all" class="toggle-all" type="checkbox"{{if not .Active}} checked{{end}}> |
| + <label for="toggle-all">Mark all as complete</label> |
| + <button class="toggle-all-submit" data-action="toggle-all" title="{{if .Active}}Mark all as complete{{else}}Mark all as active{{end}}"></button> |
| + </form> |
| + <ul class="todo-list"> |
| +{{- range .Items}} |
| + <li{{if .Completed}} class="completed{{if eq .ID $.EditID}} editing{{end}}"{{else if eq .ID $.EditID}} class="editing"{{end}} data-id="{{.ID}}"> |
| + <div class="view"> |
| + <form class="inline" method="post" action="/todos/{{.ID}}/toggle"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}> |
| + <label>{{.Title}}</label> |
| + <button class="toggle-submit" data-action="toggle" title="{{if .Completed}}Mark as active{{else}}Mark as complete{{end}}"></button> |
| + </form> |
| + <a class="edit-link" data-action="edit" href="{{$.Path}}?edit={{.ID}}">edit</a> |
| + <form class="inline" method="post" action="/todos/{{.ID}}/destroy"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <button class="destroy" aria-label="Delete"></button> |
| + </form> |
| + </div> |
| + <form class="inline" method="post" action="/todos/{{.ID}}/edit"> |
| + <input type="hidden" name="filter" value="{{$.Filter}}"> |
| + <input class="edit" name="title" value="{{.Title}}" aria-label="Edit todo"{{if eq .ID $.EditID}} autofocus{{end}}> |
| + </form> |
| + </li> |
| +{{- end}} |
| + </ul> |
| + </section> |
| + <footer class="footer"> |
| + <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> |
| +{{- if .Completed}} |
| + <form class="inline" method="post" action="/todos/clear-completed"> |
| + <input type="hidden" name="filter" value="{{.Filter}}"> |
| + <button class="clear-completed">Clear completed</button> |
| + </form> |
| +{{- end}} |
| + </footer> |
| +{{- end}}{{end}} |
diff --git a/apps/go/web/web.go b/apps/go/web/web.go| new file mode 100644 |
| index 0000000..6786935 |
| --- /dev/null |
| +++ b/apps/go/web/web.go |
| @@ -0,0 +1,356 @@ |
| +// Package web serves the TodoMVC pages and form actions. |
| +// |
| +// Every action is an HTML form POST answered with 303 See Other, so the |
| +// app works with JavaScript off. The page's JavaScript sends the same |
| +// POSTs with an X-Todo-Fragment header and gets the list's HTML back |
| +// instead of a redirect. |
| +package web |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "embed" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "errors" |
| + "html/template" |
| + "io/fs" |
| + "log/slog" |
| + "mime" |
| + "net/http" |
| + "path" |
| + "strconv" |
| + |
| + "todo-go/internal/todo" |
| +) |
| + |
| +// bumpMarker exists so `make bump` has a constant in this file to change, |
| +// forcing the next build to recompile it. It has no effect at run time. |
| +const bumpMarker = "steady" |
| + |
| +const ( |
| + cookieName = "todo_list" |
| + // Browsers cap cookie lifetimes at 400 days; the page view renews it. |
| + cookieMaxAge = 400 * 24 * 60 * 60 |
| + secretBytes = 32 // 256 bits of randomness per visitor |
| + fragmentHeader = "X-Todo-Fragment" |
| + maxFormBytes = 1 << 20 |
| +) |
| + |
| +//go:embed templates/page.html |
| +var templateFS embed.FS |
| + |
| +//go:embed static |
| +var staticFS embed.FS |
| + |
| +var page = template.Must(template.ParseFS(templateFS, "templates/page.html")) |
| + |
| +// Config is what a Server needs to know about where it runs. |
| +type Config struct { |
| + // Origin is this app's own origin, e.g. "https://todo-go.example". |
| + // A POST whose Origin header is set to anything else is refused. |
| + // Empty means "whatever origin the request's Host names". |
| + Origin string |
| + // Secure marks the list cookie Secure (the app is served over https). |
| + Secure bool |
| + // Version is the build version, served at /api/version. |
| + Version string |
| + Log *slog.Logger |
| +} |
| + |
| +// Server is the app's http.Handler. |
| +type Server struct { |
| + cfg Config |
| + store *todo.Store |
| + mux *http.ServeMux |
| + static map[string]asset |
| +} |
| + |
| +type asset struct { |
| + body []byte |
| + ctype string |
| + etag string |
| +} |
| + |
| +// New builds the handler. |
| +func New(store *todo.Store, cfg Config) *Server { |
| + if cfg.Log == nil { |
| + cfg.Log = slog.Default() |
| + } |
| + s := &Server{cfg: cfg, store: store, mux: http.NewServeMux(), static: loadStatic()} |
| + |
| + s.mux.HandleFunc("GET /{$}", s.page("all")) |
| + s.mux.HandleFunc("GET /active", s.page("active")) |
| + s.mux.HandleFunc("GET /completed", s.page("completed")) |
| + s.mux.HandleFunc("GET /static/{name}", s.serveStatic) |
| + s.mux.HandleFunc("GET /healthz", s.healthz) |
| + s.mux.HandleFunc("GET /api/version", s.version) |
| + |
| + s.mux.HandleFunc("POST /todos", s.action(func(r *http.Request, k todo.ListKey) error { |
| + return s.store.Add(r.Context(), k, r.PostFormValue("title")) |
| + })) |
| + s.mux.HandleFunc("POST /todos/toggle-all", s.action(func(r *http.Request, k todo.ListKey) error { |
| + return s.store.ToggleAll(r.Context(), k) |
| + })) |
| + s.mux.HandleFunc("POST /todos/clear-completed", s.action(func(r *http.Request, k todo.ListKey) error { |
| + return s.store.ClearCompleted(r.Context(), k) |
| + })) |
| + s.mux.HandleFunc("POST /todos/{id}/toggle", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| + return s.store.Toggle(ctx, k, id) |
| + })) |
| + s.mux.HandleFunc("POST /todos/{id}/edit", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| + return s.store.Rename(ctx, k, id, r.PostFormValue("title")) |
| + })) |
| + s.mux.HandleFunc("POST /todos/{id}/destroy", s.itemAction(func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error { |
| + return s.store.Delete(ctx, k, id) |
| + })) |
| + return s |
| +} |
| + |
| +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + h := w.Header() |
| + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'") |
| + h.Set("X-Content-Type-Options", "nosniff") |
| + h.Set("Referrer-Policy", "same-origin") |
| + s.mux.ServeHTTP(w, r) |
| +} |
| + |
| +// filterPath maps a filter name to its page. Anything unknown is "all". |
| +func filterPath(filter string) (string, string) { |
| + switch filter { |
| + case "active": |
| + return "active", "/active" |
| + case "completed": |
| + return "completed", "/completed" |
| + } |
| + return "all", "/" |
| +} |
| + |
| +// view is everything the page template shows. |
| +type view struct { |
| + Filter, Path string |
| + Items []todo.Todo // the items this filter shows |
| + Total int |
| + Active int |
| + Completed int |
| + EditID int64 |
| + Version string |
| +} |
| + |
| +func (s *Server) view(ctx context.Context, k todo.ListKey, filter string) (view, error) { |
| + all, err := s.store.List(ctx, k) |
| + if err != nil { |
| + return view{}, err |
| + } |
| + v := view{Version: s.cfg.Version, Total: len(all)} |
| + v.Filter, v.Path = filterPath(filter) |
| + for _, t := range all { |
| + if t.Completed { |
| + v.Completed++ |
| + } else { |
| + v.Active++ |
| + } |
| + if v.Filter == "all" || (v.Filter == "completed") == t.Completed { |
| + v.Items = append(v.Items, t) |
| + } |
| + } |
| + return v, nil |
| +} |
| + |
| +func (s *Server) page(filter string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + k := s.listKey(w, r, true) |
| + v, err := s.view(r.Context(), k, filter) |
| + if err != nil { |
| + s.fail(w, r, err) |
| + return |
| + } |
| + if id, err := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64); err == nil { |
| + v.EditID = id |
| + } |
| + s.render(w, r, "page.html", v) |
| + } |
| +} |
| + |
| +// render executes a template into a buffer first, so a failure can still |
| +// become a clean 500 and Content-Length is exact. |
| +func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, v view) { |
| + var buf bytes.Buffer |
| + if err := page.ExecuteTemplate(&buf, name, v); err != nil { |
| + s.fail(w, r, err) |
| + return |
| + } |
| + h := w.Header() |
| + h.Set("Content-Type", "text/html; charset=utf-8") |
| + h.Set("Content-Length", strconv.Itoa(buf.Len())) |
| + h.Set("Cache-Control", "no-store") |
| + w.Write(buf.Bytes()) |
| +} |
| + |
| +// action wraps a list-wide form POST: origin check, cookie, form parsing, |
| +// then a 303 back to the filter (or the list's HTML for app.js). |
| +func (s *Server) action(do func(r *http.Request, k todo.ListKey) error) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + if !s.sameOrigin(r) { |
| + http.Error(w, "cross-origin request refused", http.StatusForbidden) |
| + return |
| + } |
| + r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) |
| + if err := r.ParseForm(); err != nil { |
| + status := http.StatusBadRequest |
| + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { |
| + status = http.StatusRequestEntityTooLarge |
| + } |
| + http.Error(w, http.StatusText(status), status) |
| + return |
| + } |
| + k := s.listKey(w, r, false) |
| + if err := do(r, k); err != nil { |
| + if errors.Is(err, todo.ErrNotFound) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + s.fail(w, r, err) |
| + return |
| + } |
| + filter, to := filterPath(r.PostFormValue("filter")) |
| + if r.Header.Get(fragmentHeader) == "" { |
| + http.Redirect(w, r, to, http.StatusSeeOther) |
| + return |
| + } |
| + v, err := s.view(r.Context(), k, filter) |
| + if err != nil { |
| + s.fail(w, r, err) |
| + return |
| + } |
| + s.render(w, r, "list", v) |
| + } |
| +} |
| + |
| +// itemAction is action for a route naming one todo. An id that doesn't |
| +// parse is as unknown as one that isn't in the caller's list. |
| +func (s *Server) itemAction(do func(ctx context.Context, k todo.ListKey, id int64, r *http.Request) error) http.HandlerFunc { |
| + return s.action(func(r *http.Request, k todo.ListKey) error { |
| + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| + if err != nil { |
| + return todo.ErrNotFound |
| + } |
| + return do(r.Context(), k, id, r) |
| + }) |
| +} |
| + |
| +// sameOrigin allows a POST with no Origin header (the contract says so; |
| +// SameSite=Lax already keeps cross-site POSTs from carrying the cookie) |
| +// or one naming this app's own origin. |
| +func (s *Server) sameOrigin(r *http.Request) bool { |
| + origin := r.Header.Get("Origin") |
| + if origin == "" { |
| + return true |
| + } |
| + if s.cfg.Origin != "" { |
| + return origin == s.cfg.Origin |
| + } |
| + scheme := "http" |
| + if r.TLS != nil { |
| + scheme = "https" |
| + } |
| + return origin == scheme+"://"+r.Host |
| +} |
| + |
| +// listKey reads the visitor's list cookie. A missing or unrecognisable |
| +// cookie gets a fresh random one, which is a fresh empty list. When renew |
| +// is set, a valid cookie is sent again to push its expiry forward. |
| +func (s *Server) listKey(w http.ResponseWriter, r *http.Request, renew bool) todo.ListKey { |
| + if c, err := r.Cookie(cookieName); err == nil { |
| + secret, err := base64.RawURLEncoding.DecodeString(c.Value) |
| + if err == nil && len(secret) == secretBytes { |
| + if renew { |
| + s.setCookie(w, c.Value) |
| + } |
| + return todo.KeyFor(secret) |
| + } |
| + } |
| + secret := make([]byte, secretBytes) |
| + rand.Read(secret) // never fails (crypto/rand panics rather than return an error) |
| + s.setCookie(w, base64.RawURLEncoding.EncodeToString(secret)) |
| + return todo.KeyFor(secret) |
| +} |
| + |
| +func (s *Server) setCookie(w http.ResponseWriter, value string) { |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: cookieName, |
| + Value: value, |
| + Path: "/", |
| + MaxAge: cookieMaxAge, |
| + HttpOnly: true, |
| + Secure: s.cfg.Secure, |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| +} |
| + |
| +func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) { |
| + if errors.Is(err, context.Canceled) { |
| + return // the client went away |
| + } |
| + s.cfg.Log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err) |
| + http.Error(w, "Something went wrong. Please try again.", http.StatusInternalServerError) |
| +} |
| + |
| +func (s *Server) healthz(w http.ResponseWriter, r *http.Request) { |
| + if err := s.store.Ping(r.Context()); err != nil { |
| + s.fail(w, r, err) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| + w.Write([]byte("ok\n")) |
| +} |
| + |
| +func (s *Server) version(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "text/plain; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-store") |
| + w.Write([]byte(s.cfg.Version)) |
| +} |
| + |
| +// loadStatic reads the embedded assets once, with an ETag for each. |
| +func loadStatic() map[string]asset { |
| + out := map[string]asset{} |
| + sub, _ := fs.Sub(staticFS, "static") |
| + fs.WalkDir(sub, ".", func(name string, d fs.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return err |
| + } |
| + body, err := fs.ReadFile(sub, name) |
| + if err != nil { |
| + return err |
| + } |
| + sum := sha256.Sum256(body) |
| + out[name] = asset{ |
| + body: body, |
| + ctype: mime.TypeByExtension(path.Ext(name)), |
| + etag: `"` + hex.EncodeToString(sum[:8]) + `"`, |
| + } |
| + return nil |
| + }) |
| + return out |
| +} |
| + |
| +func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) { |
| + a, ok := s.static[r.PathValue("name")] |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + h := w.Header() |
| + h.Set("Content-Type", a.ctype) |
| + h.Set("ETag", a.etag) |
| + // Pages link assets with ?v=<build>, so a new build fetches afresh. |
| + h.Set("Cache-Control", "public, max-age=86400") |
| + if r.Header.Get("If-None-Match") == a.etag { |
| + w.WriteHeader(http.StatusNotModified) |
| + return |
| + } |
| + h.Set("Content-Length", strconv.Itoa(len(a.body))) |
| + w.Write(a.body) |
| +} |
diff --git a/apps/go/web/web_test.go b/apps/go/web/web_test.go| new file mode 100644 |
| index 0000000..f13b2d0 |
| --- /dev/null |
| +++ b/apps/go/web/web_test.go |
| @@ -0,0 +1,267 @@ |
| +package web |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "net/http/cookiejar" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "todo-go/internal/todo" |
| +) |
| + |
| +// client is one visitor: a cookie jar that doesn't follow redirects. |
| +type client struct { |
| + t *testing.T |
| + srv *httptest.Server |
| + hc *http.Client |
| +} |
| + |
| +func newServer(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + store, err := todo.Open(context.Background(), filepath.Join(t.TempDir(), "todo.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { store.Close() }) |
| + srv := httptest.NewUnstartedServer(nil) |
| + srv.Config.Handler = New(store, Config{Origin: "http://" + srv.Listener.Addr().String(), Version: "test"}) |
| + srv.Start() |
| + t.Cleanup(srv.Close) |
| + return srv |
| +} |
| + |
| +func visitor(t *testing.T, srv *httptest.Server) *client { |
| + jar, _ := cookiejar.New(nil) |
| + hc := &http.Client{Jar: jar, CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return http.ErrUseLastResponse |
| + }} |
| + return &client{t: t, srv: srv, hc: hc} |
| +} |
| + |
| +func (c *client) get(path string) (*http.Response, string) { |
| + c.t.Helper() |
| + res, err := c.hc.Get(c.srv.URL + path) |
| + if err != nil { |
| + c.t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + var b bytes.Buffer |
| + b.ReadFrom(res.Body) |
| + return res, b.String() |
| +} |
| + |
| +func (c *client) post(path string, form url.Values, header map[string]string) (*http.Response, string) { |
| + c.t.Helper() |
| + req, _ := http.NewRequest("POST", c.srv.URL+path, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + for k, v := range header { |
| + req.Header.Set(k, v) |
| + } |
| + res, err := c.hc.Do(req) |
| + if err != nil { |
| + c.t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + var b bytes.Buffer |
| + b.ReadFrom(res.Body) |
| + return res, b.String() |
| +} |
| + |
| +var idRE = regexp.MustCompile(`data-id="(\d+)"`) |
| + |
| +func ids(body string) []string { |
| + var out []string |
| + for _, m := range idRE.FindAllStringSubmatch(body, -1) { |
| + out = append(out, m[1]) |
| + } |
| + return out |
| +} |
| + |
| +func TestActionsRedirectToTheirFilter(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} { |
| + res, _ := c.post("/todos", url.Values{"title": {"x"}, "filter": {filter}}, nil) |
| + if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != want { |
| + t.Errorf("filter %q: %d to %q, want 303 to %q", filter, res.StatusCode, res.Header.Get("Location"), want) |
| + } |
| + } |
| +} |
| + |
| +func TestPageShowsFilterAndCounts(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + _, body := c.get("/") |
| + if strings.Contains(body, `class="main"`) || strings.Contains(body, `class="footer"`) { |
| + t.Fatal("empty list should hide .main and .footer") |
| + } |
| + c.post("/todos", url.Values{"title": {"a"}}, nil) |
| + c.post("/todos", url.Values{"title": {"b"}}, nil) |
| + _, body = c.get("/") |
| + id := ids(body)[0] |
| + c.post("/todos/"+id+"/toggle", url.Values{"filter": {"all"}}, nil) |
| + |
| + _, body = c.get("/active") |
| + if got := ids(body); len(got) != 1 { |
| + t.Fatalf("/active shows %v", got) |
| + } |
| + for _, want := range []string{ |
| + `<strong>1</strong> item left`, |
| + `<a class="selected" href="/active">Active</a>`, |
| + `class="clear-completed"`, |
| + `href="/active?edit=`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("/active is missing %q", want) |
| + } |
| + } |
| + _, body = c.get("/completed?edit=" + id) |
| + if !strings.Contains(body, `class="completed editing" data-id="`+id+`"`) || |
| + !strings.Contains(body, `action="/todos/`+id+`/edit"`) { |
| + t.Errorf("edit view not rendered:\n%s", body) |
| + } |
| +} |
| + |
| +func TestEscapesTitles(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + c.post("/todos", url.Values{"title": {`<img src=x onerror=alert(1)> & "q"`}}, nil) |
| + _, body := c.get("/") |
| + if strings.Contains(body, "<img") { |
| + t.Fatal("title rendered as HTML") |
| + } |
| + if !strings.Contains(body, "<img src=x onerror=alert(1)> & "q"") { |
| + t.Fatal("escaped title not found") |
| + } |
| +} |
| + |
| +func TestContentLengthIsBytes(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + c.post("/todos", url.Values{"title": {"naïve 🚀 日本語"}}, nil) |
| + res, body := c.get("/") |
| + if n, _ := strconv.Atoi(res.Header.Get("Content-Length")); n != len(body) { |
| + t.Fatalf("Content-Length %d, body %d bytes", n, len(body)) |
| + } |
| +} |
| + |
| +func TestCrossOriginPostIsRefused(t *testing.T) { |
| + srv := newServer(t) |
| + c := visitor(t, srv) |
| + res, _ := c.post("/todos", url.Values{"title": {"evil"}}, map[string]string{"Origin": "https://evil.example"}) |
| + if res.StatusCode != http.StatusForbidden { |
| + t.Fatalf("got %d, want 403", res.StatusCode) |
| + } |
| + res, _ = c.post("/todos", url.Values{"title": {"mine"}}, map[string]string{"Origin": srv.URL}) |
| + if res.StatusCode != http.StatusSeeOther { |
| + t.Fatalf("own origin: got %d, want 303", res.StatusCode) |
| + } |
| + if _, body := c.get("/"); len(ids(body)) != 1 { |
| + t.Fatal("want exactly the one same-origin todo") |
| + } |
| +} |
| + |
| +func TestOtherListsIdsAre404(t *testing.T) { |
| + srv := newServer(t) |
| + alice, bob := visitor(t, srv), visitor(t, srv) |
| + alice.post("/todos", url.Values{"title": {"alice's"}}, nil) |
| + _, body := alice.get("/") |
| + id := ids(body)[0] |
| + bob.get("/") |
| + for _, action := range []string{"toggle", "edit", "destroy"} { |
| + res, _ := bob.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}}, nil) |
| + if res.StatusCode != http.StatusNotFound { |
| + t.Errorf("%s: got %d, want 404", action, res.StatusCode) |
| + } |
| + } |
| + for _, bad := range []string{"abc", "99999", "-1"} { |
| + if res, _ := alice.post("/todos/"+bad+"/toggle", nil, nil); res.StatusCode != http.StatusNotFound { |
| + t.Errorf("id %q: got %d, want 404", bad, res.StatusCode) |
| + } |
| + } |
| + if _, body := alice.get("/"); !strings.Contains(body, "alice's") || strings.Contains(body, `class="completed"`) { |
| + t.Fatal("alice's list changed") |
| + } |
| +} |
| + |
| +func TestGarbledCookieGetsFreshList(t *testing.T) { |
| + srv := newServer(t) |
| + for _, value := range []string{"%%%", "short", strings.Repeat("A", 43)} { |
| + req, _ := http.NewRequest("GET", srv.URL+"/", nil) |
| + req.Header.Set("Cookie", cookieName+"="+value) |
| + res, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res.Body.Close() |
| + if res.StatusCode != http.StatusOK { |
| + t.Errorf("cookie %q: status %d", value, res.StatusCode) |
| + } |
| + } |
| +} |
| + |
| +func TestCookieAttributes(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + res, _ := c.get("/") |
| + sc := res.Header.Get("Set-Cookie") |
| + for _, want := range []string{"HttpOnly", "SameSite=Lax", "Path=/"} { |
| + if !strings.Contains(sc, want) { |
| + t.Errorf("Set-Cookie %q lacks %s", sc, want) |
| + } |
| + } |
| + if strings.Contains(sc, "Secure") { |
| + t.Error("cookie is Secure over plain http") |
| + } |
| +} |
| + |
| +func TestFragmentForScripts(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + res, body := c.post("/todos", url.Values{"title": {"a"}, "filter": {"all"}}, map[string]string{fragmentHeader: "1"}) |
| + if res.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(body), `<section class="main">`) || |
| + strings.Contains(body, "<html") { |
| + t.Fatalf("got %d:\n%s", res.StatusCode, body) |
| + } |
| +} |
| + |
| +// The contract wants TodoMVC's stylesheets byte for byte. |
| +func TestStaticCSSMatchesShared(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + for _, name := range []string{"base.css", "index.css"} { |
| + want, err := os.ReadFile(filepath.Join("..", "..", "..", "shared", name)) |
| + if err != nil { |
| + t.Skipf("shared/ not available: %v", err) |
| + } |
| + res, got := c.get("/static/" + name) |
| + if res.StatusCode != 200 || got != string(want) { |
| + t.Errorf("%s differs from shared/%s", name, name) |
| + } |
| + if !strings.HasPrefix(res.Header.Get("Content-Type"), "text/css") { |
| + t.Errorf("%s served as %q", name, res.Header.Get("Content-Type")) |
| + } |
| + } |
| +} |
| + |
| +func TestHealthAndVersion(t *testing.T) { |
| + c := visitor(t, newServer(t)) |
| + if res, _ := c.get("/healthz"); res.StatusCode != 200 { |
| + t.Errorf("/healthz: %d", res.StatusCode) |
| + } |
| + if res, body := c.get("/api/version"); res.StatusCode != 200 || body != "test" { |
| + t.Errorf("/api/version: %d %q", res.StatusCode, body) |
| + } |
| +} |
| + |
| +// AGENTS.md caps the page's JavaScript at 300 lines. |
| +func TestScriptStaysSmall(t *testing.T) { |
| + src, err := staticFS.ReadFile("static/app.js") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := bytes.Count(src, []byte("\n")); n > 300 { |
| + t.Fatalf("app.js is %d lines; the cap is 300", n) |
| + } |
| +} |