Merge pull request '#173: render friendly Paste ID not found UI instead of bare 404' (#174) from fix/173-paste-not-found into dev
CI / test (push) Successful in 33s
CI / docker (push) Successful in 44s

This commit was merged in pull request #174.
This commit is contained in:
fen
2026-09-10 16:09:14 +00:00
5 changed files with 84 additions and 7 deletions
+24
View File
@@ -299,3 +299,27 @@ func TestNotFound(t *testing.T) {
t.Fatalf("expected 404, got %d", rec.Code) t.Fatalf("expected 404, got %d", rec.Code)
} }
} }
// #173: a missing paste ID on the UI route (/p/{id}, i.e. /{id} HTML view)
// should render the main UI page with a friendly "Paste ID not found"
// message, not a bare text 404. Status stays 404.
func TestPasteViewNotFoundFriendly(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("GET", "/zzzzzz", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 status, got %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Paste ID not found") {
t.Fatalf("expected friendly message in body, got: %.200s", body)
}
if !strings.Contains(body, "<nav>") {
t.Fatalf("expected main UI chrome in body")
}
if strings.Contains(body, "404 page not found") {
t.Fatalf("body still contains bare Go 404 text")
}
}
+6 -3
View File
@@ -129,9 +129,12 @@ func (a *apiServer) routes() http.Handler {
r.Get("/{id}", a.handlePasteView) r.Get("/{id}", a.handlePasteView)
r.Post("/{id}", a.handlePasteView) r.Post("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) { // #173: missing paste URLs render the main UI with a friendly not-found
writeErr(w, 404, "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 return r
} }
+3
View File
@@ -341,6 +341,9 @@ td a.slug.paste-name { background: none; padding: 0; border-radius: 0; font-fami
} }
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; } .inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; } .inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
/* #173: missing paste message box */
.notfound-err { color: var(--err); }
.notfound-card .btn { display: block; margin-top: 10px; }
.pwinput { .pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center; background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float notfound-card">
<div class="inner">
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<h1>Paste ID not found</h1>
<p class="sub">The paste <span class="slug">/{{.ID}}</span> does not exist, has expired, or was burned.</p>
<a class="btn" href="/new">Create a new paste</a>
</div>
</div>
</div>
{{template "foot" .}}
+38 -4
View File
@@ -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). // RenderPage is the exported wrapper used by the api package (#4 can pages).
func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) { func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) {
h.renderPage(w, name, data) 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"}`)) 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) { func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1 lines := strings.Count(row.Content, "\n") + 1
gutter := "" gutter := ""
@@ -262,11 +293,14 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
return return
} }
if row == nil { 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 return
} }
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { 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 return
} }
if row.PasswordHash.Valid { 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. // Just-created first render does not count as a read for the creator.
if !justCreated { if !justCreated {
rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin()) rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
if !admitted { // #58: lost the burn claim; do not render content if !admitted { // #58: lost the burn claim; #173: friendly not-found UI
http.NotFound(w, r) h.renderNotFound(w, r, row.ID)
return return
} }
h.renderPaste(w, row, false, "", rem) h.renderPaste(w, row, false, "", rem)