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", "same-origin") 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") }