mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 19:02:49 -05:00
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:
@@ -0,0 +1,3 @@
|
||||
bin
|
||||
tailwindcss
|
||||
.git
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(), "<script>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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'"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./web/templates/**/*.html"],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
File diff suppressed because one or more lines are too long
@@ -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}}
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user