feat: add Hetzner-hosted todo application stack

Implement the server-rendered Go todo application with PostgreSQL persistence, Tailwind styling, health checks, and test coverage.

Provision production and staging infrastructure with OpenTofu, including private networking, static IPs, Object Storage backups, and load balancing.

Configure Rocky Linux hosts with Ansible, rootless Podman, PostgreSQL, WAL-G backup automation, staging restores, and generated inventory.
This commit is contained in:
cole-maxwell1
2026-08-06 21:34:56 -05:00
commit 1382c42803
68 changed files with 2380 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
bin
tailwindcss
.git
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1.7
FROM alpine:3.21 AS css
ARG TARGETARCH
ARG TAILWIND_VERSION=v3.4.17
WORKDIR /src
RUN apk add --no-cache ca-certificates curl \
&& case "$TARGETARCH" in amd64) tw_arch=x64 ;; arm64) tw_arch=arm64 ;; *) exit 1 ;; esac \
&& curl -fsSL -o /usr/local/bin/tailwindcss "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${tw_arch}" \
&& chmod +x /usr/local/bin/tailwindcss
COPY tailwind.config.js ./
COPY web/input.css web/input.css
COPY web/templates web/templates
RUN mkdir -p web/static && tailwindcss -c tailwind.config.js -i web/input.css -o web/static/app.css --minify
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=css /src/web/static/app.css web/static/app.css
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /todo ./cmd/server
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /todo /todo
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/todo"]
+27
View File
@@ -0,0 +1,27 @@
TAILWIND_VERSION ?= v3.4.17
TAILWIND_BIN := ./tailwindcss
.PHONY: build css test vet clean
build: css
mkdir -p bin
CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o bin/todo ./cmd/server
$(TAILWIND_BIN):
@os=$$(uname -s | tr '[:upper:]' '[:lower:]'); arch=$$(uname -m); \
case "$$os" in darwin) os=macos ;; linux) ;; *) exit 1 ;; esac; \
case "$$arch" in x86_64) arch=x64 ;; arm64|aarch64) arch=arm64 ;; *) exit 1 ;; esac; \
curl -fsSL -o $(TAILWIND_BIN) "https://github.com/tailwindlabs/tailwindcss/releases/download/$(TAILWIND_VERSION)/tailwindcss-$$os-$$arch"
chmod +x $(TAILWIND_BIN)
css: $(TAILWIND_BIN)
$(TAILWIND_BIN) -c tailwind.config.js -i web/input.css -o web/static/app.css --minify
test:
go test ./...
vet:
go vet ./...
clean:
rm -rf bin $(TAILWIND_BIN)
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"todo-hetzner/app/internal/db"
"todo-hetzner/app/internal/handler"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := run(logger); err != nil {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return errors.New("DATABASE_URL is required")
}
addr := os.Getenv("APP_ADDR")
if addr == "" {
addr = ":8080"
}
store, err := db.Open(databaseURL)
if err != nil {
return err
}
defer store.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := store.Ping(ctx); err != nil {
return err
}
h, err := handler.New(store, logger)
if err != nil {
return err
}
server := &http.Server{
Addr: addr,
Handler: h.Routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-shutdownCtx.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", "error", err)
}
}()
logger.Info("server listening", "address", addr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
module todo-hetzner/app
go 1.22.0
require (
github.com/lib/pq v1.10.9
xorm.io/xorm v1.3.9
)
require (
github.com/goccy/go-json v0.8.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 // indirect
)
+96
View File
@@ -0,0 +1,96 @@
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:lSA0F4e9A2NcQSqGqTOXqu2aRi/XEQxDCBwM8yJtE6s=
gitea.com/xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a/go.mod h1:EXuID2Zs0pAQhH8yz+DNjUbjppKQzKFAn28TMYPB6IU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
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/goccy/go-json v0.8.1 h1:4/Wjm0JIJaTDm8K1KcGrLHJoa8EsJ13YWeX+6Kfq6uI=
github.com/goccy/go-json v0.8.1/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978 h1:bvLlAPW1ZMTWA32LuZMBEGHAUOcATZjzHcotf3SWweM=
xorm.io/builder v0.3.11-0.20220531020008-1bd24a7dc978/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE=
xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU=
xorm.io/xorm v1.3.9/go.mod h1:LsCCffeeYp63ssk0pKumP6l96WZcHix7ChpurcLNuMw=
+64
View File
@@ -0,0 +1,64 @@
package db
import (
"context"
"fmt"
_ "github.com/lib/pq"
"xorm.io/xorm"
"todo-hetzner/app/internal/model"
)
type Store struct {
engine *xorm.Engine
}
func Open(databaseURL string) (*Store, error) {
engine, err := xorm.NewEngine("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("create database engine: %w", err)
}
engine.SetMaxOpenConns(10)
engine.SetMaxIdleConns(5)
if err := engine.Sync2(new(model.Todo)); err != nil {
engine.Close()
return nil, fmt.Errorf("migrate database: %w", err)
}
return &Store{engine: engine}, nil
}
func (s *Store) Ping(ctx context.Context) error {
return s.engine.DB().PingContext(ctx)
}
func (s *Store) Close() error {
return s.engine.Close()
}
func (s *Store) List(ctx context.Context) ([]model.Todo, error) {
var todos []model.Todo
err := s.engine.Context(ctx).Desc("created_at", "id").Find(&todos)
return todos, err
}
func (s *Store) Create(ctx context.Context, title string) error {
_, err := s.engine.Context(ctx).Insert(&model.Todo{Title: title})
return err
}
func (s *Store) Toggle(ctx context.Context, id int64) (bool, error) {
var todo model.Todo
found, err := s.engine.Context(ctx).ID(id).Get(&todo)
if err != nil || !found {
return found, err
}
todo.Completed = !todo.Completed
_, err = s.engine.Context(ctx).ID(id).Cols("completed", "updated_at").Update(&todo)
return true, err
}
func (s *Store) Delete(ctx context.Context, id int64) (bool, error) {
affected, err := s.engine.Context(ctx).ID(id).Delete(new(model.Todo))
return affected > 0, err
}
+57
View File
@@ -0,0 +1,57 @@
package db
import (
"context"
"os"
"strconv"
"testing"
"time"
)
func TestStoreLifecycle(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
store, err := Open(databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
title := "store-test-" + strconv.FormatInt(time.Now().UnixNano(), 10)
if err := store.Create(ctx, title); err != nil {
t.Fatal(err)
}
todos, err := store.List(ctx)
if err != nil {
t.Fatal(err)
}
var id int64
for _, todo := range todos {
if todo.Title == title {
id = todo.ID
break
}
}
if id == 0 {
t.Fatalf("created todo %q not found", title)
}
t.Cleanup(func() { _, _ = store.Delete(context.Background(), id) })
found, err := store.Toggle(ctx, id)
if err != nil || !found {
t.Fatalf("toggle found = %v, error = %v", found, err)
}
found, err = store.Delete(ctx, id)
if err != nil || !found {
t.Fatalf("delete found = %v, error = %v", found, err)
}
found, err = store.Delete(ctx, id)
if err != nil || found {
t.Fatalf("second delete found = %v, error = %v", found, err)
}
}
+167
View File
@@ -0,0 +1,167 @@
package handler
import (
"context"
"errors"
"html/template"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"todo-hetzner/app/internal/model"
"todo-hetzner/app/web"
)
const maxFormBytes = 4096
type TodoStore interface {
List(context.Context) ([]model.Todo, error)
Create(context.Context, string) error
Toggle(context.Context, int64) (bool, error)
Delete(context.Context, int64) (bool, error)
Ping(context.Context) error
}
type Handler struct {
store TodoStore
logger *slog.Logger
templates *template.Template
}
func New(store TodoStore, logger *slog.Logger) (*Handler, error) {
tmpl, err := template.ParseFS(web.Assets, "templates/layouts/*.html", "templates/pages/*.html")
if err != nil {
return nil, err
}
return &Handler{store: store, logger: logger, templates: tmpl}, nil
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", h.list)
mux.HandleFunc("POST /todos", h.create)
mux.HandleFunc("POST /todos/{id}/toggle", h.toggle)
mux.HandleFunc("POST /todos/{id}/delete", h.delete)
mux.HandleFunc("GET /healthz", h.health)
mux.Handle("GET /static/", http.FileServerFS(web.Assets))
return h.security(h.accessLog(mux))
}
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
todos, err := h.store.List(r.Context())
if err != nil {
h.serverError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "layout", struct{ Todos []model.Todo }{todos}); err != nil {
h.logger.Error("render response", "error", err, "request_id", requestID(r))
}
}
func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
if !sameOrigin(r) {
http.Error(w, "invalid origin", http.StatusForbidden)
return
}
if err := parseForm(w, r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" || len([]rune(title)) > 200 {
http.Error(w, "title must contain 1 to 200 characters", http.StatusUnprocessableEntity)
return
}
if err := h.store.Create(r.Context(), title); err != nil {
h.serverError(w, r, err)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) toggle(w http.ResponseWriter, r *http.Request) {
h.mutateByID(w, r, h.store.Toggle)
}
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
h.mutateByID(w, r, h.store.Delete)
}
func (h *Handler) mutateByID(w http.ResponseWriter, r *http.Request, mutate func(context.Context, int64) (bool, error)) {
if !sameOrigin(r) {
http.Error(w, "invalid origin", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
http.Error(w, "invalid todo ID", http.StatusBadRequest)
return
}
found, err := mutate(r.Context(), id)
if err != nil {
h.serverError(w, r, err)
return
}
if !found {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
if err := h.store.Ping(r.Context()); err != nil {
http.Error(w, "unhealthy", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
}
func (h *Handler) serverError(w http.ResponseWriter, r *http.Request, err error) {
h.logger.Error("request failed", "error", err, "request_id", requestID(r))
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func (h *Handler) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}
func (h *Handler) accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h.logger.Info("request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "request_id", requestID(r))
next.ServeHTTP(w, r)
})
}
func parseForm(w http.ResponseWriter, r *http.Request) error {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
return errors.New("invalid form data")
}
return nil
}
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
return err == nil && strings.EqualFold(u.Host, r.Host)
}
func requestID(r *http.Request) string {
return r.Header.Get("X-Request-ID")
}
+130
View File
@@ -0,0 +1,130 @@
package handler
import (
"context"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"todo-hetzner/app/internal/model"
)
type fakeStore struct {
todos []model.Todo
created string
toggled int64
deleted int64
pingErr error
}
func (s *fakeStore) List(context.Context) ([]model.Todo, error) { return s.todos, nil }
func (s *fakeStore) Create(_ context.Context, title string) error {
s.created = title
return nil
}
func (s *fakeStore) Toggle(_ context.Context, id int64) (bool, error) {
s.toggled = id
return id != 404, nil
}
func (s *fakeStore) Delete(_ context.Context, id int64) (bool, error) {
s.deleted = id
return id != 404, nil
}
func (s *fakeStore) Ping(context.Context) error { return s.pingErr }
func testHandler(t *testing.T, store *fakeStore) http.Handler {
t.Helper()
h, err := New(store, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
return h.Routes()
}
func TestListRendersEscapedTodos(t *testing.T) {
router := testHandler(t, &fakeStore{todos: []model.Todo{{ID: 1, Title: "<script>alert(1)</script>"}}})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d", recorder.Code)
}
if strings.Contains(recorder.Body.String(), "<script>") || !strings.Contains(recorder.Body.String(), "&lt;script&gt;") {
t.Fatalf("title was not escaped: %s", recorder.Body.String())
}
}
func TestCreateValidatesAndTrimsTitle(t *testing.T) {
store := &fakeStore{}
router := testHandler(t, store)
form := url.Values{"title": {" ship it "}}
req := httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusSeeOther || store.created != "ship it" {
t.Fatalf("status = %d, created = %q", recorder.Code, store.created)
}
req = httptest.NewRequest(http.MethodPost, "/todos", strings.NewReader("title=++"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
recorder = httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("empty title status = %d", recorder.Code)
}
}
func TestMutationsAndNotFound(t *testing.T) {
store := &fakeStore{}
router := testHandler(t, store)
for _, tc := range []struct {
path string
want int
}{
{"/todos/12/toggle", http.StatusSeeOther},
{"/todos/13/delete", http.StatusSeeOther},
{"/todos/404/delete", http.StatusNotFound},
{"/todos/nope/toggle", http.StatusBadRequest},
} {
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, tc.path, nil))
if recorder.Code != tc.want {
t.Errorf("%s status = %d, want %d", tc.path, recorder.Code, tc.want)
}
}
if store.toggled != 12 || store.deleted != 404 {
t.Fatalf("toggled = %d, deleted = %d", store.toggled, store.deleted)
}
}
func TestHealth(t *testing.T) {
store := &fakeStore{}
router := testHandler(t, store)
for _, tc := range []struct {
err error
want int
}{{nil, http.StatusOK}, {errors.New("down"), http.StatusServiceUnavailable}} {
store.pingErr = tc.err
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if recorder.Code != tc.want {
t.Errorf("status = %d, want %d", recorder.Code, tc.want)
}
}
}
func TestRejectsCrossOriginMutation(t *testing.T) {
router := testHandler(t, &fakeStore{})
req := httptest.NewRequest(http.MethodPost, "/todos/1/delete", nil)
req.Header.Set("Origin", "https://attacker.example")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusForbidden {
t.Fatalf("status = %d", recorder.Code)
}
}
+11
View File
@@ -0,0 +1,11 @@
package model
import "time"
type Todo struct {
ID int64 `xorm:"pk autoincr 'id'"`
Title string `xorm:"varchar(200) notnull 'title'"`
Completed bool `xorm:"notnull default false 'completed'"`
CreatedAt time.Time `xorm:"created 'created_at'"`
UpdatedAt time.Time `xorm:"created updated 'updated_at'"`
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./web/templates/**/*.html"],
theme: { extend: {} },
plugins: [],
};
+8
View File
@@ -0,0 +1,8 @@
package web
import "embed"
// Assets contains server-rendered templates and compiled static files.
//
//go:embed templates static
var Assets embed.FS
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
{{define "layout"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Field Notes</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="min-h-screen bg-stone-950 text-stone-100 antialiased">
<main class="mx-auto max-w-3xl px-5 py-12 sm:px-8 sm:py-20">
{{template "content" .}}
</main>
</body>
</html>
{{end}}
+37
View File
@@ -0,0 +1,37 @@
{{define "content"}}
<header class="mb-12 border-l-4 border-amber-400 pl-5">
<p class="mb-2 font-mono text-xs uppercase tracking-[0.3em] text-amber-400">Daily ledger</p>
<h1 class="text-4xl font-black tracking-tight sm:text-6xl">Field Notes</h1>
<p class="mt-3 text-stone-400">Small commitments, made concrete.</p>
</header>
<form action="/todos" method="post" class="mb-10 flex flex-col gap-3 sm:flex-row">
<label for="title" class="sr-only">New task</label>
<input id="title" name="title" required maxlength="200" autocomplete="off" placeholder="What needs doing?" class="min-w-0 flex-1 rounded-none border border-stone-700 bg-stone-900 px-4 py-3 text-base outline-none transition focus:border-amber-400 focus:ring-1 focus:ring-amber-400">
<button class="bg-amber-400 px-6 py-3 font-bold text-stone-950 transition hover:bg-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-200">Add note</button>
</form>
<section aria-label="Tasks">
{{if .Todos}}
<ul class="divide-y divide-stone-800 border-y border-stone-800">
{{range .Todos}}
<li class="group flex items-center gap-4 py-5">
<form action="/todos/{{.ID}}/toggle" method="post">
<button aria-label="Toggle {{.Title}}" class="flex h-7 w-7 items-center justify-center border-2 border-stone-600 text-stone-950 transition hover:border-amber-400 {{if .Completed}}border-amber-400 bg-amber-400{{end}}">
{{if .Completed}}<span aria-hidden="true">✓</span>{{end}}
</button>
</form>
<span class="min-w-0 flex-1 break-words text-lg {{if .Completed}}text-stone-500 line-through{{end}}">{{.Title}}</span>
<form action="/todos/{{.ID}}/delete" method="post">
<button class="px-2 py-1 font-mono text-xs uppercase tracking-widest text-stone-500 transition hover:text-red-400" aria-label="Delete {{.Title}}">Delete</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<div class="border border-dashed border-stone-700 px-6 py-14 text-center text-stone-500">
<p class="font-mono text-sm uppercase tracking-widest">The page is clear</p>
</div>
{{end}}
</section>
{{end}}