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
+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)
}
}