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,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'"`
|
||||
}
|
||||
Reference in New Issue
Block a user