mirror of
https://github.com/cole-maxwell1/todo-hetzner.git
synced 2026-10-27 19:02:49 -05:00
Use the same-origin referrer policy to prevent Chrome from sending a null Origin header on POST requests.
168 lines
4.7 KiB
Go
168 lines
4.7 KiB
Go
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")
|
|
}
|