diff --git a/internal/api/server.go b/internal/api/server.go index cf2aaf7..dfe1ac4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -129,9 +129,12 @@ func (a *apiServer) routes() http.Handler { r.Get("/{id}", a.handlePasteView) r.Post("/{id}", a.handlePasteView) - r.NotFound(func(w http.ResponseWriter, r *http.Request) { - writeErr(w, 404, "not found") - }) +// #173: missing paste URLs render the main UI with a friendly not-found +// message instead of a bare JSON 404. Known routes (above) handle real pages; +// anything else is a nonexistent paste ID or typo. +r.NotFound(func(w http.ResponseWriter, r *http.Request) { + a.webHandlers().HandleNotFoundPage(w, r) +}) return r } diff --git a/internal/web/templates/notfound.html b/internal/web/templates/notfound.html new file mode 100644 index 0000000..eda487f --- /dev/null +++ b/internal/web/templates/notfound.html @@ -0,0 +1,13 @@ +{{template "head" .}} +{{template "topbar" .}} +
+
+
+
+

Paste ID not found

+

The paste /{{.ID}} does not exist, has expired, or was burned.

+ Create a new paste +
+
+
+{{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 074d183..b502a4d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -104,6 +104,13 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) { } } +// RenderNotFoundPage is the exported not-found renderer used by the api +// package (#173): the router's NotFound handler renders the main UI with a +// friendly "Paste ID not found" message, still with HTTP 404. +func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + // RenderPage is the exported wrapper used by the api package (#4 can pages). func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) { h.renderPage(w, name, data) @@ -200,6 +207,30 @@ func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { w.Write([]byte(`{"error":"rate limit exceeded"}`)) } +// renderNotFound serves the friendly not-found page (#173): the main UI +// chrome (topbar, centered card) with a "Paste ID not found" message in the +// result card, instead of a bare text 404. Still returns HTTP 404 so +// crawlers/validators see the correct status. +func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) { + h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id}) +} + +// HandleNotFoundPage serves the friendly not-found page for unknown routes +// (#173): main UI chrome with a "Paste ID not found" message. Called from the +// chi NotFound handler in the api package. +func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + +// renderPageStatus renders a template with an explicit HTTP status code. +func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "template error: "+err.Error(), 500) + } +} + func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) { lines := strings.Count(row.Content, "\n") + 1 gutter := "" @@ -262,11 +293,14 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { return } if row == nil { - http.NotFound(w, r) + // #173: a missing paste ID gets the main UI with a friendly message, + // not a bare text 404 page. + h.renderNotFound(w, r, id) return } if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { - http.Error(w, "paste expired", 404) + // #173: expired pastes render the same friendly not-found UI. + h.renderNotFound(w, r, id) return } if row.PasswordHash.Valid { @@ -333,8 +367,8 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { // Just-created first render does not count as a read for the creator. if !justCreated { rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin()) - if !admitted { // #58: lost the burn claim; do not render content - http.NotFound(w, r) + if !admitted { // #58: lost the burn claim; #173: friendly not-found UI + h.renderNotFound(w, r, row.ID) return } h.renderPaste(w, row, false, "", rem)