carlos / todomvc-bakeoff Public
feat: go-rastrillo builder output
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
31 files changed,
+2466
−0
apps/go-rastrillo/.rastrillo/budgets.txt+11 −0apps/go-rastrillo/AGENTS.md+34 −0apps/go-rastrillo/DEPS+3 −0apps/go-rastrillo/Makefile+57 −0apps/go-rastrillo/README.md+119 −0apps/go-rastrillo/STATUS.md+45 −0apps/go-rastrillo/cmd/todo/main.go+117 −0apps/go-rastrillo/cmd/todo/main_test.go+48 −0apps/go-rastrillo/go.mod+40 −0apps/go-rastrillo/go.sum+95 −0apps/go-rastrillo/internal/todo/app.go+117 −0apps/go-rastrillo/internal/todo/handlers.go+171 −0apps/go-rastrillo/internal/todo/migrations.go+22 −0apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql+6 −0apps/go-rastrillo/internal/todo/migrations/schema.sql+9 −0apps/go-rastrillo/internal/todo/models.go+31 −0apps/go-rastrillo/internal/todo/origin.go+27 −0apps/go-rastrillo/internal/todo/render.go+66 −0apps/go-rastrillo/internal/todo/static/app.css+22 −0apps/go-rastrillo/internal/todo/static/app.js+96 −0apps/go-rastrillo/internal/todo/static/base.css+141 −0apps/go-rastrillo/internal/todo/static/index.css+393 −0apps/go-rastrillo/internal/todo/static/nojs.css+53 −0apps/go-rastrillo/internal/todo/templates/errors.html+13 −0apps/go-rastrillo/internal/todo/templates/index.html+68 −0apps/go-rastrillo/internal/todo/templates/layout.html+22 −0apps/go-rastrillo/internal/todo/view.go+96 −0apps/go-rastrillo/internal/todo/visitor.go+39 −0apps/go-rastrillo/internal/todotest/harness_test.go+124 −0apps/go-rastrillo/internal/todotest/perf_test.go+74 −0apps/go-rastrillo/internal/todotest/todo_test.go+307 −0
diff --git a/apps/go-rastrillo/.rastrillo/budgets.txt b/apps/go-rastrillo/.rastrillo/budgets.txt| new file mode 100644 |
| index 0000000..d7dac99 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/.rastrillo/budgets.txt |
| @@ -0,0 +1,11 @@ |
| +# Exceptions to this app's budgets. Empty is the goal. |
| +# |
| +# Defaults: 5,000 source and 8,000 test lines per directory; 10s per |
| +# test package; 150ms to first byte per GET screen; 500ms cold boot. |
| +# A record exists only where something cannot meet them, and every |
| +# record says why. docs/site/testing.md has the full grammar. |
| +# |
| +# size <dir> <source>|- <test>|- <reason> |
| +# time <dir> <duration> <reason> |
| +# perf <METHOD> <path> <duration>|skip <reason> |
| +# boot <duration> <reason> |
diff --git a/apps/go-rastrillo/AGENTS.md b/apps/go-rastrillo/AGENTS.md| new file mode 100644 |
| index 0000000..636c04d |
| --- /dev/null |
| +++ b/apps/go-rastrillo/AGENTS.md |
| @@ -0,0 +1,34 @@ |
| +# 🤖 AGENTS.md: todo-go-rastrillo |
| + |
| +The one rule: **a visitor's list is reachable only through that |
| +visitor's own cookie.** Every read and every write of a todo goes |
| +through the `owned(r)` seam (`scope.Owned` on the session's subject), and |
| +a todo that isn't in the caller's list doesn't exist: 404, never 403. |
| + |
| +## Tenancy declaration (2026-10-23) |
| + |
| +- **One instance for the whole app.** Carloku app `todo-go-rastrillo`, |
| + host `todo-go-rastrillo.bfj.oncarlos.com`, channel `edge`. |
| +- **Each visitor's list is kept separately by an anonymous cookie.** The |
| + first request without a live session creates a `visitors` row and |
| + signs that browser in to it through Rastrillo's `sessions` (method |
| + `anonymous`): a 256-bit random token in an `HttpOnly; SameSite=Lax; |
| + Path=/` cookie, `Secure` and `__Host-` on https. Only the token's |
| + SHA-256 is stored. An unknown or garbled cookie gets a fresh, empty |
| + list. |
| +- **No sign-in.** There are no accounts, no email, no passwords. Losing |
| + the cookie loses the list. |
| +- **Trust class C.** Todo titles are stored as plain text in the SQLite |
| + file and the server can read them. That's chosen: it's a demo app |
| + holding to-do items, and it's written down here and in the README. |
| + |
| +## Working rules |
| + |
| +- The binding spec is the repo's `BRIEF.md` and `CONTRACT.md`. The UI is |
| + TodoMVC's markup and CSS, not Rastrillo's design system. |
| +- Cross-site protection is the contract's Origin check (`origin.go`), |
| + not `csrf.Protect` alone: a POST with no `Origin` header is allowed. |
| +- Migrations only add. Never edit a shipped file in |
| + `internal/todo/migrations/`; add a new one. |
| +- `make test` before every commit; the shared suite before every deploy. |
| +- Never bind anything other than `127.0.0.1` locally. |
diff --git a/apps/go-rastrillo/DEPS b/apps/go-rastrillo/DEPS| new file mode 100644 |
| index 0000000..809ab70 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/DEPS |
| @@ -0,0 +1,3 @@ |
| +amadan.net/rastrillo/rastrillo |
| +github.com/go-chi/chi/v5 |
| +gorm.io/gorm |
diff --git a/apps/go-rastrillo/Makefile b/apps/go-rastrillo/Makefile| new file mode 100644 |
| index 0000000..49c8a3f |
| --- /dev/null |
| +++ b/apps/go-rastrillo/Makefile |
| @@ -0,0 +1,57 @@ |
| +# Go + Rastrillo TodoMVC. See README.md. |
| +# |
| +# GOCACHE stays at its default (a shared machine rule): COLD=1 forces a |
| +# from-scratch compile with -a instead of deleting the cache. |
| + |
| +APP := todo-go-rastrillo |
| +ACCOUNT := bfj |
| +VERSION ?= $(shell git rev-parse --short HEAD) |
| +LDFLAGS := -s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)' |
| +COLDFLAG := $(if $(COLD),-a,) |
| +RASTRILLO := go run amadan.net/rastrillo/rastrillo/cmd/rastrillo |
| +MESSAGE ?= Deploy $(VERSION) |
| + |
| +# The string constant make bump edits: in handlers.go, beside GET /. |
| +BUMP_FILE := internal/todo/handlers.go |
| +BUMP_FROM := const notFoundTitle = "That item is not in your list" |
| +BUMP_TO := const notFoundTitle = "That item is not in your list." |
| + |
| +export CGO_ENABLED = 0 |
| + |
| +.PHONY: build build-arm64 test perf migration-check clean clean-deps bump unbump deploy |
| + |
| +build: |
| + go build $(COLDFLAG) -trimpath -ldflags="$(LDFLAGS)" -o dist/app ./cmd/todo |
| + |
| +build-arm64: |
| + GOOS=linux GOARCH=arm64 go build $(COLDFLAG) -trimpath -ldflags="$(LDFLAGS)" -o dist/app-linux-arm64 ./cmd/todo |
| + |
| +# Rastrillo's test runner: go test plus each package's time budget. |
| +test: |
| + go vet ./... |
| + test -z "$$(gofmt -l .)" || { gofmt -l .; exit 1; } |
| + $(RASTRILLO) budget test ./... |
| + $(RASTRILLO) migration check |
| + |
| +# Every GET screen at 150ms to first byte, cold boot at 500ms. Serial |
| +# and uncached, because a measurement taken beside other work isn't one. |
| +perf: |
| + $(RASTRILLO) budget test -no-time -require TestPerfScreens,TestPerfBoot -tags perf -count=1 -p 1 -parallel 1 -run '^TestPerf' ./... |
| + |
| +clean: |
| + rm -rf dist |
| + |
| +# Go keeps compiled packages in the shared GOCACHE, which this target |
| +# leaves alone; `make build-arm64 COLD=1` recompiles everything with -a. |
| +clean-deps: clean |
| + |
| +bump: |
| + sed -i 's/^$(BUMP_FROM)$$/$(BUMP_TO)/' $(BUMP_FILE) |
| + grep -qxF '$(BUMP_TO)' $(BUMP_FILE) |
| + |
| +unbump: |
| + sed -i 's/^$(BUMP_TO)$$/$(BUMP_FROM)/' $(BUMP_FILE) |
| + grep -qxF '$(BUMP_FROM)' $(BUMP_FILE) |
| + |
| +deploy: |
| + carlos deploy --account $(ACCOUNT) --app $(APP) --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64 |
diff --git a/apps/go-rastrillo/README.md b/apps/go-rastrillo/README.md| new file mode 100644 |
| index 0000000..48a1b82 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/README.md |
| @@ -0,0 +1,119 @@ |
| +# TodoMVC: Go + Rastrillo |
| + |
| +The bakeoff's TodoMVC built the way Rastrillo v0.28 recommends: a |
| +server-rendered app that works with JavaScript off, plus one small ES |
| +module that makes it behave like the reference TodoMVC when JavaScript is |
| +on. Live at |
| +[https://todo-go-rastrillo.bfj.oncarlos.com](https://todo-go-rastrillo.bfj.oncarlos.com). |
| + |
| +## The stack, and why |
| + |
| +| Piece | Choice | Why | |
| +|---|---|---| |
| +| Framework | [Rastrillo](https://rastrillo.org) v0.28.0 | The CARLOS framework. `rastrillo.Resolve` + `Serve` speak the platform contract (unix socket, `/healthz`, `/api/version`, SIGTERM drain, baseline security headers), so the app doesn't hand-roll any of it | |
| +| Router | `github.com/go-chi/chi/v5` | Rastrillo's router: `net/http` handlers, middleware per group | |
| +| Data | GORM over `rastrillo/db` | Rastrillo's default data layer. `db.Open` sets up SQLite (modernc, no cgo) in WAL mode with one writer connection and a reader pool | |
| +| Migrations | `rastrillo/migrate`, generated by `rastrillo migration generate` | Ledgered, applied once at boot, additive only. `make test` runs `rastrillo migration check` | |
| +| Visitors | `rastrillo/sessions` | Each browser's list is an anonymous session (below) | |
| +| Scoping | `rastrillo/scope` | Every todo query goes through one `owned(r)` seam that adds `WHERE user_id = ?` | |
| +| Forms | `rastrillo/form` | `form.Parse` reads and trims each field by name; nothing is bound onto a model | |
| +| Templates | `html/template`, embedded | Titles are always escaped text | |
| +| Static files | `rastrillo.NewAssets` | Pages link fingerprinted, cache-forever URLs; the bare names (`/static/base.css`) are served too, byte for byte | |
| +| JavaScript | `static/app.js`, one ES module, no build step | Posts the page's own forms with `fetch` and swaps in the server's markup | |
| + |
| +The UI is TodoMVC's own markup and CSS (`base.css`, `index.css`), not |
| +Rastrillo's design system: the bakeoff requires it. `static/app.css` adds |
| +one rule that matters, `.todoapp form { display: contents }`, so the forms |
| +that make every action work without JavaScript don't change the layout. |
| + |
| +### How the anonymous lists work |
| + |
| +Rastrillo's `sessions` stores signed-in state as SQLite rows keyed by |
| +the SHA-256 of a 256-bit random cookie token. This app treats "being |
| +given a list" as the sign-in: the first request with no live session |
| +creates a `visitors` row and calls `sessions.SignIn` with that row's id as |
| +the subject (method `anonymous`). From then on `sessions.UserID(r)` is |
| +the visitor's id and `scope.Owned` filters on it, exactly as it would for |
| +a signed-in user. An unknown or garbled cookie resolves to no session, so |
| +that browser gets a fresh, empty list. The cookie is |
| +`HttpOnly; SameSite=Lax; Path=/`, and on https it's `Secure` with the |
| +`__Host-` prefix. Lists live 400 days from the first visit instead of |
| +sessions' default of 30. |
| + |
| +### Cross-site protection: the contract's Origin check, not `csrf.Protect` |
| + |
| +Rastrillo's `csrf.Protect` refuses any POST that carries none of |
| +`Sec-Fetch-Site`, `Origin` or `Referer`. The bakeoff's shared tests and |
| +load tests post exactly like that, and the contract allows it. Protect |
| +also trusts `Sec-Fetch-Site` ahead of `Origin`, so it would let through a |
| +request claiming `same-origin` that carries a foreign `Origin`. So |
| +`origin.go` does the contract's check instead: a POST whose `Origin` |
| +header is not exactly this app's origin gets 403, and a POST with no |
| +`Origin` header is allowed. There's no CSRF token, as the contract says. |
| + |
| +Form posts are capped at 1 MiB and parsed before any handler runs. A |
| +body that's too big gets 413 and a malformed one gets 400, and neither |
| +changes anything. |
| + |
| +### JavaScript on and off |
| + |
| +Every action is a plain form post that answers 303. With JavaScript off, |
| +`<noscript>` loads `static/nojs.css`, which shows the contract's submit |
| +controls (`[data-action="toggle"]`, `[data-action="toggle-all"]` and the |
| +`a[data-action="edit"]` link) as transparent buttons laid over the circle, |
| +the ❯ and the title. The page looks the same, and clicking where you'd |
| +click with JavaScript does the same thing through a page load. |
| + |
| +With JavaScript on, `app.js` sends the same forms with an |
| +`X-Todo-Fragment` header, and the server answers with the new contents |
| +of `section.todoapp` instead of a redirect. The checkboxes, double-click |
| +editing, Enter, blur and Escape behave as in TodoMVC. Without that header, |
| +nothing changes. |
| + |
| +## Build, run, test, deploy |
| + |
| +Go 1.25 or newer. On docs-t1, `source ../../scripts/env.sh` first. |
| + |
| +```sh |
| +make build # dist/app for this machine |
| +make build-arm64 # dist/app-linux-arm64, static, what ships |
| +make test # vet, gofmt, go test with Rastrillo's time budgets, migration check |
| +make perf # every GET screen under 150ms to first byte, cold boot under 500ms |
| +``` |
| + |
| +Run it locally. It refuses any TCP address not on `127.0.0.1`, and |
| +defaults to `127.0.0.1:8080`: |
| + |
| +```sh |
| +dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/go-rastrillo.db" |
| +../../scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/go-rastrillo-suite.json" |
| +``` |
| + |
| +On Carloku the platform starts it as `app --socket <path> --db <path>` and |
| +sets `CARLOS_HOST`; the app's origin is then `https://$CARLOS_HOST`. |
| +`--listen` is the bakeoff's name for Rastrillo's `--addr`, and `main.go` |
| +translates it before `rastrillo.Resolve` reads the arguments. |
| + |
| +```sh |
| +make build-arm64 |
| +../../scripts/boot-check.sh dist/app-linux-arm64 |
| +make deploy MESSAGE="What changed" |
| +``` |
| + |
| +The version `/api/version` reports is the git short SHA, stamped into |
| +`rastrillo.BuildVersion` at build time. |
| + |
| +`make clean` removes `dist/`. `make clean-deps` does the same, because |
| +Go keeps compiled dependencies in the shared `GOCACHE`, which stays |
| +untouched on this machine: `make build-arm64 COLD=1` rebuilds everything |
| +from scratch with `-a` instead. |
| + |
| +## Honest trade-offs |
| + |
| +- **Trust class C (2026-10-23).** Todo titles are stored as plain text in |
| + SQLite, and the server can read them. |
| +- **No sign-in.** A list belongs to a cookie. Clear it, or use another |
| + browser, and that list is gone for you. |
| +- **GORM costs some speed.** Rastrillo's own measurements put GORM reads |
| + at about twice the time of plain `database/sql`. Rastrillo recommends it |
| + for CRUD apps like this one, so this entry uses it. |
diff --git a/apps/go-rastrillo/STATUS.md b/apps/go-rastrillo/STATUS.md| new file mode 100644 |
| index 0000000..51edc78 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/STATUS.md |
| @@ -0,0 +1,45 @@ |
| +LIVE https://todo-go-rastrillo.bfj.oncarlos.com 1364616 |
| + |
| +## Checks (2026-10-23, after the review round) |
| + |
| +- `make test`: vet, gofmt, Go tests under Rastrillo's budgets, and |
| + `rastrillo migration check`, all green. `make perf` (first build): |
| + every GET screen under 1 ms median locally, cold boot 64 ms. |
| +- Shared suite locally on `dist/app`: 67 passed, 3 skipped (the suite's |
| + own skips: the visual check and blur/Escape editing with JS off). |
| +- `scripts/boot-check.sh dist/app-linux-arm64`: `/healthz` and |
| + `/api/version` 200, clean exit on SIGTERM. |
| +- `make deploy`: swapped on the live host; `/api/version` reports |
| + `1364616`. |
| +- Shared suite against the live host: 67 passed, 3 skipped. |
| + |
| +## Review findings fixed |
| + |
| +1. **Origin check.** `origin.go` no longer delegates to `csrf.Protect`. |
| + A POST whose `Origin` isn't exactly the app's origin gets 403, whatever |
| + `Sec-Fetch-Site` says. A new test sends a foreign `Origin` with |
| + `Sec-Fetch-Site: same-origin` and with `none`, and expects 403. Also |
| + checked by hand on the live host: 403. |
| +2. **Unread forms.** Every `/todos` post is capped at 1 MiB and parsed |
| + before a handler runs, as `form.Parse`'s docs recommend. Too big gets |
| + 413, malformed gets 400, and nothing changes. A new test posts an edit |
| + over 2 MiB and checks that the item survives. |
| +3. **Typing during an add.** `app.js` clears `.new-todo` when the add is |
| + sent. After the response, the swap keeps whatever is in the field |
| + (and the caret). Checked in Chromium with the add response delayed by |
| + 800 ms: text typed meanwhile was still there afterwards. |
| +4. **Loopback only.** `main.go` refuses any TCP address whose host isn't |
| + `127.0.0.1` (`0.0.0.0`, `:8080`, `[::]`, `localhost` and so on) and |
| + exits with an error. Unit-tested, and checked by hand with |
| + `--listen 0.0.0.0:18432`. |
| + |
| +## Review findings not fixed |
| + |
| +None. |
| + |
| +## Notes |
| + |
| +- Rastrillo's local flag is `--addr`. `main.go` translates the bakeoff's |
| + `--listen` to it before `rastrillo.Resolve`. |
| +- `carlos.Host()` isn't in v0.28.0 (it's on Rastrillo's main branch), so |
| + `main.go` reads `CARLOS_HOST` directly. |
diff --git a/apps/go-rastrillo/cmd/todo/main.go b/apps/go-rastrillo/cmd/todo/main.go| new file mode 100644 |
| index 0000000..bf9b165 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/cmd/todo/main.go |
| @@ -0,0 +1,117 @@ |
| +// Command todo is the bakeoff's Go + Rastrillo TodoMVC: resolve the |
| +// platform's activation argv, open the database, build the app, serve. |
| +// |
| +// todo --socket <path> --db <path> on Carloku |
| +// todo --listen 127.0.0.1:<port> --db <path> locally |
| +package main |
| + |
| +import ( |
| + "fmt" |
| + "log/slog" |
| + "net" |
| + "os" |
| + "strings" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + |
| + todo "todo/internal/todo" |
| +) |
| + |
| +// localAddr is where the app listens when it is given neither a socket |
| +// nor an address: loopback only, never every interface. |
| +const localAddr = "127.0.0.1:8080" |
| + |
| +func main() { |
| + // First, so perf's cold budget counts the whole start: a hibernated |
| + // instance pays all of it on someone's click. |
| + started := time.Now() |
| + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| + |
| + // The bakeoff's local flag is --listen; Rastrillo's is --addr. |
| + os.Args = listenAsAddr(os.Args) |
| + |
| + // Resolve, not Run: this app opens its own database handle via |
| + // db.Open, so Options.DBPath must be empty again by the Serve call |
| + // below, or Serve would open a second connection to the same file. |
| + opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "todo.db", Logger: logger}) |
| + if err != nil { |
| + logger.Error("resolve activation", "err", err) |
| + os.Exit(1) |
| + } |
| + if opts.Socket == "" && opts.Addr == "" && os.Getenv("LISTEN_FDS") == "" { |
| + opts.Addr = localAddr |
| + } |
| + if opts.Addr != "" { |
| + if err := loopbackOnly(opts.Addr); err != nil { |
| + logger.Error("listen address", "err", err) |
| + os.Exit(1) |
| + } |
| + } |
| + |
| + d, err := db.Open(opts.DBPath, logger) |
| + if err != nil { |
| + logger.Error("open database", "err", err) |
| + os.Exit(1) |
| + } |
| + defer d.Close() |
| + |
| + o := origin(opts.Addr) |
| + mux, err := todo.App(d, o, logger) |
| + if err != nil { |
| + logger.Error("build app", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + todo.Configure(&opts, mux, started) |
| + opts.DBPath = "" |
| + logger.Info("serving", "origin", o, "socket", opts.Socket, "addr", opts.Addr) |
| + if err := rastrillo.Serve(opts); err != nil { |
| + logger.Error("serve failed", "err", err) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// loopbackOnly refuses a TCP address that isn't on 127.0.0.1: the app |
| +// never listens on another interface ("0.0.0.0:8080" or ":8080" would |
| +// expose it to the whole network). |
| +func loopbackOnly(addr string) error { |
| + host, _, err := net.SplitHostPort(addr) |
| + if err != nil { |
| + return fmt.Errorf("%q: %w", addr, err) |
| + } |
| + if host != "127.0.0.1" { |
| + return fmt.Errorf("%q: only 127.0.0.1 may be listened on", addr) |
| + } |
| + return nil |
| +} |
| + |
| +// origin is the app's own origin: https://$CARLOS_HOST on Carloku, and |
| +// http://<listen address> when run locally. |
| +func origin(addr string) string { |
| + if host := os.Getenv("CARLOS_HOST"); host != "" { |
| + return "https://" + host |
| + } |
| + if host, port, err := net.SplitHostPort(addr); err == nil && host != "" { |
| + return "http://" + net.JoinHostPort(host, port) |
| + } |
| + return "http://" + localAddr |
| +} |
| + |
| +// listenAsAddr rewrites --listen (and -listen, --listen=x) to Rastrillo's |
| +// --addr, leaving every other argument alone. |
| +func listenAsAddr(args []string) []string { |
| + out := make([]string, 0, len(args)) |
| + for _, a := range args { |
| + name, value, hasValue := strings.Cut(a, "=") |
| + if name == "--listen" || name == "-listen" { |
| + a = "--addr" |
| + if hasValue { |
| + a += "=" + value |
| + } |
| + } |
| + out = append(out, a) |
| + } |
| + return out |
| +} |
diff --git a/apps/go-rastrillo/cmd/todo/main_test.go b/apps/go-rastrillo/cmd/todo/main_test.go| new file mode 100644 |
| index 0000000..aacebac |
| --- /dev/null |
| +++ b/apps/go-rastrillo/cmd/todo/main_test.go |
| @@ -0,0 +1,48 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "slices" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/budget" |
| +) |
| + |
| +func TestMain(m *testing.M) { os.Exit(budget.Main(m)) } |
| + |
| +func TestListenAsAddr(t *testing.T) { |
| + t.Parallel() |
| + got := listenAsAddr([]string{"app", "--listen", "127.0.0.1:1", "-listen=127.0.0.1:2", "--db", "x.db", "--socket", "s"}) |
| + want := []string{"app", "--addr", "127.0.0.1:1", "--addr=127.0.0.1:2", "--db", "x.db", "--socket", "s"} |
| + if !slices.Equal(got, want) { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestOrigin(t *testing.T) { |
| + t.Setenv("CARLOS_HOST", "") |
| + if got := origin("127.0.0.1:9000"); got != "http://127.0.0.1:9000" { |
| + t.Errorf("local origin %q", got) |
| + } |
| + t.Setenv("CARLOS_HOST", "todo-go-rastrillo.bfj.oncarlos.com") |
| + if got := origin(""); got != "https://todo-go-rastrillo.bfj.oncarlos.com" { |
| + t.Errorf("carloku origin %q", got) |
| + } |
| +} |
| + |
| +func TestLoopbackOnly(t *testing.T) { |
| + t.Parallel() |
| + for addr, ok := range map[string]bool{ |
| + "127.0.0.1:8080": true, |
| + "0.0.0.0:8080": false, |
| + ":8080": false, |
| + "[::]:8080": false, |
| + "localhost:8080": false, |
| + "10.0.0.5:8080": false, |
| + "127.0.0.1": false, |
| + } { |
| + if err := loopbackOnly(addr); (err == nil) != ok { |
| + t.Errorf("loopbackOnly(%q) = %v, want ok=%v", addr, err, ok) |
| + } |
| + } |
| +} |
diff --git a/apps/go-rastrillo/go.mod b/apps/go-rastrillo/go.mod| new file mode 100644 |
| index 0000000..5656330 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/go.mod |
| @@ -0,0 +1,40 @@ |
| +module todo |
| + |
| +go 1.25.0 |
| + |
| +require ( |
| + amadan.net/rastrillo/rastrillo v0.28.0 |
| + github.com/go-chi/chi/v5 v5.3.2 |
| + gorm.io/gorm v1.31.2 |
| +) |
| + |
| +require ( |
| + amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect |
| + github.com/BurntSushi/toml v1.6.0 // indirect |
| + github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/google/uuid v1.6.0 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/mattn/go-isatty v0.0.20 // indirect |
| + github.com/ncruces/go-strftime v1.0.0 // indirect |
| + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| + golang.org/x/mod v0.37.0 // indirect |
| + golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.39.0 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| + modernc.org/libc v1.74.1 // indirect |
| + modernc.org/mathutil v1.7.1 // indirect |
| + modernc.org/memory v1.11.0 // indirect |
| + modernc.org/sqlite v1.55.0 // indirect |
| +) |
| + |
| +// The CLI this app's own gate runs: make migration-check, and |
| +// rastrillo generate once the app declares manifest resources. It is |
| +// declared as a tool so that go mod tidy records the CLI's build |
| +// dependencies in go.sum alongside the app's own. Nothing an app |
| +// imports reaches, say, the TOML parser behind manifest, so without |
| +// this line tidy prunes it and the first make ci on a clean scaffold |
| +// dies with "missing go.sum entry for github.com/BurntSushi/toml" — |
| +// a dependency that is real at go run time and invisible at tidy |
| +// time. Deleting this line breaks the gate, not the app. |
| +tool amadan.net/rastrillo/rastrillo/cmd/rastrillo |
diff --git a/apps/go-rastrillo/go.sum b/apps/go-rastrillo/go.sum| new file mode 100644 |
| index 0000000..01ab5c5 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/go.sum |
| @@ -0,0 +1,95 @@ |
| +amadan.net/rastrillo/rastrillo v0.28.0 h1:pG7qsJ7x4+R7fMDlYa8ldKZNKQOYBNZzU6XQcEyzvXY= |
| +amadan.net/rastrillo/rastrillo v0.28.0/go.mod h1:WmC8TAIfqjAkSwmD+FCTqr7VuqAO5FaX03HlV49K608= |
| +amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM= |
| +amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U= |
| +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
| +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= |
| +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= |
| +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= |
| +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= |
| +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= |
| +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| +github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= |
| +github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= |
| +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= |
| +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= |
| +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= |
| +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= |
| +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= |
| +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| +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/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= |
| +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= |
| +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= |
| +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= |
| +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +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.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| +modernc.org/gc/v3 v3.1.4/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.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| +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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| +modernc.org/memory v1.11.0/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.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| +modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| +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-rastrillo/internal/todo/app.go b/apps/go-rastrillo/internal/todo/app.go| new file mode 100644 |
| index 0000000..cd1e244 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/app.go |
| @@ -0,0 +1,117 @@ |
| +package todo |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log/slog" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/perf" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// visitorTTL is how long an anonymous list lives from its first visit. |
| +// Sessions' default is 30 days, which suits a sign-in that can be |
| +// repeated; a list cookie that can't be got back should outlast that. |
| +const visitorTTL = 400 * 24 * time.Hour |
| + |
| +// App wires the whole app: schema, router, static files. origin is the |
| +// app's own external origin ("https://todo-go-rastrillo.bfj.oncarlos.com", |
| +// or "http://127.0.0.1:8080" locally): the Origin check's yardstick and |
| +// what decides the session cookie's Secure and __Host- attributes. |
| +func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| + r, err := Router(d, origin, logger) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return Mux(r), nil |
| +} |
| + |
| +// Router applies the schema and builds the routes. |
| +func Router(d *db.DB, origin string, logger *slog.Logger) (chi.Router, error) { |
| + migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| + if err != nil { |
| + return nil, err |
| + } |
| + logger.Info("migrate", "applied", migrated.Applied, |
| + "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| + |
| + writer, err := d.G.DB() |
| + if err != nil { |
| + return nil, err |
| + } |
| + sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, TTL: visitorTTL, Logger: logger}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + a := &app{db: d.G, sess: sess, logger: logger} |
| + |
| + r := chi.NewRouter() |
| + // App-wide, above every route: the contract's cross-site check. |
| + r.Use(originCheck(origin)) |
| + r.Use(a.visitor) |
| + r.Get("/", a.page(filterAll)) |
| + r.Get("/active", a.page(filterActive)) |
| + r.Get("/completed", a.page(filterCompleted)) |
| + r.Route("/todos", func(r chi.Router) { |
| + r.Use(parseForm) |
| + r.Post("/", a.create) |
| + r.Post("/toggle-all", a.toggleAll) |
| + r.Post("/clear-completed", a.clearCompleted) |
| + r.Post("/{id}/toggle", a.toggle) |
| + r.Post("/{id}/edit", a.edit) |
| + r.Post("/{id}/destroy", a.destroy) |
| + }) |
| + return r, nil |
| +} |
| + |
| +// Mux mounts the static files beside the router. The stylesheets are |
| +// served under their bare names too (/static/base.css), byte for byte, |
| +// which the contract requires; pages link the fingerprinted names. |
| +func Mux(r http.Handler) *http.ServeMux { |
| + mux := http.NewServeMux() |
| + mux.Handle("GET /static/", assets.Handler()) |
| + mux.Handle("/", r) |
| + return mux |
| +} |
| + |
| +// Configure is the one place the serving options are set, so main.go |
| +// and the perf test cannot drift apart. |
| +func Configure(opts *rastrillo.Options, mux *http.ServeMux, started time.Time) { |
| + opts.Mux = mux |
| + opts.ErrorPage = ErrorPage |
| + // Every page depends on the visitor's cookie, so there is nothing |
| + // worth prerendering, and a prerendered GET would mint a list. |
| + opts.NoSpeculationRules = true |
| + var rec perf.Recorder |
| + opts.Wrap = perf.Middleware(&rec, perf.Options{Started: started, Logger: opts.Logger}) |
| +} |
| + |
| +// parseForm caps a form post at 1 MiB (room for a title far past the |
| +// contract's 10,000 characters, and no room for a body meant to fill |
| +// the disk) and parses it before any handler runs, as form.Parse's own |
| +// docs ask. A body that is too big or malformed is refused here, so a |
| +// handler never mistakes an unread field for an empty one and changes |
| +// data on it: an over-long edit must not read as "delete". |
| +func parseForm(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) |
| + if err := r.ParseForm(); err != nil { |
| + var tooBig *http.MaxBytesError |
| + if errors.As(err, &tooBig) { |
| + errorPage(w, http.StatusRequestEntityTooLarge, "That is too long to save") |
| + return |
| + } |
| + errorPage(w, http.StatusBadRequest, "That form could not be read") |
| + return |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/handlers.go b/apps/go-rastrillo/internal/todo/handlers.go| new file mode 100644 |
| index 0000000..c66a10d |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/handlers.go |
| @@ -0,0 +1,171 @@ |
| +package todo |
| + |
| +import ( |
| + "log/slog" |
| + "net/http" |
| + "strconv" |
| + |
| + "github.com/go-chi/chi/v5" |
| + "gorm.io/gorm" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/form" |
| + "amadan.net/rastrillo/rastrillo/scope" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// notFoundTitle is the 404 page's heading: an item that isn't in your |
| +// list. (make bump edits this line.) |
| +const notFoundTitle = "That item is not in your list" |
| + |
| +// app holds what every handler needs. |
| +type app struct { |
| + db *gorm.DB |
| + sess *sessions.Sessions |
| + logger *slog.Logger |
| +} |
| + |
| +// owned is the one seam every todo query goes through: reads and |
| +// writes alike carry WHERE user_id = <this visitor>. |
| +func (a *app) owned(r *http.Request) *gorm.DB { |
| + uid, _ := sessions.UserID(r) |
| + return scope.Owned(a.db.WithContext(r.Context()), uid) |
| +} |
| + |
| +// page serves one filter's view of the list, with ?edit=<id> opening |
| +// that item's editor. |
| +func (a *app) page(f filter) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + var todos []Todo |
| + if err := a.owned(r).Order("id").Find(&todos).Error; err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + editing, _ := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64) |
| + w.Header().Set("Cache-Control", "no-store") |
| + render(w, "index", newView(todos, f, editing)) |
| + } |
| +} |
| + |
| +func (a *app) create(w http.ResponseWriter, r *http.Request) { |
| + p := form.Parse(r, form.Field{Name: "title"}) |
| + if title := p.String("title"); title != "" { |
| + uid, _ := sessions.UserID(r) |
| + if err := a.db.WithContext(r.Context()).Create(&Todo{UserID: uid, Title: title}).Error; err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + } |
| + a.done(w, r) |
| +} |
| + |
| +func (a *app) toggle(w http.ResponseWriter, r *http.Request) { |
| + id, ok := itemID(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + res := a.owned(r).Model(&Todo{}).Where("id = ?", id). |
| + Update("completed", gorm.Expr("NOT completed")) |
| + a.afterItemWrite(w, r, res) |
| +} |
| + |
| +func (a *app) edit(w http.ResponseWriter, r *http.Request) { |
| + id, ok := itemID(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + p := form.Parse(r, form.Field{Name: "title"}) |
| + title := p.String("title") |
| + var res *gorm.DB |
| + if title == "" { |
| + res = a.owned(r).Where("id = ?", id).Delete(&Todo{}) |
| + } else { |
| + res = a.owned(r).Model(&Todo{}).Where("id = ?", id).Update("title", title) |
| + } |
| + a.afterItemWrite(w, r, res) |
| +} |
| + |
| +func (a *app) destroy(w http.ResponseWriter, r *http.Request) { |
| + id, ok := itemID(r) |
| + if !ok { |
| + a.notFound(w, r) |
| + return |
| + } |
| + a.afterItemWrite(w, r, a.owned(r).Where("id = ?", id).Delete(&Todo{})) |
| +} |
| + |
| +// toggleAll completes every item, or reopens them all when every one |
| +// already was: one statement, so it can't race a concurrent toggle. |
| +func (a *app) toggleAll(w http.ResponseWriter, r *http.Request) { |
| + uid, _ := sessions.UserID(r) |
| + err := a.owned(r).Model(&Todo{}).Where("1 = 1").Update("completed", |
| + gorm.Expr("EXISTS (SELECT 1 FROM todos WHERE user_id = ? AND NOT completed)", uid)).Error |
| + if err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.done(w, r) |
| +} |
| + |
| +func (a *app) clearCompleted(w http.ResponseWriter, r *http.Request) { |
| + if err := a.owned(r).Where("completed").Delete(&Todo{}).Error; err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + a.done(w, r) |
| +} |
| + |
| +// afterItemWrite finishes a write to one item: an error is a 500, no |
| +// row touched means the id isn't in this visitor's list (404). |
| +func (a *app) afterItemWrite(w http.ResponseWriter, r *http.Request, res *gorm.DB) { |
| + switch { |
| + case res.Error != nil: |
| + a.fail(w, r, res.Error) |
| + case res.RowsAffected == 0: |
| + a.notFound(w, r) |
| + default: |
| + a.done(w, r) |
| + } |
| +} |
| + |
| +// done answers a successful action: 303 back to the filter the form |
| +// came from, or, for the page's own script, the list's new markup. |
| +func (a *app) done(w http.ResponseWriter, r *http.Request) { |
| + f := parseFilter(r.PostFormValue("filter")) |
| + if !wantsFragment(r) { |
| + http.Redirect(w, r, f.path(), http.StatusSeeOther) |
| + return |
| + } |
| + var todos []Todo |
| + if err := a.owned(r).Order("id").Find(&todos).Error; err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + w.Header().Set("Cache-Control", "no-store") |
| + render(w, "app", newView(todos, f, 0)) |
| +} |
| + |
| +func (a *app) notFound(w http.ResponseWriter, r *http.Request) { |
| + errorPage(w, http.StatusNotFound, notFoundTitle) |
| +} |
| + |
| +// fail logs err under a fresh ref and renders the 500 page carrying it. |
| +func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) { |
| + ref := rastrillo.NewRef() |
| + a.logger.Error("request failed", "ref", ref, "method", r.Method, "path", r.URL.Path, "err", err) |
| + ErrorPage(w, r, http.StatusInternalServerError, ref) |
| +} |
| + |
| +// itemID reads {id}. A malformed id is a row that doesn't exist. |
| +func itemID(r *http.Request) (int64, bool) { |
| + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| + return id, err == nil && id > 0 |
| +} |
| + |
| +// fragmentHeader is what the page's script sends to get the list's |
| +// markup back instead of a 303. Without it nothing changes. |
| +const fragmentHeader = "X-Todo-Fragment" |
| + |
| +func wantsFragment(r *http.Request) bool { return r.Header.Get(fragmentHeader) != "" } |
diff --git a/apps/go-rastrillo/internal/todo/migrations.go b/apps/go-rastrillo/internal/todo/migrations.go| new file mode 100644 |
| index 0000000..199b616 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/migrations.go |
| @@ -0,0 +1,22 @@ |
| +package todo |
| + |
| +import ( |
| + "embed" |
| + |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +//go:embed migrations/*.sql |
| +var migrationFS embed.FS |
| + |
| +// Schema is this app's own migrations, in order, and nothing else: it is |
| +// what `rastrillo migration generate` and `rastrillo migration check` |
| +// diff against Models. Migrations apply once at boot and are never |
| +// re-run or reversed; never edit one that has shipped, add a new one. |
| +var Schema = migrate.MustFromFS(migrationFS, "todo") |
| + |
| +// BootSchema is everything applied at boot: the sessions table first, |
| +// then the app's own. Subsystems go here, never in Schema, or check |
| +// would propose dropping their tables. |
| +var BootSchema = migrate.Merge(sessions.Schema, Schema) |
diff --git a/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql b/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql| new file mode 100644 |
| index 0000000..190a3ae |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `visitors` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime); |
| + |
| +CREATE TABLE `todos` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer NOT NULL,`title` text,`completed` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime); |
| + |
| +CREATE INDEX `idx_todos_user_id` ON `todos`(`user_id`); |
| + |
diff --git a/apps/go-rastrillo/internal/todo/migrations/schema.sql b/apps/go-rastrillo/internal/todo/migrations/schema.sql| new file mode 100644 |
| index 0000000..f239a44 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/migrations/schema.sql |
| @@ -0,0 +1,9 @@ |
| +-- Generated by rastrillo migration generate; DO NOT EDIT. |
| +-- The schema every migration in this directory adds up to. |
| + |
| +CREATE TABLE `todos` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer NOT NULL,`title` text,`completed` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime); |
| + |
| +CREATE TABLE `visitors` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime); |
| + |
| +CREATE INDEX `idx_todos_user_id` ON `todos`(`user_id`); |
| + |
diff --git a/apps/go-rastrillo/internal/todo/models.go b/apps/go-rastrillo/internal/todo/models.go| new file mode 100644 |
| index 0000000..926c709 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/models.go |
| @@ -0,0 +1,31 @@ |
| +package todo |
| + |
| +import "time" |
| + |
| +// Models is every model the schema generator manages. Keep it in step |
| +// with the structs below: `rastrillo migration generate` reads it to |
| +// work out what the database should look like, and |
| +// `rastrillo migration check` fails when it and the migrations disagree. |
| +var Models = []any{ |
| + &Visitor{}, |
| + &Todo{}, |
| +} |
| + |
| +// Visitor is one anonymous browser: the owner of one list. Its ID is |
| +// the session subject (visitor.go), so sessions.UserID returns it and |
| +// scope.Owned filters on it like any signed-in user's id. |
| +type Visitor struct { |
| + ID int64 |
| + CreatedAt time.Time |
| +} |
| + |
| +// Todo is one item in a visitor's list. UserID is the owning visitor: |
| +// Rastrillo's owner column, the one scope.Owned filters on. |
| +type Todo struct { |
| + ID int64 |
| + UserID int64 `gorm:"index;not null"` |
| + Title string |
| + Completed bool `gorm:"not null;default:false"` |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/origin.go b/apps/go-rastrillo/internal/todo/origin.go| new file mode 100644 |
| index 0000000..9a525d9 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/origin.go |
| @@ -0,0 +1,27 @@ |
| +package todo |
| + |
| +import "net/http" |
| + |
| +// originCheck is the contract's cross-site protection: a POST carrying |
| +// an Origin header that isn't exactly this app's own answers 403, and a |
| +// POST with no Origin header is allowed. |
| +// |
| +// It is deliberately not csrf.Protect. Protect refuses a POST that |
| +// carries none of Sec-Fetch-Site, Origin and Referer, which the |
| +// bakeoff's shared tests and load tests send and the contract allows; |
| +// and it trusts Sec-Fetch-Site ahead of Origin, so a request claiming |
| +// "same-origin" would pass with a foreign Origin. Here the Origin |
| +// header alone decides. |
| +func originCheck(origin string) func(http.Handler) http.Handler { |
| + return func(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions { |
| + if o := r.Header.Get("Origin"); o != "" && o != origin { |
| + http.Error(w, "cross-origin form submission refused", http.StatusForbidden) |
| + return |
| + } |
| + } |
| + next.ServeHTTP(w, r) |
| + }) |
| + } |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/render.go b/apps/go-rastrillo/internal/todo/render.go| new file mode 100644 |
| index 0000000..168962d |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/render.go |
| @@ -0,0 +1,66 @@ |
| +package todo |
| + |
| +import ( |
| + "bytes" |
| + "embed" |
| + "html/template" |
| + "net/http" |
| + "strconv" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| +) |
| + |
| +//go:embed templates static |
| +var appFS embed.FS |
| + |
| +// assets fingerprints static/: {{asset "static/index.css"}} returns a |
| +// content-hashed URL served immutable-cacheable, and the bare name |
| +// (/static/index.css) is served too, never long-cached. |
| +var assets = rastrillo.NewAssets(appFS) |
| + |
| +var templates = template.Must(template.New(""). |
| + Funcs(template.FuncMap{ |
| + "asset": assets.Path, |
| + "plural": func(n int) string { |
| + if n == 1 { |
| + return "item" |
| + } |
| + return "items" |
| + }, |
| + }). |
| + ParseFS(appFS, "templates/*.html")) |
| + |
| +// render executes a template into a buffer before anything touches the |
| +// wire, so a template error becomes a clean 500 and the response |
| +// carries an exact Content-Length. |
| +func render(w http.ResponseWriter, name string, data any) { |
| + renderStatus(w, http.StatusOK, name, data) |
| +} |
| + |
| +func renderStatus(w http.ResponseWriter, status int, name string, data any) { |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, name, data); err != nil { |
| + http.Error(w, "something went wrong", http.StatusInternalServerError) |
| + return |
| + } |
| + h := w.Header() |
| + h.Set("Content-Type", "text/html; charset=utf-8") |
| + h.Set("Content-Length", strconv.Itoa(buf.Len())) |
| + w.WriteHeader(status) |
| + buf.WriteTo(w) |
| +} |
| + |
| +// ErrorPage is the rastrillo.ErrorPageFunc Options.ErrorPage points at, |
| +// so a panic gets the same page a handled failure does: TodoMVC's frame |
| +// with the status and, for a 500, the ref the log line carries. |
| +func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + title := http.StatusText(status) |
| + if status == http.StatusInternalServerError { |
| + title = "Something went wrong on our side" |
| + } |
| + renderStatus(w, status, "errors", map[string]any{"Title": title, "Ref": ref}) |
| +} |
| + |
| +func errorPage(w http.ResponseWriter, status int, title string) { |
| + renderStatus(w, status, "errors", map[string]any{"Title": title}) |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/static/app.css b/apps/go-rastrillo/internal/todo/static/app.css| new file mode 100644 |
| index 0000000..064820c |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/static/app.css |
| @@ -0,0 +1,22 @@ |
| +/* The app's own layer over TodoMVC's base.css and index.css. */ |
| + |
| +/* Forms wrap the controls so every action works without JavaScript. |
| + display: contents takes the form boxes out of layout, so the page lays |
| + out exactly as TodoMVC's own template. */ |
| +.todoapp form { |
| + display: contents; |
| +} |
| + |
| +/* The controls a page without JavaScript needs. nojs.css, loaded inside |
| + <noscript>, shows them; with JavaScript the script drives the page's |
| + own checkboxes and double-click editing instead. */ |
| +.nojs { |
| + display: none; |
| +} |
| + |
| +.todoapp .error p { |
| + margin: 0; |
| + padding: 15px 15px 15px 60px; |
| + font-size: 18px; |
| + line-height: 1.4; |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/static/app.js b/apps/go-rastrillo/internal/todo/static/app.js| new file mode 100644 |
| index 0000000..824f41b |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/static/app.js |
| @@ -0,0 +1,96 @@ |
| +// TodoMVC behaviour on top of the server-rendered page. Every action is |
| +// still the page's own form post; this script sends it with fetch and |
| +// the X-Todo-Fragment header, and the server answers with the new |
| +// contents of section.todoapp instead of a 303. Without this script the |
| +// forms post normally and nothing here is needed. |
| + |
| +const app = document.querySelector('.todoapp'); |
| + |
| +// Posts run one at a time, in the order they were made, so a quick |
| +// toggle-then-delete can't land the other way round. |
| +let queue = Promise.resolve(); |
| + |
| +function send(form) { |
| + const body = new URLSearchParams(new FormData(form)); |
| + const newTodo = form.querySelector('.new-todo'); |
| + // TodoMVC clears the field once a todo is added. Clear it now, as the |
| + // post goes, so anything typed while it is in flight is kept. |
| + if (newTodo) newTodo.value = ''; |
| + queue = queue |
| + .then(() => fetch(form.action, { |
| + method: 'POST', |
| + body, |
| + headers: { 'X-Todo-Fragment': '1' }, |
| + credentials: 'same-origin', |
| + })) |
| + .then((res) => { |
| + if (!res.ok) throw new Error(`${res.status} from ${form.action}`); |
| + return res.text(); |
| + }) |
| + .then((html) => swap(html, newTodo !== null)) |
| + .catch(() => location.reload()); |
| +} |
| + |
| +// swap replaces the list with the server's markup, keeping whatever is |
| +// in the new-todo field and its focus. After an add the field keeps |
| +// focus, as TodoMVC does. |
| +function swap(html, addsTodo) { |
| + const input = app.querySelector('.new-todo'); |
| + const typing = input.value; |
| + const focused = addsTodo || document.activeElement === input; |
| + const [start, end] = [input.selectionStart, input.selectionEnd]; |
| + app.innerHTML = html; |
| + const next = app.querySelector('.new-todo'); |
| + if (typing) next.value = typing; |
| + if (focused) { |
| + next.focus(); |
| + if (typing) next.setSelectionRange(start, end); |
| + } |
| +} |
| + |
| +app.addEventListener('submit', (e) => { |
| + e.preventDefault(); |
| + send(e.target); |
| +}); |
| + |
| +// The checkboxes are the page's own controls with JavaScript on. |
| +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); |
| +}); |
| + |
| +// Enter saves (the edit form's own submit), Escape cancels. |
| +app.addEventListener('keydown', (e) => { |
| + if (!e.target.matches('.edit') || e.key !== 'Escape') return; |
| + const input = e.target; |
| + const li = input.closest('li'); |
| + input.value = input.defaultValue; |
| + li.classList.remove('editing'); |
| + input.blur(); |
| +}); |
| + |
| +// Leaving the editor saves it, unless Escape already closed it. |
| +app.addEventListener('focusout', (e) => { |
| + if (!e.target.matches('.edit')) return; |
| + const li = e.target.closest('li'); |
| + if (!li.classList.contains('editing') || li.dataset.saving) return; |
| + li.dataset.saving = '1'; |
| + send(e.target.form); |
| +}); |
| + |
| +// Enter's submit also blurs as the list is replaced; mark it so the |
| +// focusout above doesn't post the same edit twice. |
| +app.addEventListener('submit', (e) => { |
| + const li = e.target.closest('li'); |
| + if (li) li.dataset.saving = '1'; |
| +}, true); |
diff --git a/apps/go-rastrillo/internal/todo/static/base.css b/apps/go-rastrillo/internal/todo/static/base.css| new file mode 100644 |
| index 0000000..da65968 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/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-rastrillo/internal/todo/static/index.css b/apps/go-rastrillo/internal/todo/static/index.css| new file mode 100644 |
| index 0000000..2c0b4b6 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/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-rastrillo/internal/todo/static/nojs.css b/apps/go-rastrillo/internal/todo/static/nojs.css| new file mode 100644 |
| index 0000000..7d150c2 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/static/nojs.css |
| @@ -0,0 +1,53 @@ |
| +/* Only loaded when JavaScript is off (<noscript> in <head>). Each submit |
| + control sits transparently over the thing a person would click, so the |
| + page looks the same as with JavaScript and the click does the same |
| + thing: a form post and a page load. */ |
| +.todoapp .nojs { |
| + display: block; |
| + position: absolute; |
| + z-index: 1; |
| + margin: 0; |
| + padding: 0; |
| + border: 0; |
| + background: none; |
| + cursor: pointer; |
| +} |
| + |
| +.todoapp .nojs span { |
| + position: absolute; |
| + width: 1px; |
| + height: 1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| + white-space: nowrap; |
| +} |
| + |
| +/* Over the round checkbox at the start of the item. */ |
| +.todo-list li .toggle-submit { |
| + top: 0; |
| + bottom: 0; |
| + left: 0; |
| + width: 40px; |
| +} |
| + |
| +/* Over the ❯ beside the new-todo field. */ |
| +.main .toggle-all-submit { |
| + top: -65px; |
| + left: 0; |
| + width: 45px; |
| + height: 65px; |
| +} |
| + |
| +/* Over the title: a click opens the editor, as a double-click does with |
| + JavaScript. The delete button, later in the item, stays on top. */ |
| +.todo-list li .edit-link { |
| + top: 0; |
| + bottom: 0; |
| + left: 40px; |
| + right: 60px; |
| + cursor: text; |
| +} |
| + |
| +.todo-list li .destroy { |
| + z-index: 2; |
| +} |
diff --git a/apps/go-rastrillo/internal/todo/templates/errors.html b/apps/go-rastrillo/internal/todo/templates/errors.html| new file mode 100644 |
| index 0000000..49ff857 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/templates/errors.html |
| @@ -0,0 +1,13 @@ |
| +{{define "errors"}}{{template "head"}} <section class="todoapp"> |
| + <header class="header"> |
| + <h1>todos</h1> |
| + </header> |
| + <section class="main error"> |
| + <p>{{.Title}}</p> |
| +{{- if .Ref}} |
| + <p>Reference: <code>{{.Ref}}</code></p> |
| +{{- end}} |
| + <p><a href="/">Back to your list</a></p> |
| + </section> |
| + </section> |
| +{{template "foot"}}{{end}} |
diff --git a/apps/go-rastrillo/internal/todo/templates/index.html b/apps/go-rastrillo/internal/todo/templates/index.html| new file mode 100644 |
| index 0000000..64a3bdd |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/templates/index.html |
| @@ -0,0 +1,68 @@ |
| +{{define "index"}}{{template "head"}} <section class="todoapp">{{template "app" .}}</section> |
| +{{template "foot"}}{{end}} |
| + |
| +{{/* app is section.todoapp's contents: the whole page body, and what |
| + a fragment request gets back. Forms are display: contents (app.css), |
| + so the markup lays out exactly as TodoMVC's template. */}} |
| +{{define "app"}}{{$f := .Filter}} |
| + <header class="header"> |
| + <h1>todos</h1> |
| + <form method="post" action="/todos"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{{if not .Editing}} autofocus{{end}}> |
| + </form> |
| + </header> |
| +{{- if .Total}} |
| + <section class="main"> |
| + <form method="post" action="/todos/toggle-all"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <input id="toggle-all" class="toggle-all" type="checkbox"{{if .AllCompleted}} checked{{end}}> |
| + <label for="toggle-all">Mark all as complete</label> |
| + <button class="nojs toggle-all-submit" data-action="toggle-all"><span>Mark all as complete</span></button> |
| + </form> |
| + <ul class="todo-list"> |
| +{{- range .Items}} |
| + <li data-id="{{.ID}}"{{with .Class}} class="{{.}}"{{end}}> |
| + <div class="view"> |
| + <form method="post" action="/todos/{{.ID}}/toggle"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}> |
| + <label>{{.Title}}</label> |
| + <button class="nojs toggle-submit" data-action="toggle"><span>Toggle</span></button> |
| + </form> |
| + <a class="nojs edit-link" data-action="edit" href="{{.EditHref}}"><span>Edit</span></a> |
| + <form method="post" action="/todos/{{.ID}}/destroy"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <button class="destroy"></button> |
| + </form> |
| + </div> |
| + <form method="post" action="/todos/{{.ID}}/edit"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <input class="edit" name="title" value="{{.Title}}"{{if .Editing}} autofocus{{end}}> |
| + </form> |
| + </li> |
| +{{- end}} |
| + </ul> |
| + </section> |
| + <footer class="footer"> |
| + <span class="todo-count"><strong>{{.Left}}</strong> {{plural .Left}} left</span> |
| + <ul class="filters"> |
| + <li> |
| + <a{{if eq $f "all"}} class="selected"{{end}} href="/">All</a> |
| + </li> |
| + <li> |
| + <a{{if eq $f "active"}} class="selected"{{end}} href="/active">Active</a> |
| + </li> |
| + <li> |
| + <a{{if eq $f "completed"}} class="selected"{{end}} href="/completed">Completed</a> |
| + </li> |
| + </ul> |
| +{{- if .Completed}} |
| + <form method="post" action="/todos/clear-completed"> |
| + <input type="hidden" name="filter" value="{{$f}}"> |
| + <button class="clear-completed">Clear completed</button> |
| + </form> |
| +{{- end}} |
| + </footer> |
| +{{- end}} |
| + {{end}} |
diff --git a/apps/go-rastrillo/internal/todo/templates/layout.html b/apps/go-rastrillo/internal/todo/templates/layout.html| new file mode 100644 |
| index 0000000..030b166 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/templates/layout.html |
| @@ -0,0 +1,22 @@ |
| +{{define "head"}}<!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="{{asset "static/base.css"}}"> |
| + <link rel="stylesheet" href="{{asset "static/index.css"}}"> |
| + <link rel="stylesheet" href="{{asset "static/app.css"}}"> |
| + <noscript><link rel="stylesheet" href="{{asset "static/nojs.css"}}"></noscript> |
| + <script type="module" src="{{asset "static/app.js"}}"></script> |
| + </head> |
| + <body> |
| +{{end}} |
| +{{define "foot"}} <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 + Rastrillo</p> |
| + <p>Part of <a href="http://todomvc.com">TodoMVC</a></p> |
| + </footer> |
| + </body> |
| +</html> |
| +{{end}} |
diff --git a/apps/go-rastrillo/internal/todo/view.go b/apps/go-rastrillo/internal/todo/view.go| new file mode 100644 |
| index 0000000..8864e76 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/view.go |
| @@ -0,0 +1,96 @@ |
| +package todo |
| + |
| +import "strconv" |
| + |
| +// filter is which slice of the list a page shows. Its name is what forms |
| +// post back as `filter`; its path is where the 303 goes. |
| +type filter string |
| + |
| +const ( |
| + filterAll filter = "all" |
| + filterActive filter = "active" |
| + filterCompleted filter = "completed" |
| +) |
| + |
| +// parseFilter reads a posted filter. Missing or unknown is "all". |
| +func parseFilter(s string) filter { |
| + switch f := filter(s); f { |
| + case filterActive, filterCompleted: |
| + return f |
| + } |
| + return filterAll |
| +} |
| + |
| +func (f filter) path() string { |
| + if f == filterAll { |
| + return "/" |
| + } |
| + return "/" + string(f) |
| +} |
| + |
| +func (f filter) shows(t Todo) bool { |
| + switch f { |
| + case filterActive: |
| + return !t.Completed |
| + case filterCompleted: |
| + return t.Completed |
| + } |
| + return true |
| +} |
| + |
| +// view is everything the page template draws. |
| +type view struct { |
| + Filter filter |
| + Items []item |
| + Total int |
| + Left int |
| + Completed int |
| + Editing bool |
| +} |
| + |
| +type item struct { |
| + ID int64 |
| + Title string |
| + Completed bool |
| + Editing bool |
| + EditHref string |
| +} |
| + |
| +// newView sorts the list into what the page shows. Counts cover the |
| +// whole list whichever filter is on; editing is the item whose editor is |
| +// open, or 0. |
| +func newView(todos []Todo, f filter, editing int64) view { |
| + v := view{Filter: f, Total: len(todos)} |
| + for _, t := range todos { |
| + if t.Completed { |
| + v.Completed++ |
| + } else { |
| + v.Left++ |
| + } |
| + if !f.shows(t) { |
| + continue |
| + } |
| + it := item{ID: t.ID, Title: t.Title, Completed: t.Completed, |
| + Editing: t.ID == editing, |
| + EditHref: f.path() + "?edit=" + strconv.FormatInt(t.ID, 10)} |
| + v.Editing = v.Editing || it.Editing |
| + v.Items = append(v.Items, it) |
| + } |
| + return v |
| +} |
| + |
| +// Class is the li's class attribute: completed, editing, both or none. |
| +func (it item) Class() string { |
| + switch { |
| + case it.Completed && it.Editing: |
| + return "completed editing" |
| + case it.Completed: |
| + return "completed" |
| + case it.Editing: |
| + return "editing" |
| + } |
| + return "" |
| +} |
| + |
| +// AllCompleted is toggle-all's checked state. |
| +func (v view) AllCompleted() bool { return v.Total > 0 && v.Left == 0 } |
diff --git a/apps/go-rastrillo/internal/todo/visitor.go b/apps/go-rastrillo/internal/todo/visitor.go| new file mode 100644 |
| index 0000000..dd4abc1 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todo/visitor.go |
| @@ -0,0 +1,39 @@ |
| +package todo |
| + |
| +import ( |
| + "net/http" |
| + "strconv" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// visitorMethod is the session method recorded for an anonymous list: |
| +// nothing was verified, the browser was simply given a list. |
| +const visitorMethod = "anonymous" |
| + |
| +// visitor gives every request a list. A live session cookie names its |
| +// visitor; anything else (no cookie, an expired one, a value the store |
| +// has never seen, garbled or not) gets a fresh visitor row and a new |
| +// session, through the same sessions.SignIn an identity plugin calls. |
| +// After it, sessions.UserID(r) is always the visitor's id, so the |
| +// handlers' owned(r) seam works exactly as it does behind sess.Require. |
| +func (a *app) visitor(next http.Handler) http.Handler { |
| + return a.sess.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if _, ok := sessions.UserID(r); !ok { |
| + v := Visitor{} |
| + if err := a.db.WithContext(r.Context()).Create(&v).Error; err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + now := time.Now() |
| + s := sessions.Session{Subject: strconv.FormatInt(v.ID, 10), Method: visitorMethod, At: now} |
| + if err := a.sess.SignIn(w, r, s); err != nil { |
| + a.fail(w, r, err) |
| + return |
| + } |
| + r = sessions.WithSession(r, s) |
| + } |
| + next.ServeHTTP(w, r) |
| + })) |
| +} |
diff --git a/apps/go-rastrillo/internal/todotest/harness_test.go b/apps/go-rastrillo/internal/todotest/harness_test.go| new file mode 100644 |
| index 0000000..f505370 |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todotest/harness_test.go |
| @@ -0,0 +1,124 @@ |
| +// Package todotest tests the app from the outside: real HTTP requests |
| +// against the real router, wired exactly as cmd/todo/main.go wires it. |
| +package todotest |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/rastrillo/budget" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/dbtest" |
| + |
| + todo "todo/internal/todo" |
| +) |
| + |
| +// testOrigin is the origin newApp wires: the app's own. |
| +const testOrigin = "http://app.test" |
| + |
| +// schema is migrated once per test binary and copied for each test. |
| +var schema = dbtest.FromSet(todo.BootSchema) |
| + |
| +func TestMain(m *testing.M) { |
| + code := budget.Main(m) |
| + schema.Remove() |
| + os.Exit(code) |
| +} |
| + |
| +// newApp builds the whole app over its own copy of the migrated |
| +// database, exactly as main.go does. |
| +func newApp(t *testing.T) http.Handler { |
| + t.Helper() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(schema.Path(t), logger) |
| + if err != nil { |
| + t.Fatalf("db.Open: %v", err) |
| + } |
| + t.Cleanup(func() { d.Close() }) |
| + mux, err := todo.App(d, testOrigin, logger) |
| + if err != nil { |
| + t.Fatalf("App: %v", err) |
| + } |
| + return mux |
| +} |
| + |
| +// browser is one visitor: a cookie jar in front of the app. |
| +type browser struct { |
| + t *testing.T |
| + h http.Handler |
| + cookies map[string]*http.Cookie |
| +} |
| + |
| +func newBrowser(t *testing.T, h http.Handler) *browser { |
| + return &browser{t: t, h: h, cookies: map[string]*http.Cookie{}} |
| +} |
| + |
| +func (b *browser) do(req *http.Request) *httptest.ResponseRecorder { |
| + b.t.Helper() |
| + for _, c := range b.cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + b.h.ServeHTTP(rec, req) |
| + for _, c := range rec.Result().Cookies() { |
| + if c.MaxAge < 0 { |
| + delete(b.cookies, c.Name) |
| + } else { |
| + b.cookies[c.Name] = c |
| + } |
| + } |
| + return rec |
| +} |
| + |
| +func (b *browser) get(target string) *httptest.ResponseRecorder { |
| + b.t.Helper() |
| + return b.do(httptest.NewRequest(http.MethodGet, target, nil)) |
| +} |
| + |
| +// post sends a form the way the shared suite does: no Origin header. |
| +func (b *browser) post(target string, form url.Values, headers ...string) *httptest.ResponseRecorder { |
| + b.t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + for i := 0; i+1 < len(headers); i += 2 { |
| + req.Header.Set(headers[i], headers[i+1]) |
| + } |
| + return b.do(req) |
| +} |
| + |
| +// add posts a new todo and fails the test unless it 303s. |
| +func (b *browser) add(title string) { |
| + b.t.Helper() |
| + rec := b.post("/todos", url.Values{"title": {title}, "filter": {"all"}}) |
| + if rec.Code != http.StatusSeeOther { |
| + b.t.Fatalf("POST /todos: status %d, want 303", rec.Code) |
| + } |
| +} |
| + |
| +var itemRE = regexp.MustCompile(`(?s)<li data-id="(\d+)"(?: class="([^"]*)")?>.*?<label>(.*?)</label>`) |
| + |
| +type shown struct{ ID, Class, Title string } |
| + |
| +// items lists what a page shows, in order. |
| +func items(body string) []shown { |
| + var out []shown |
| + for _, m := range itemRE.FindAllStringSubmatch(body, -1) { |
| + out = append(out, shown{m[1], m[2], m[3]}) |
| + } |
| + return out |
| +} |
| + |
| +func titles(body string) []string { |
| + var out []string |
| + for _, it := range items(body) { |
| + out = append(out, it.Title) |
| + } |
| + return out |
| +} |
diff --git a/apps/go-rastrillo/internal/todotest/perf_test.go b/apps/go-rastrillo/internal/todotest/perf_test.go| new file mode 100644 |
| index 0000000..5952abc |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todotest/perf_test.go |
| @@ -0,0 +1,74 @@ |
| +//go:build perf |
| + |
| +package todotest |
| + |
| +import ( |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/perf/perftest" |
| + |
| + todo "todo/internal/todo" |
| +) |
| + |
| +// The perf lane: make perf, serial and uncached, failing only on CI. |
| +// Every GET screen the router serves is held to 150ms to first byte; a |
| +// new route fails here until it is measured or has a record in |
| +// .rastrillo/budgets.txt. Never t.Parallel() in this file: a perf test |
| +// sharing the machine measures its neighbours. |
| +func TestPerfScreens(t *testing.T) { |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(schema.Path(t), logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer d.Close() |
| + r, err := todo.Router(d, testOrigin, logger) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + opts := rastrillo.Options{Logger: logger} |
| + todo.Configure(&opts, todo.Mux(r), time.Now()) |
| + h, closeAll, err := rastrillo.Handler(opts) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer closeAll() |
| + // Screens behind sign-in need Signin; a {param} route needs a Paths |
| + // entry whose row this test seeds first. |
| + perftest.Screens(t, perftest.ScreenConfig{Routes: r, Handler: h}) |
| +} |
| + |
| +// A cold start, from exec to the first byte of "/", held to 500ms. |
| +func TestPerfBoot(t *testing.T) { |
| + perftest.Boot(t, perftest.BootConfig{ |
| + Prepare: schema.Path, |
| + Build: func(path string) (http.Handler, func(), error) { |
| + started := time.Now() |
| + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + d, err := db.Open(path, logger) |
| + if err != nil { |
| + return nil, nil, err |
| + } |
| + mux, err := todo.App(d, testOrigin, logger) |
| + if err != nil { |
| + d.Close() |
| + return nil, nil, err |
| + } |
| + opts := rastrillo.Options{Logger: logger} |
| + todo.Configure(&opts, mux, started) |
| + h, closeAll, err := rastrillo.Handler(opts) |
| + if err != nil { |
| + d.Close() |
| + return nil, nil, err |
| + } |
| + return h, func() { closeAll(); d.Close() }, nil |
| + }, |
| + Path: "/", |
| + }) |
| +} |
diff --git a/apps/go-rastrillo/internal/todotest/todo_test.go b/apps/go-rastrillo/internal/todotest/todo_test.go| new file mode 100644 |
| index 0000000..323847a |
| --- /dev/null |
| +++ b/apps/go-rastrillo/internal/todotest/todo_test.go |
| @@ -0,0 +1,307 @@ |
| +package todotest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestFirstVisitSetsListCookie(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + rec := b.get("/") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /: status %d", rec.Code) |
| + } |
| + cs := rec.Result().Cookies() |
| + if len(cs) != 1 { |
| + t.Fatalf("got %d cookies, want 1", len(cs)) |
| + } |
| + c := cs[0] |
| + if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" { |
| + t.Errorf("cookie %+v: want HttpOnly, SameSite=Lax, Path=/", c) |
| + } |
| + if len(c.Value) < 22 { // 128 bits in base64 |
| + t.Errorf("cookie value %q is too short to hold 128 random bits", c.Value) |
| + } |
| + // A second visit keeps the same list: no new cookie. |
| + if got := b.get("/").Result().Cookies(); len(got) != 0 { |
| + t.Errorf("second visit set cookies %v", got) |
| + } |
| +} |
| + |
| +func TestEmptyListHidesMainAndFooter(t *testing.T) { |
| + t.Parallel() |
| + body := newBrowser(t, newApp(t)).get("/").Body.String() |
| + for _, s := range []string{`class="main"`, `class="footer"`} { |
| + if strings.Contains(body, s) { |
| + t.Errorf("empty list renders %s", s) |
| + } |
| + } |
| + if !strings.Contains(body, `class="new-todo"`) { |
| + t.Error("page has no new-todo field") |
| + } |
| + if !strings.Contains(body, "the CARLOS bakeoff</a> · Go + Rastrillo") { |
| + t.Error("footer doesn't name the setup") |
| + } |
| +} |
| + |
| +func TestAddTrimsAndRedirectsToFilter(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} { |
| + rec := b.post("/todos", url.Values{"title": {" x "}, "filter": {filter}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want { |
| + t.Errorf("filter %q: %d to %q, want 303 to %q", filter, rec.Code, rec.Header().Get("Location"), want) |
| + } |
| + } |
| + b.add(" ") |
| + got := titles(b.get("/").Body.String()) |
| + if want := []string{"x", "x", "x", "x", "x"}; !slices.Equal(got, want) { |
| + t.Errorf("titles %q, want %q", got, want) |
| + } |
| +} |
| + |
| +func TestTitlesAreTextAndKeptExactly(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add(`<img src=x onerror=alert(1)> & "q"`) |
| + b.add("naïve 🚀 日本語") |
| + long := strings.Repeat("x", 10_000) |
| + b.add(long) |
| + rec := b.get("/") |
| + got := titles(rec.Body.String()) |
| + want := []string{"<img src=x onerror=alert(1)> & "q"", "naïve 🚀 日本語", long} |
| + if !slices.Equal(got, want) { |
| + t.Errorf("titles %q, want %q", got, want) |
| + } |
| + if n, _ := strconv.Atoi(rec.Header().Get("Content-Length")); n != rec.Body.Len() { |
| + t.Errorf("Content-Length %d, body %d bytes", n, rec.Body.Len()) |
| + } |
| +} |
| + |
| +func TestToggleEditDestroy(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add("one") |
| + b.add("two") |
| + id := items(b.get("/").Body.String())[0].ID |
| + |
| + rec := b.post("/todos/"+id+"/toggle", url.Values{"filter": {"active"}}) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/active" { |
| + t.Fatalf("toggle: %d to %q", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if got := titles(b.get("/active").Body.String()); !slices.Equal(got, []string{"two"}) { |
| + t.Errorf("active after toggle: %q", got) |
| + } |
| + if got := titles(b.get("/completed").Body.String()); !slices.Equal(got, []string{"one"}) { |
| + t.Errorf("completed after toggle: %q", got) |
| + } |
| + if !strings.Contains(b.get("/").Body.String(), "<strong>1</strong> item left") { |
| + t.Error("counter doesn't read 1 item left") |
| + } |
| + |
| + b.post("/todos/"+id+"/edit", url.Values{"title": {" uno "}, "filter": {"all"}}) |
| + if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"uno", "two"}) { |
| + t.Errorf("after edit: %q", got) |
| + } |
| + b.post("/todos/"+id+"/edit", url.Values{"title": {" "}, "filter": {"all"}}) |
| + if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"two"}) { |
| + t.Errorf("empty edit should delete: %q", got) |
| + } |
| + |
| + id2 := items(b.get("/").Body.String())[0].ID |
| + if rec := b.post("/todos/"+id2+"/destroy", url.Values{"filter": {"all"}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("destroy: %d", rec.Code) |
| + } |
| + if got := titles(b.get("/").Body.String()); len(got) != 0 { |
| + t.Errorf("after destroy: %q", got) |
| + } |
| +} |
| + |
| +func TestEditView(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add("one") |
| + id := items(b.get("/").Body.String())[0].ID |
| + body := b.get("/active?edit=" + id).Body.String() |
| + if it := items(body)[0]; it.Class != "editing" { |
| + t.Errorf("li class %q, want editing", it.Class) |
| + } |
| + if !strings.Contains(body, `<form method="post" action="/todos/`+id+`/edit">`) { |
| + t.Error("edit input isn't in a form posting to /todos/<id>/edit") |
| + } |
| + if !strings.Contains(body, `href="/active?edit=`+id+`"`) { |
| + t.Error("edit link doesn't keep the filter") |
| + } |
| +} |
| + |
| +func TestToggleAllAndClearCompleted(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add("a") |
| + b.add("b") |
| + id := items(b.get("/").Body.String())[0].ID |
| + b.post("/todos/"+id+"/toggle", url.Values{}) |
| + |
| + b.post("/todos/toggle-all", url.Values{"filter": {"all"}}) |
| + body := b.get("/").Body.String() |
| + for _, it := range items(body) { |
| + if it.Class != "completed" { |
| + t.Errorf("after toggle-all, %s has class %q", it.Title, it.Class) |
| + } |
| + } |
| + if !strings.Contains(body, `class="toggle-all" type="checkbox" checked`) { |
| + t.Error("toggle-all isn't checked when everything is completed") |
| + } |
| + if !strings.Contains(body, `class="clear-completed"`) { |
| + t.Error("clear-completed hidden with completed items") |
| + } |
| + |
| + b.post("/todos/toggle-all", url.Values{"filter": {"all"}}) |
| + for _, it := range items(b.get("/").Body.String()) { |
| + if it.Class != "" { |
| + t.Errorf("after second toggle-all, %s has class %q", it.Title, it.Class) |
| + } |
| + } |
| + if strings.Contains(b.get("/").Body.String(), `class="clear-completed"`) { |
| + t.Error("clear-completed shown with nothing completed") |
| + } |
| + |
| + b.post("/todos/"+id+"/toggle", url.Values{}) |
| + b.post("/todos/clear-completed", url.Values{"filter": {"completed"}}) |
| + if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"b"}) { |
| + t.Errorf("after clear-completed: %q", got) |
| + } |
| +} |
| + |
| +func TestListsAreSeparate(t *testing.T) { |
| + t.Parallel() |
| + h := newApp(t) |
| + alice, mallory := newBrowser(t, h), newBrowser(t, h) |
| + alice.add("mine") |
| + id := items(alice.get("/").Body.String())[0].ID |
| + |
| + if got := titles(mallory.get("/").Body.String()); len(got) != 0 { |
| + t.Fatalf("another visitor sees %q", got) |
| + } |
| + for _, action := range []string{"toggle", "edit", "destroy"} { |
| + rec := mallory.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}, "filter": {"all"}}) |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("%s on another list's item: %d, want 404", action, rec.Code) |
| + } |
| + } |
| + for _, path := range []string{"/todos/toggle-all", "/todos/clear-completed"} { |
| + mallory.post(path, url.Values{}) |
| + } |
| + if got := items(alice.get("/").Body.String()); len(got) != 1 || got[0].Title != "mine" || got[0].Class != "" { |
| + t.Errorf("alice's list changed: %+v", got) |
| + } |
| + for _, id := range []string{"999999", "abc", "-1"} { |
| + if rec := alice.post("/todos/"+id+"/toggle", url.Values{}); rec.Code != http.StatusNotFound { |
| + t.Errorf("toggle %s: %d, want 404", id, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +func TestUnknownCookieGetsFreshList(t *testing.T) { |
| + t.Parallel() |
| + h := newApp(t) |
| + alice := newBrowser(t, h) |
| + alice.add("mine") |
| + var name string |
| + for n := range alice.cookies { |
| + name = n |
| + } |
| + for _, v := range []string{"%%%", "garbage", strings.Repeat("A", 43)} { |
| + b := newBrowser(t, h) |
| + b.cookies[name] = &http.Cookie{Name: name, Value: v} |
| + rec := b.get("/") |
| + if rec.Code != http.StatusOK || len(items(rec.Body.String())) != 0 { |
| + t.Errorf("cookie %q: status %d, items %v", v, rec.Code, items(rec.Body.String())) |
| + } |
| + } |
| +} |
| + |
| +func TestOriginCheck(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + form := url.Values{"title": {"x"}, "filter": {"all"}} |
| + if rec := b.post("/todos", form, "Origin", "https://evil.example"); rec.Code != http.StatusForbidden { |
| + t.Errorf("cross-site Origin: %d, want 403", rec.Code) |
| + } |
| + if rec := b.post("/todos", form, "Origin", "null"); rec.Code != http.StatusForbidden { |
| + t.Errorf("Origin null: %d, want 403", rec.Code) |
| + } |
| + if got := titles(b.get("/").Body.String()); len(got) != 0 { |
| + t.Fatalf("refused posts added %q", got) |
| + } |
| + // Sec-Fetch-Site can't vouch for a foreign Origin. |
| + for _, site := range []string{"same-origin", "none"} { |
| + if rec := b.post("/todos", form, "Origin", "https://evil.example", "Sec-Fetch-Site", site); rec.Code != http.StatusForbidden { |
| + t.Errorf("foreign Origin with Sec-Fetch-Site %s: %d, want 403", site, rec.Code) |
| + } |
| + } |
| + if got := titles(b.get("/").Body.String()); len(got) != 0 { |
| + t.Fatalf("refused posts added %q", got) |
| + } |
| + if rec := b.post("/todos", form, "Origin", testOrigin); rec.Code != http.StatusSeeOther { |
| + t.Errorf("own Origin: %d, want 303", rec.Code) |
| + } |
| + if rec := b.post("/todos", form); rec.Code != http.StatusSeeOther { |
| + t.Errorf("no Origin: %d, want 303", rec.Code) |
| + } |
| +} |
| + |
| +func TestFragmentForScript(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + rec := b.post("/todos", url.Values{"title": {"x"}, "filter": {"active"}}, "X-Todo-Fragment", "1") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("fragment post: %d, want 200", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if strings.Contains(body, "<html") || !strings.Contains(body, `class="todo-list"`) { |
| + t.Errorf("fragment isn't the list markup alone:\n%s", body) |
| + } |
| + if !strings.Contains(body, `<a class="selected" href="/active">`) { |
| + t.Error("fragment lost the filter") |
| + } |
| +} |
| + |
| +func TestStylesheetsServedByteForByte(t *testing.T) { |
| + t.Parallel() |
| + h := newApp(t) |
| + for _, name := range []string{"base.css", "index.css"} { |
| + want, err := os.ReadFile("../../../../shared/" + name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := newBrowser(t, h).get("/static/" + name) |
| + if rec.Code != http.StatusOK || rec.Body.String() != string(want) { |
| + t.Errorf("/static/%s: status %d, %d bytes, want shared/%s (%d bytes)", name, rec.Code, rec.Body.Len(), name, len(want)) |
| + } |
| + } |
| +} |
| + |
| +func TestOversizedEditChangesNothing(t *testing.T) { |
| + t.Parallel() |
| + b := newBrowser(t, newApp(t)) |
| + b.add("keep me") |
| + id := items(b.get("/").Body.String())[0].ID |
| + huge := strings.Repeat("x", 2<<20) |
| + rec := b.post("/todos/"+id+"/edit", url.Values{"title": {huge}, "filter": {"all"}}) |
| + if rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized edit: %d, want 413", rec.Code) |
| + } |
| + if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"keep me"}) { |
| + t.Errorf("after oversized edit: %q", got) |
| + } |
| + if rec := b.post("/todos", url.Values{"title": {huge}}); rec.Code != http.StatusRequestEntityTooLarge { |
| + t.Errorf("oversized add: %d, want 413", rec.Code) |
| + } |
| +} |