Files
paliad/internal/handlers/chart_pages.go
mAi 968b0bc2da feat(t-paliad-177): close visibility leak on /projects/{id}/chart handler
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.

A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).

Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).

Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.
2026-05-13 00:03:45 +02:00

75 lines
2.4 KiB
Go

package handlers
import (
"errors"
"net/http"
"os"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-177 — Project Timeline / Chart standalone page.
//
// Slice 1 served dist/projects-chart.html unconditionally and relied on
// the client's first API fetch to enforce visibility. That leaked a 200
// for any well-formed UUID a guesser tried (m/paliad#35 Slice 1 edge
// case #2). Slice 2 closes the leak — we resolve the project via
// ProjectService.GetByID *before* serving the shell so an inaccessible
// id returns 404 + the standard notfound chrome.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
func handleProjectsChartPage(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
serveChartNotFound(w)
return
}
if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
// ErrNotVisible + any "not found" surface from the service collapses
// to the same outward 404 — never tell a guesser whether the id
// exists, only whether they can see it.
if errors.Is(err, services.ErrNotVisible) {
serveChartNotFound(w)
return
}
// Genuine errors (DB hiccup, etc.) — log via writeServiceError but
// also fall back to 404 page chrome for the user instead of a raw
// 500 string. The JSON path of writeServiceError handles /api/*
// only, so we keep its logging side-effect but render the HTML.
writeServiceError(httpDevNullJSON{}, err)
serveChartNotFound(w)
return
}
http.ServeFile(w, r, "dist/projects-chart.html")
}
func serveChartNotFound(w http.ResponseWriter) {
body, err := os.ReadFile("dist/notfound.html")
if err != nil {
http.Error(w, "404 page not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write(body)
}
// httpDevNullJSON is a writer that discards everything writeServiceError
// would have emitted — we only want the log line, not a duplicate body
// before serveChartNotFound writes the real one.
type httpDevNullJSON struct{}
func (httpDevNullJSON) Header() http.Header { return http.Header{} }
func (httpDevNullJSON) Write(b []byte) (int, error) { return len(b), nil }
func (httpDevNullJSON) WriteHeader(int) {}