feat(20-01): GetTabloDetailPage handler + GET /tablos/{tabloID} route registration

- tabloDetailRepository interface with ListTasksByTablo
- GetTabloDetailPage: auth check, UUID parse, ownership-scoped tablo lookup, task fetch, vm build, render
- router.go: mux.Get('/tablos/{tabloID}') registered before /edit route
- activePath='/tablos' so sidebar Tablos item stays highlighted
- Threat T-20-01 mitigated: findTabloByID filters by OwnerID from session
This commit is contained in:
Arthur Belleville 2026-05-18 15:46:30 +02:00
parent 9713cbd168
commit 3fc8aae7a0
No known key found for this signature in database
2 changed files with 84 additions and 0 deletions

View file

@ -0,0 +1,83 @@
package handlers
import (
"context"
"net/http"
"github.com/google/uuid"
"xtablo-backend/internal/web/views"
)
// tabloDetailRepository is the interface for fetching tasks for a specific tablo.
type tabloDetailRepository interface {
ListTasksByTablo(ctx context.Context, input ListTasksByTabloInput) ([]TaskRecord, error)
}
// GetTabloDetailPage handles GET /tablos/{tabloID}.
// It authenticates the user, fetches the tablo (with ownership check), fetches tasks,
// and renders the tablo detail page.
func (h *AuthHandler) GetTabloDetailPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := h.authenticatedUser(r.Context(), r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
tabloID, err := uuid.Parse(r.PathValue("tabloID"))
if err != nil {
http.Error(w, "invalid tablo id", http.StatusBadRequest)
return
}
// Fetch all tablos owned by the user — ownership check is implicit via OwnerID filter
tablos, err := h.repo.ListTablos(r.Context(), ListTablosInput{OwnerID: user.ID})
if err != nil {
http.Error(w, "failed to load tablos", http.StatusInternalServerError)
return
}
// Find the specific tablo (returns 404 if not owned by this user)
tablo, ok := findTabloByID(tablos, tabloID)
if !ok {
http.Error(w, "tablo not found", http.StatusNotFound)
return
}
// Type-assert to tabloDetailRepository to access ListTasksByTablo
taskRepo, ok := h.repo.(tabloDetailRepository)
if !ok {
http.Error(w, "tasks repository not configured", http.StatusInternalServerError)
return
}
tasks, err := taskRepo.ListTasksByTablo(r.Context(), ListTasksByTabloInput{
OwnerID: user.ID,
TabloID: tabloID,
})
if err != nil {
http.Error(w, "failed to load tasks", http.StatusInternalServerError)
return
}
// Fetch owner display name (best-effort; falls back to empty string)
ownerName := ""
if owner, err := h.repo.GetPublicUserByID(r.Context(), user.ID); err == nil {
ownerName = owner.DisplayName
}
vm := views.NewTabloDetailViewModel(tablo, tasks, ownerName)
content := views.TabloDetailPage(vm)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var renderErr error
if isHXRequest(r) {
renderErr = views.DashboardContentSwapWithMainClass("/tablos", tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
} else {
renderErr = views.DashboardPageWithMainClass("/tablos", tablos, "flex-1 overflow-auto", content).Render(r.Context(), w)
}
if renderErr != nil {
http.Error(w, "failed to render page", http.StatusInternalServerError)
}
}
}

View file

@ -42,6 +42,7 @@ func newRouterWithHandler(authHandler *handlers.AuthHandler) http.Handler {
mux.Get("/files", authHandler.GetFilesPage())
mux.Get("/feedback", authHandler.GetFeedbackPage())
mux.Post("/tablos", authHandler.PostTablos())
mux.Get("/tablos/{tabloID}", authHandler.GetTabloDetailPage())
mux.Get("/tablos/{tabloID}/edit", authHandler.GetEditTabloModal())
mux.Post("/tablos/{tabloID}", authHandler.PostTabloUpdate())
mux.Delete("/tablos/{tabloID}", authHandler.DeleteTablo())