Backend Phase A: foundation, migrations, Docker setup

- Go backend skeleton: config (caarlos0/env), slog JSON logging,
  pgxpool store, HTTP server with graceful shutdown.
- httpapi: render helpers, Problem errors, middleware chain
  (requestID / logging / recover / CORS), /healthz and /readyz.
- Migrations: full initial schema (users, sessions, lists,
  list_members, items, op_log SOURCE OF TRUTH, item_names) +
  golang-migrate runner binary using source/iofs (embedded).
- Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot),
  builds both server and migrate binaries.
- deploy: docker-compose (caddy + backend + migrate + postgres:16),
  Caddyfile (auto-HTTPS), .env.example, pg extensions init script.
- AGENTS.md: project context + roadmap for AI agents.

Verified: image builds, both binaries run in container (smoke test).
This commit is contained in:
Tronax 2026-08-05 15:14:37 +02:00
commit 2899eb205b
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
23 changed files with 1372 additions and 0 deletions

View file

@ -0,0 +1,125 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"github.com/mitbringsl/backend/internal/config"
)
// ctxKey is an unexported key type for context values in this package.
type ctxKey int
const (
ctxKeyRequestID ctxKey = iota
ctxKeyUserID
ctxKeySessionID
)
type wrappedWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *wrappedWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *wrappedWriter) Write(b []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(b)
w.bytes += n
return n, err
}
// requestIDMiddleware injects a random request id into the request context and response header.
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
b := make([]byte, 8)
_, _ = rand.Read(b)
id = hex.EncodeToString(b)
}
w.Header().Set("X-Request-ID", id)
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// loggingMiddleware logs each request as structured JSON.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := &wrappedWriter{ResponseWriter: w}
next.ServeHTTP(ww, r)
slog.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.status,
"bytes", ww.bytes,
"request_id", r.Context().Value(ctxKeyRequestID),
"remote", r.RemoteAddr,
)
})
}
// recoverMiddleware catches panics, logs them and returns 500.
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
slog.Error("panic recovered",
"error", rec,
"request_id", r.Context().Value(ctxKeyRequestID),
"stack", string(debug.Stack()),
)
renderError(w, http.StatusInternalServerError, "Internal error", "An unexpected error occurred.")
}
}()
next.ServeHTTP(w, r)
})
}
// corsMiddleware adds permissive-but-scoped CORS headers when configured.
// Native clients (Android) don't need CORS; this only affects browsers.
func corsMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
allowed := map[string]bool{}
for _, o := range cfg.CORSAllowedOrigins {
if o != "" {
allowed[o] = true
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
}
// Chain wires middlewares in declaration order: first one runs outermost.
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}