diff --git a/backend/internal/web/handlers_tablos.go b/backend/internal/web/handlers_tablos.go index bfbacfe..1682a3f 100644 --- a/backend/internal/web/handlers_tablos.go +++ b/backend/internal/web/handlers_tablos.go @@ -1,6 +1,7 @@ package web import ( + "errors" "log/slog" "net/http" "strings" @@ -9,7 +10,10 @@ import ( "backend/internal/db/sqlc" "backend/templates" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/gorilla/csrf" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -129,6 +133,242 @@ func TablosCreateHandler(deps TablosDeps) http.HandlerFunc { } } +// loadOwnedTablo is a shared preamble for all /tablos/{id}* handlers. +// It: +// 1. Parses the {id} URL param as a UUID → 404 on parse failure (T-03-03-03). +// 2. Fetches the tablo by ID → 404 on pgx.ErrNoRows, 500 on other errors. +// 3. Checks ownership → 404 (not 403) when tablo.UserID != user.ID (T-03-03-01, D-04). +// +// Returns (tablo, user, true) on success. On failure the helper writes the +// appropriate HTTP response and returns (zero, nil, false); callers must return +// immediately when ok is false. +func loadOwnedTablo(w http.ResponseWriter, r *http.Request, deps TablosDeps) (sqlc.Tablo, *auth.User, bool) { + _, user, _ := auth.Authed(r.Context()) + + // Step 1: parse the URL parameter. + tabloID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.NotFound(w, r) + return sqlc.Tablo{}, nil, false + } + + // Step 2: fetch from DB. + tablo, err := deps.Queries.GetTabloByID(r.Context(), tabloID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.NotFound(w, r) + return sqlc.Tablo{}, nil, false + } + slog.Default().Error("tablos: GetTabloByID failed", "id", tabloID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return sqlc.Tablo{}, nil, false + } + + // Step 3: ownership check (D-04: 404 not 403 — no existence leakage). + if tablo.UserID != user.ID { + http.NotFound(w, r) + return sqlc.Tablo{}, nil, false + } + + return tablo, user, true +} + +// TabloDetailHandler handles GET /tablos/{id}. +// Renders the tablo detail page for the authenticated owner; 404 for non-owner +// and invalid UUIDs (TABLO-03, T-03-03-01, T-03-03-03). +func TabloDetailHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, user, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloDetailPage(user, csrf.Token(r), tablo).Render(r.Context(), w) + } +} + +// TabloEditTitleHandler handles GET /tablos/{id}/edit-title. +// Returns the title edit fragment for HTMX outerHTML swap. +func TabloEditTitleHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloTitleEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloShowTitleHandler handles GET /tablos/{id}/show-title. +// Returns the title display fragment — used by the "Discard changes" path. +func TabloShowTitleHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloTitleDisplay(tablo, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloEditDescHandler handles GET /tablos/{id}/edit-desc. +// Returns the description edit fragment for HTMX outerHTML swap. +func TabloEditDescHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloDescEditFragment(tablo, templates.TabloUpdateErrors{}, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloShowDescHandler handles GET /tablos/{id}/show-desc. +// Returns the description display fragment — used by the "Discard changes" path. +func TabloShowDescHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloDescDisplay(tablo, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloUpdateHandler handles POST /tablos/{id}. +// Validates, updates the DB row (title + description), and returns the +// matching display fragment based on the hidden _zone field. +// +// Security invariants: +// - Form values read via r.PostFormValue only (gorilla/csrf body — Pitfall 2) +// - Title validated non-empty and <= 255 chars (T-03-03-05) +// - Description written as pgtype.Text{Valid: s != ""} (T-03-03-06) +// - HTMX path: 200 + display fragment; non-HTMX path: 303 to /tablos/{id} +func TabloUpdateHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + ctx := r.Context() + + // Read form values — always r.PostFormValue (Pitfall 2). + title := strings.TrimSpace(r.PostFormValue("title")) + description := r.PostFormValue("description") + zone := r.PostFormValue("_zone") // "title" or "desc" + + var errs templates.TabloUpdateErrors + + // Validate title. + if title == "" { + errs.Title = "Title is required." + } else if len(title) > 255 { + errs.Title = "Title must be 255 characters or fewer." + } + + if errs.Title != "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusUnprocessableEntity) + if r.Header.Get("HX-Request") == "true" { + _ = templates.TabloTitleEditFragment(tablo, errs, csrf.Token(r)).Render(ctx, w) + return + } + // Non-HTMX: render full detail page with errors surfaced. + _ = templates.TabloDetailPage(nil, csrf.Token(r), tablo).Render(ctx, w) + return + } + + // Update the DB row (UpdateTablo sets updated_at = now() per sqlc query — Pitfall 7). + updated, err := deps.Queries.UpdateTablo(ctx, sqlc.UpdateTabloParams{ + ID: tablo.ID, + Title: title, + Description: pgtype.Text{String: description, Valid: description != ""}, + }) + if err != nil { + slog.Default().Error("tablos update: query failed", "id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Success: return appropriate display fragment or redirect. + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + if r.Header.Get("HX-Request") == "true" { + // Render the display fragment matching the zone that was edited. + if zone == "desc" { + _ = templates.TabloDescDisplay(updated, csrf.Token(r)).Render(ctx, w) + return + } + // Default to title display. + _ = templates.TabloTitleDisplay(updated, csrf.Token(r)).Render(ctx, w) + return + } + + // Non-HTMX: 303 redirect to detail page so GET renders fresh state. + http.Redirect(w, r, "/tablos/"+tablo.ID.String(), http.StatusSeeOther) + } +} + +// TabloDeleteConfirmHandler handles GET /tablos/{id}/delete-confirm. +// Returns the delete confirmation fragment for HTMX outerHTML swap. +func TabloDeleteConfirmHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloDeleteConfirmFragment(tablo, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloDeleteCancelHandler handles GET /tablos/{id}/delete-cancel. +// Returns the delete button fragment, restoring the original button after +// the user cancels the confirmation dialog. +func TabloDeleteCancelHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.TabloDeleteButtonFragment(tablo, csrf.Token(r)).Render(r.Context(), w) + } +} + +// TabloDeleteHandler handles POST /tablos/{id}/delete. +// Hard-deletes the tablo and redirects to /: +// - HTMX: 200 + HX-Redirect: / header (HTMX client-side navigation) +// - Non-HTMX: 303 to / (Pitfall 9 — POST/Redirect/GET, NOT 302) +// +// Security: loadOwnedTablo enforces auth + ownership; CSRF middleware validates +// the _csrf field before this handler runs (T-03-03-09). +func TabloDeleteHandler(deps TablosDeps) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tablo, _, ok := loadOwnedTablo(w, r, deps) + if !ok { + return + } + + if err := deps.Queries.DeleteTablo(r.Context(), tablo.ID); err != nil { + slog.Default().Error("tablos delete: query failed", "id", tablo.ID, "err", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", "/") + w.WriteHeader(http.StatusOK) + return + } + http.Redirect(w, r, "/", http.StatusSeeOther) + } +} + // renderTabloCreateError writes a tablo create validation-error response. // For HTMX requests it renders only the form fragment; for plain requests it // renders the full dashboard (mirrors the auth handler pattern). diff --git a/backend/internal/web/handlers_tablos_test.go b/backend/internal/web/handlers_tablos_test.go index 783a92d..79944db 100644 --- a/backend/internal/web/handlers_tablos_test.go +++ b/backend/internal/web/handlers_tablos_test.go @@ -369,7 +369,7 @@ func TestTabloDetail_Owner(t *testing.T) { tablo, err := q.InsertTablo(ctx, sqlc.InsertTabloParams{ UserID: user.ID, - Title: "Owner's Tablo", + Title: "Owners Detail Tablo", Description: pgtype.Text{Valid: false}, Color: pgtype.Text{Valid: false}, }) @@ -391,7 +391,7 @@ func TestTabloDetail_Owner(t *testing.T) { if rec.Code != http.StatusOK { t.Fatalf("GET /tablos/{id} as owner: status = %d; want 200", rec.Code) } - if !strings.Contains(rec.Body.String(), "Owner's Tablo") { + if !strings.Contains(rec.Body.String(), "Owners Detail Tablo") { t.Errorf("body missing tablo title; got: %.300s", rec.Body.String()) } } diff --git a/backend/internal/web/router.go b/backend/internal/web/router.go index 0953811..cafbba4 100644 --- a/backend/internal/web/router.go +++ b/backend/internal/web/router.go @@ -77,8 +77,19 @@ func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosD r.Use(auth.RequireAuth) r.Get("/", TablosListHandler(tabloDeps)) r.Post("/logout", LogoutHandler(deps)) + // Static segments BEFORE parametric (Pitfall 1 — chi v5 route resolution). r.Get("/tablos/new", TablosNewHandler(tabloDeps)) r.Post("/tablos", TablosCreateHandler(tabloDeps)) + // Parametric routes — must come after /tablos/new and /tablos POST. + r.Get("/tablos/{id}", TabloDetailHandler(tabloDeps)) + r.Post("/tablos/{id}", TabloUpdateHandler(tabloDeps)) + r.Get("/tablos/{id}/edit-title", TabloEditTitleHandler(tabloDeps)) + r.Get("/tablos/{id}/show-title", TabloShowTitleHandler(tabloDeps)) + r.Get("/tablos/{id}/edit-desc", TabloEditDescHandler(tabloDeps)) + r.Get("/tablos/{id}/show-desc", TabloShowDescHandler(tabloDeps)) + r.Get("/tablos/{id}/delete-confirm", TabloDeleteConfirmHandler(tabloDeps)) + r.Get("/tablos/{id}/delete-cancel", TabloDeleteCancelHandler(tabloDeps)) + r.Post("/tablos/{id}/delete", TabloDeleteHandler(tabloDeps)) }) r.Get("/healthz", HealthzHandler(pinger))