Commit 075af6c002

075af6c002f84e2edcbd793668a23239fbe18636

parent: d298215429

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 17:03 UTC

web: designed 404 page

New notFound helper renders 404.html with a 404 status (plain-text
fallback if the template fails); web.go page handlers switch to it.
Private and missing resources stay indistinguishable. Empty states for
issues, MRs, logs, refs, and empty repos landed with their pages.

Layout: unified · split

internal/httpd/web.go +27 −14
@@ -55,6 +55,19 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
55 w.Write(web.FaviconSVG) 55 w.Write(web.FaviconSVG)
56} 56}
57 57
58// notFound renders the designed 404 page with a 404 status. Falls back to
59// the stock plain-text response if the template fails.
60func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
61 var buf bytes.Buffer
62 if err := web.Render(&buf, "404.html", struct{ Site string }{s.siteName()}); err != nil {
63 http.NotFound(w, r)
64 return
65 }
66 w.Header().Set("Content-Type", "text/html; charset=utf-8")
67 w.WriteHeader(http.StatusNotFound)
68 buf.WriteTo(w)
69}
70
58// describedRepo pairs a repo with its description for listings. 71// describedRepo pairs a repo with its description for listings.
59type describedRepo struct { 72type describedRepo struct {
60 store.Repo 73 store.Repo
@@ -129,7 +142,7 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
129 ok = policyCanRead(viewer, repo, grant) 142 ok = policyCanRead(viewer, repo, grant)
130 } 143 }
131 if !ok { 144 if !ok {
132 http.NotFound(w, r) 145 s.notFound(w, r)
133 return repoPage{}, false 146 return repoPage{}, false
134 } 147 }
135 if ref == "" { 148 if ref == "" {
@@ -186,7 +199,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
186 kind, ownerID = "org", o.ID 199 kind, ownerID = "org", o.ID
187 members, _ = s.st.OrgMembers(o.ID) 200 members, _ = s.st.OrgMembers(o.ID)
188 } else { 201 } else {
189 http.NotFound(w, r) 202 s.notFound(w, r)
190 return 203 return
191 } 204 }
192 profile, _ := s.st.OwnerProfile(kind, ownerID) 205 profile, _ := s.st.OwnerProfile(kind, ownerID)
@@ -251,7 +264,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
251 } 264 }
252 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath) 265 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
253 if err != nil { 266 if err != nil {
254 http.NotFound(w, r) 267 s.notFound(w, r)
255 return 268 return
256 } 269 }
257 prefix := "" 270 prefix := ""
@@ -286,7 +299,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
286 filePath := strings.Trim(r.PathValue("path"), "/") 299 filePath := strings.Trim(r.PathValue("path"), "/")
287 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1) 300 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
288 if err != nil { 301 if err != nil {
289 http.NotFound(w, r) 302 s.notFound(w, r)
290 return 303 return
291 } 304 }
292 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes 305 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
@@ -338,7 +351,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
338 filePath := strings.Trim(r.PathValue("path"), "/") 351 filePath := strings.Trim(r.PathValue("path"), "/")
339 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes) 352 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
340 if err != nil { 353 if err != nil {
341 http.NotFound(w, r) 354 s.notFound(w, r)
342 return 355 return
343 } 356 }
344 // Serve inert: never let repo content execute in the forge's origin. 357 // Serve inert: never let repo content execute in the forge's origin.
@@ -577,7 +590,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
577 const pageSize = 50 590 const pageSize = 50
578 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1) 591 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
579 if err != nil { 592 if err != nil {
580 http.NotFound(w, r) 593 s.notFound(w, r)
581 return 594 return
582 } 595 }
583 next := "" 596 next := ""
@@ -617,12 +630,12 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
617 sha := r.PathValue("sha") 630 sha := r.PathValue("sha")
618 full, err := gitutil.ResolveRef(p.Dir, sha) 631 full, err := gitutil.ResolveRef(p.Dir, sha)
619 if err != nil { 632 if err != nil {
620 http.NotFound(w, r) 633 s.notFound(w, r)
621 return 634 return
622 } 635 }
623 v, parsed := s.sigFor(p.Repo, p.Dir, full) 636 v, parsed := s.sigFor(p.Repo, p.Dir, full)
624 if parsed == nil { 637 if parsed == nil {
625 http.NotFound(w, r) 638 s.notFound(w, r)
626 return 639 return
627 } 640 }
628 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) 641 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
@@ -708,12 +721,12 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
708 p.Tab = "issues" 721 p.Tab = "issues"
709 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) 722 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
710 if err != nil { 723 if err != nil {
711 http.NotFound(w, r) 724 s.notFound(w, r)
712 return 725 return
713 } 726 }
714 iss, err := s.st.IssueByNumber(p.Repo.ID, n) 727 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
715 if err != nil { 728 if err != nil {
716 http.NotFound(w, r) 729 s.notFound(w, r)
717 return 730 return
718 } 731 }
719 comments, err := s.st.ListIssueComments(iss.ID) 732 comments, err := s.st.ListIssueComments(iss.ID)
@@ -764,12 +777,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
764 p.Tab = "merge requests" 777 p.Tab = "merge requests"
765 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) 778 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
766 if err != nil { 779 if err != nil {
767 http.NotFound(w, r) 780 s.notFound(w, r)
768 return 781 return
769 } 782 }
770 m, err := s.st.MRByNumber(p.Repo.ID, n) 783 m, err := s.st.MRByNumber(p.Repo.ID, n)
771 if err != nil { 784 if err != nil {
772 http.NotFound(w, r) 785 s.notFound(w, r)
773 return 786 return
774 } 787 }
775 comments, _ := s.st.ListMRComments(m.ID) 788 comments, _ := s.st.ListMRComments(m.ID)
@@ -827,11 +840,11 @@ func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
827 file := r.PathValue("file") 840 file := r.PathValue("file")
828 ref, ok := strings.CutSuffix(file, ".tar.gz") 841 ref, ok := strings.CutSuffix(file, ".tar.gz")
829 if !ok { 842 if !ok {
830 http.NotFound(w, r) 843 s.notFound(w, r)
831 return 844 return
832 } 845 }
833 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil { 846 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
834 http.NotFound(w, r) 847 s.notFound(w, r)
835 return 848 return
836 } 849 }
837 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref) 850 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
internal/web/static/style.css +12
@@ -463,6 +463,18 @@ button {
463} 463}
464button:hover { filter: brightness(1.08); } 464button:hover { filter: brightness(1.08); }
465 465
466/* 404 */
467.notfound { text-align: center; padding: var(--sp-6) 0; }
468.notfound .bigcode {
469 font-family: var(--mono);
470 font-size: 4rem;
471 color: var(--faint);
472 -webkit-text-stroke: 1px var(--line);
473 margin: 0;
474 line-height: 1;
475}
476.notfound h1 { margin-top: var(--sp-3); }
477
466/* footer */ 478/* footer */
467footer { 479footer {
468 max-width: 64rem; 480 max-width: 64rem;
internal/web/templates/404.html added +10
@@ -0,0 +1,10 @@
1{{define "title"}}not found · {{.Site}}{{end}}
2{{define "content"}}
3<div class="notfound">
4 <p class="bigcode">404</p>
5 <h1>page not found</h1>
6 <p class="desc">nothing lives at this address — the path may be wrong,
7 or the repository may be private.</p>
8 <p><a href="/">← back to {{.Site}}</a></p>
9</div>
10{{end}}