tenseleyflow/shithub / 912d82a

Browse files

S17: tree/blob/raw/finder handlers + chroma css route + repo home redirect to tree

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
912d82ac7fecf38689281d08ad53f6043cabe835
Parents
8065d54
Tree
7c89d66

5 changed files

StatusFile+-
A internal/web/handlers/chroma_css.go 30 0
M internal/web/handlers/handlers.go 13 0
A internal/web/handlers/repo/code.go 439 0
M internal/web/handlers/repo/repo.go 10 18
M internal/web/server.go 1 0
internal/web/handlers/chroma_css.goadded
@@ -0,0 +1,30 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"net/http"
7
+	"sync"
8
+
9
+	"github.com/tenseleyFlow/shithub/internal/repos/highlight"
10
+)
11
+
12
+// chromaCSSHandler serves the runtime-generated Chroma stylesheet at
13
+// /static/css/chroma.css. The CSS is theme-derived (Chroma's `github`
14
+// style) and immutable for the process lifetime, so we cache it once.
15
+//
16
+// Long-cache headers are safe because changing the theme would also
17
+// require a binary upgrade — the operator's restart invalidates the
18
+// upstream cache.
19
+func chromaCSSHandler() http.HandlerFunc {
20
+	var (
21
+		once sync.Once
22
+		body []byte
23
+	)
24
+	return func(w http.ResponseWriter, _ *http.Request) {
25
+		once.Do(func() { body = []byte(highlight.CSS()) })
26
+		w.Header().Set("Content-Type", "text/css; charset=utf-8")
27
+		w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
28
+		_, _ = w.Write(body)
29
+	}
30
+}
internal/web/handlers/handlers.gomodified
@@ -59,6 +59,10 @@ type Deps struct {
5959
 	// transfer accept/decline/cancel, inbox). All routes are auth-
6060
 	// required; the handler enforces policy.Can per route.
6161
 	RepoLifecycleMounter func(chi.Router)
62
+	// RepoCodeMounter, when non-nil, registers /tree/* /blob/* /raw/*
63
+	// /find/* under the repo two-segment prefix. Public for read; the
64
+	// handler runs the policy gate per request.
65
+	RepoCodeMounter func(chi.Router)
6266
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
6367
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
6468
 	// land in a route group that bypasses CSRF, response compression,
@@ -116,6 +120,10 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
116120
 		r.Use(middleware.Compress)
117121
 		r.Use(middleware.Timeout(30 * time.Second))
118122
 		r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
123
+		// S17: Chroma highlight CSS is generated at runtime from the
124
+		// theme; serve under /static/css/chroma.css so the layout can
125
+		// link it without a build step.
126
+		r.Get("/static/css/chroma.css", chromaCSSHandler())
119127
 		r.Get("/healthz", healthz)
120128
 		r.Handle("/readyz", readinessHandler(deps.ReadyCheck, deps.Logger))
121129
 		if deps.MetricsHandler != nil {
@@ -158,6 +166,11 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
158166
 		if deps.RepoNewMounter != nil {
159167
 			deps.RepoNewMounter(r)
160168
 		}
169
+		// Code-tab routes register BEFORE RepoHome's two-segment route
170
+		// so /{owner}/{repo}/tree/* doesn't get swallowed.
171
+		if deps.RepoCodeMounter != nil {
172
+			deps.RepoCodeMounter(r)
173
+		}
161174
 		if deps.RepoHomeMounter != nil {
162175
 			deps.RepoHomeMounter(r)
163176
 		}
internal/web/handlers/repo/code.goadded
@@ -0,0 +1,439 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"bytes"
7
+	"errors"
8
+	"fmt"
9
+	"html/template"
10
+	"net/http"
11
+	"path"
12
+	"strings"
13
+
14
+	"github.com/go-chi/chi/v5"
15
+	"github.com/jackc/pgx/v5/pgtype"
16
+
17
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
18
+	"github.com/tenseleyFlow/shithub/internal/repos/finder"
19
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
20
+	"github.com/tenseleyFlow/shithub/internal/repos/highlight"
21
+	mdrender "github.com/tenseleyFlow/shithub/internal/repos/markdown"
22
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
23
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
24
+)
25
+
26
+// MountCode registers the code-tab routes:
27
+//
28
+//	GET /{owner}/{repo}/tree/*
29
+//	GET /{owner}/{repo}/blob/*
30
+//	GET /{owner}/{repo}/raw/*
31
+//	GET /{owner}/{repo}/find/*
32
+//
33
+// The leading {ref} segment is variable-length (refs may contain `/`).
34
+// chi's `*` wildcard captures the rest; we resolve ref + path inside
35
+// the handler against the repo's known ref list.
36
+func (h *Handlers) MountCode(r chi.Router) {
37
+	r.Get("/{owner}/{repo}/tree/*", h.codeTree)
38
+	r.Get("/{owner}/{repo}/blob/*", h.codeBlob)
39
+	r.Get("/{owner}/{repo}/raw/*", h.codeRaw)
40
+	r.Get("/{owner}/{repo}/find/*", h.codeFinder)
41
+}
42
+
43
+// codeContext bundles the per-request data the code-tab handlers
44
+// derive once at the top. Owner+repo come from chi; ref+path come from
45
+// the wildcard, resolved against the repo's ref list.
46
+type codeContext struct {
47
+	owner    string
48
+	row      reposdb.Repo
49
+	gitDir   string
50
+	refs     repogit.RefListing
51
+	allRefs  []string
52
+	ref      string // matched ref name (or 40-hex sha)
53
+	subpath  string // path inside the ref, no leading slash
54
+}
55
+
56
+// loadCodeContext does the resolve dance for tree/blob/raw/find. On
57
+// any failure it writes the response and returns ok=false.
58
+func (h *Handlers) loadCodeContext(w http.ResponseWriter, r *http.Request) (*codeContext, bool) {
59
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
60
+	if !ok {
61
+		return nil, false
62
+	}
63
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
64
+	if err != nil {
65
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
66
+		return nil, false
67
+	}
68
+	refs, err := repogit.ListRefs(r.Context(), gitDir)
69
+	if err != nil {
70
+		h.d.Logger.WarnContext(r.Context(), "code: ListRefs", "error", err)
71
+	}
72
+	allNames := make([]string, 0, len(refs.Branches)+len(refs.Tags))
73
+	for _, b := range refs.Branches {
74
+		allNames = append(allNames, b.Name)
75
+	}
76
+	for _, t := range refs.Tags {
77
+		allNames = append(allNames, t.Name)
78
+	}
79
+
80
+	rest := chi.URLParam(r, "*")
81
+	rest = strings.Trim(rest, "/")
82
+	segs := []string{}
83
+	if rest != "" {
84
+		segs = strings.Split(rest, "/")
85
+	}
86
+	if len(segs) == 0 {
87
+		// no ref → default branch root tree
88
+		ref := row.DefaultBranch
89
+		return &codeContext{
90
+			owner: owner.Username, row: row, gitDir: gitDir,
91
+			refs: refs, allRefs: allNames, ref: ref, subpath: "",
92
+		}, true
93
+	}
94
+	ref, sub, ok2 := repogit.ResolveRef(allNames, segs)
95
+	if !ok2 {
96
+		// Fallback: if the first segment looks like a hex sha, accept it.
97
+		if len(segs[0]) == 40 && isHex(segs[0]) {
98
+			ref = segs[0]
99
+			sub = strings.Join(segs[1:], "/")
100
+		} else {
101
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
102
+			return nil, false
103
+		}
104
+	}
105
+	if !validateSubpath(sub) {
106
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
107
+		return nil, false
108
+	}
109
+	return &codeContext{
110
+		owner: owner.Username, row: row, gitDir: gitDir,
111
+		refs: refs, allRefs: allNames, ref: ref, subpath: sub,
112
+	}, true
113
+}
114
+
115
+// codeTree renders the directory listing at <ref>:<subpath>. If the
116
+// path turns out to be a blob, redirects to /blob/. README rendering
117
+// for tree-roots is appended below the listing.
118
+func (h *Handlers) codeTree(w http.ResponseWriter, r *http.Request) {
119
+	cc, ok := h.loadCodeContext(w, r)
120
+	if !ok {
121
+		return
122
+	}
123
+	kind, _, _, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
124
+	if err != nil {
125
+		if errors.Is(err, repogit.ErrPathNotFound) {
126
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
127
+			return
128
+		}
129
+		h.d.Logger.WarnContext(r.Context(), "code: StatPath", "error", err)
130
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
131
+		return
132
+	}
133
+	if kind == repogit.EntryBlob {
134
+		http.Redirect(w, r, "/"+cc.owner+"/"+cc.row.Name+"/blob/"+cc.ref+"/"+cc.subpath, http.StatusSeeOther)
135
+		return
136
+	}
137
+	entries, err := repogit.LsTree(r.Context(), cc.gitDir, cc.ref, cc.subpath)
138
+	if err != nil {
139
+		if errors.Is(err, repogit.ErrNotATree) {
140
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
141
+			return
142
+		}
143
+		h.d.Logger.WarnContext(r.Context(), "code: LsTree", "error", err)
144
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
145
+		return
146
+	}
147
+	// README detection on the requested directory only.
148
+	readmeHTML := h.findAndRenderREADME(r, cc, entries)
149
+
150
+	h.d.Render.RenderPage(w, r, "repo/tree", map[string]any{
151
+		"Title":     cc.row.Name + " · " + cc.owner,
152
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
153
+		"Owner":     cc.owner,
154
+		"Repo":      cc.row,
155
+		"Ref":       cc.ref,
156
+		"Path":      cc.subpath,
157
+		"Crumbs":    breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
158
+		"Entries":   entries,
159
+		"Branches":  cc.refs.Branches,
160
+		"Tags":      cc.refs.Tags,
161
+		"README":    template.HTML(readmeHTML), //nolint:gosec // sanitized by mdrender
162
+	})
163
+}
164
+
165
+// findAndRenderREADME looks for README* in the supplied entries (case-
166
+// insensitive). Returns rendered HTML for markdown sources; returns a
167
+// `<pre>`-wrapped escaped string for non-markdown text. Empty when
168
+// no README is present.
169
+func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries []repogit.TreeEntry) string {
170
+	const maxREADMEBytes = 1 * 1024 * 1024 // 1 MiB cap
171
+	for _, e := range entries {
172
+		if e.Kind != repogit.EntryBlob {
173
+			continue
174
+		}
175
+		lower := strings.ToLower(e.Name)
176
+		if !strings.HasPrefix(lower, "readme") {
177
+			continue
178
+		}
179
+		full := joinPath(cc.subpath, e.Name)
180
+		body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, full, maxREADMEBytes)
181
+		if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) {
182
+			return ""
183
+		}
184
+		// Markdown: render via Goldmark + sanitizer.
185
+		if hasExt(lower, []string{".md", ".markdown"}) {
186
+			out, mderr := mdrender.RenderHTML(body)
187
+			if mderr == nil {
188
+				return out
189
+			}
190
+		}
191
+		// Non-markdown plain text: escape + <pre>.
192
+		return "<pre class=\"shithub-readme-plain\">" + template.HTMLEscapeString(string(body)) + "</pre>"
193
+	}
194
+	return ""
195
+}
196
+
197
+func hasExt(filename string, exts []string) bool {
198
+	for _, e := range exts {
199
+		if strings.HasSuffix(filename, e) {
200
+			return true
201
+		}
202
+	}
203
+	return false
204
+}
205
+
206
+// codeBlob renders the file viewer.
207
+func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) {
208
+	cc, ok := h.loadCodeContext(w, r)
209
+	if !ok {
210
+		return
211
+	}
212
+	kind, _, size, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
213
+	if err != nil || kind != repogit.EntryBlob {
214
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
215
+		return
216
+	}
217
+	const largeFileThreshold = 1 * 1024 * 1024 // 1 MiB
218
+	const maxReadBytes = 4 * 1024 * 1024       // never read more than 4 MiB even for highlighting
219
+
220
+	data := map[string]any{
221
+		"Title":      cc.subpath + " · " + cc.row.Name,
222
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
223
+		"Owner":      cc.owner,
224
+		"Repo":       cc.row,
225
+		"Ref":        cc.ref,
226
+		"Path":       cc.subpath,
227
+		"Crumbs":     breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
228
+		"Branches":   cc.refs.Branches,
229
+		"Tags":       cc.refs.Tags,
230
+		"Size":       size,
231
+		"IsLarge":    size > largeFileThreshold,
232
+		"IsBinary":   false,
233
+		"IsImage":    false,
234
+		"IsMarkdown": false,
235
+		"Language":   highlight.LanguageGuess(cc.subpath),
236
+	}
237
+	if size > largeFileThreshold {
238
+		h.d.Render.RenderPage(w, r, "repo/blob", data)
239
+		return
240
+	}
241
+	body, err := repogit.ReadBlobBytes(r.Context(), cc.gitDir, cc.ref, cc.subpath, maxReadBytes)
242
+	if err != nil && !errors.Is(err, repogit.ErrBlobTooLarge) {
243
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
244
+		return
245
+	}
246
+	if isBinary(body) {
247
+		data["IsBinary"] = true
248
+		if isImageExt(cc.subpath) && size <= 5*1024*1024 {
249
+			data["IsImage"] = true
250
+		}
251
+		h.d.Render.RenderPage(w, r, "repo/blob", data)
252
+		return
253
+	}
254
+	// Text path: highlight or markdown-render.
255
+	if hasExt(strings.ToLower(cc.subpath), []string{".md", ".markdown"}) {
256
+		data["IsMarkdown"] = true
257
+		rendered, mderr := mdrender.RenderHTML(body)
258
+		if mderr == nil {
259
+			data["MarkdownHTML"] = template.HTML(rendered) //nolint:gosec // sanitized
260
+		}
261
+		data["RawSource"] = string(body)
262
+	}
263
+	highlighted := highlight.Render(cc.subpath, string(body))
264
+	data["HighlightedHTML"] = template.HTML(highlighted) //nolint:gosec // chroma + classes
265
+	h.d.Render.RenderPage(w, r, "repo/blob", data)
266
+}
267
+
268
+// codeRaw streams the raw bytes. Force `attachment` for executable
269
+// content types (HTML/SVG/JS/etc.) since shithub doesn't have a
270
+// separate raw host.
271
+func (h *Handlers) codeRaw(w http.ResponseWriter, r *http.Request) {
272
+	cc, ok := h.loadCodeContext(w, r)
273
+	if !ok {
274
+		return
275
+	}
276
+	kind, _, size, err := repogit.StatPath(r.Context(), cc.gitDir, cc.ref, cc.subpath)
277
+	if err != nil || kind != repogit.EntryBlob {
278
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
279
+		return
280
+	}
281
+	contentType, forceAttachment := rawContentType(cc.subpath)
282
+	w.Header().Set("Content-Type", contentType)
283
+	w.Header().Set("Cache-Control", "private, max-age=0, must-revalidate")
284
+	w.Header().Set("X-Content-Type-Options", "nosniff")
285
+	w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
286
+	if forceAttachment {
287
+		w.Header().Set("Content-Disposition", `attachment; filename="`+path.Base(cc.subpath)+`"`)
288
+	}
289
+	if size > 0 {
290
+		w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
291
+	}
292
+	if err := repogit.StreamBlob(r.Context(), cc.gitDir, cc.ref, cc.subpath, w); err != nil {
293
+		h.d.Logger.WarnContext(r.Context(), "code: stream raw", "error", err)
294
+	}
295
+}
296
+
297
+// codeFinder serves /find/{ref} — full list pre-filtered by `q`.
298
+func (h *Handlers) codeFinder(w http.ResponseWriter, r *http.Request) {
299
+	cc, ok := h.loadCodeContext(w, r)
300
+	if !ok {
301
+		return
302
+	}
303
+	paths, err := repogit.ListAllPaths(r.Context(), cc.gitDir, cc.ref)
304
+	if err != nil {
305
+		h.d.Logger.WarnContext(r.Context(), "code: ListAllPaths", "error", err)
306
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
307
+		return
308
+	}
309
+	q := r.URL.Query().Get("q")
310
+	matches := finder.Filter(paths, q, 200)
311
+	h.d.Render.RenderPage(w, r, "repo/finder", map[string]any{
312
+		"Title":     "Find file · " + cc.row.Name,
313
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
314
+		"Owner":     cc.owner,
315
+		"Repo":      cc.row,
316
+		"Ref":       cc.ref,
317
+		"Query":     q,
318
+		"Matches":   matches,
319
+		"Branches":  cc.refs.Branches,
320
+		"Tags":      cc.refs.Tags,
321
+	})
322
+}
323
+
324
+// breadcrumbs returns the click-each-segment slice for the tree/blob
325
+// header.
326
+type Breadcrumb struct {
327
+	Name string
328
+	URL  string
329
+}
330
+
331
+func breadcrumbs(owner, repoName, ref, subpath string) []Breadcrumb {
332
+	out := []Breadcrumb{
333
+		{Name: repoName, URL: fmt.Sprintf("/%s/%s/tree/%s", owner, repoName, ref)},
334
+	}
335
+	if subpath == "" {
336
+		return out
337
+	}
338
+	parts := strings.Split(subpath, "/")
339
+	for i, p := range parts {
340
+		out = append(out, Breadcrumb{
341
+			Name: p,
342
+			URL:  fmt.Sprintf("/%s/%s/tree/%s/%s", owner, repoName, ref, strings.Join(parts[:i+1], "/")),
343
+		})
344
+	}
345
+	return out
346
+}
347
+
348
+// validateSubpath is the path-traversal guard. Reject `..`, control
349
+// chars, leading slash, and `\`.
350
+func validateSubpath(p string) bool {
351
+	if p == "" {
352
+		return true
353
+	}
354
+	if strings.HasPrefix(p, "/") || strings.Contains(p, "\\") {
355
+		return false
356
+	}
357
+	for _, seg := range strings.Split(p, "/") {
358
+		if seg == "" || seg == ".." {
359
+			return false
360
+		}
361
+		for _, c := range seg {
362
+			if c < 0x20 || c == 0x7f {
363
+				return false
364
+			}
365
+		}
366
+	}
367
+	return true
368
+}
369
+
370
+func isHex(s string) bool {
371
+	for _, c := range s {
372
+		switch {
373
+		case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
374
+		default:
375
+			return false
376
+		}
377
+	}
378
+	return true
379
+}
380
+
381
+// rawContentType maps an extension to (Content-Type, forceAttachment).
382
+// Executable content types force `attachment` to defeat XSS via raw view
383
+// (no separate raw.host yet).
384
+func rawContentType(p string) (string, bool) {
385
+	ext := strings.ToLower(path.Ext(p))
386
+	switch ext {
387
+	case ".html", ".htm", ".xhtml", ".svg", ".js", ".mjs", ".wasm":
388
+		return "text/plain; charset=utf-8", true
389
+	case ".png":
390
+		return "image/png", false
391
+	case ".jpg", ".jpeg":
392
+		return "image/jpeg", false
393
+	case ".gif":
394
+		return "image/gif", false
395
+	case ".webp":
396
+		return "image/webp", false
397
+	case ".pdf":
398
+		return "application/pdf", false
399
+	case ".css":
400
+		return "text/css; charset=utf-8", false
401
+	case ".json":
402
+		return "application/json; charset=utf-8", false
403
+	case ".txt", ".md", ".markdown", ".yml", ".yaml", ".toml", ".log":
404
+		return "text/plain; charset=utf-8", false
405
+	default:
406
+		// Sniff by inspecting the body would be ideal, but we already
407
+		// stream — fall back to text/plain for safety.
408
+		return "text/plain; charset=utf-8", false
409
+	}
410
+}
411
+
412
+func isImageExt(p string) bool {
413
+	switch strings.ToLower(path.Ext(p)) {
414
+	case ".png", ".jpg", ".jpeg", ".gif", ".webp":
415
+		return true
416
+	}
417
+	return false
418
+}
419
+
420
+// isBinary scans the first 8 KiB for a NUL byte.
421
+func isBinary(b []byte) bool {
422
+	const window = 8192
423
+	if len(b) > window {
424
+		b = b[:window]
425
+	}
426
+	return bytes.IndexByte(b, 0) >= 0
427
+}
428
+
429
+// joinPath joins two slash-separated paths, ignoring an empty parent.
430
+func joinPath(parent, child string) string {
431
+	if parent == "" {
432
+		return child
433
+	}
434
+	return parent + "/" + child
435
+}
436
+
437
+// silence pgtype unused-import warning when the loadRepoAndAuthorize
438
+// helper is in this file's package but defined elsewhere.
439
+var _ = pgtype.Int8{}
internal/web/handlers/repo/repo.gomodified
@@ -192,6 +192,9 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
192192
 		return
193193
 	}
194194
 
195
+	// S17: when the repo has any branch, the canonical view is the
196
+	// tree at default_branch. The S11 quick-setup placeholder still
197
+	// covers the empty case.
195198
 	diskPath, fsErr := h.d.RepoFS.RepoPath(owner, row.Name)
196199
 	hasBranch := false
197200
 	if fsErr == nil {
@@ -201,6 +204,10 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
201204
 			h.d.Logger.WarnContext(r.Context(), "repo: HasAnyBranch", "error", herr)
202205
 		}
203206
 	}
207
+	if hasBranch {
208
+		http.Redirect(w, r, "/"+owner+"/"+row.Name+"/tree/"+row.DefaultBranch, http.StatusSeeOther)
209
+		return
210
+	}
204211
 
205212
 	common := map[string]any{
206213
 		"Title":         row.Name + " · " + owner,
@@ -214,24 +221,9 @@ func (h *Handlers) repoHome(w http.ResponseWriter, r *http.Request) {
214221
 	}
215222
 
216223
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
217
-	if !hasBranch {
218
-		if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil {
219
-			h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
220
-		}
221
-		return
222
-	}
223
-
224
-	// Populated path. Look up the head of the default branch — if missing
225
-	// (push went to a non-default branch only), fall through to a
226
-	// branch-not-yet-on-default note.
227
-	head, found, herr := repogit.HeadOf(r.Context(), diskPath, row.DefaultBranch)
228
-	if herr != nil {
229
-		h.d.Logger.WarnContext(r.Context(), "repo: HeadOf", "error", herr)
230
-	}
231
-	common["HeadFound"] = found
232
-	common["Head"] = head
233
-	if err := h.d.Render.RenderPage(w, r, "repo/populated", common); err != nil {
234
-		h.d.Logger.ErrorContext(r.Context(), "repo: render populated", "error", err)
224
+	// Empty path only — populated repos already redirected above.
225
+	if err := h.d.Render.RenderPage(w, r, "repo/empty", common); err != nil {
226
+		h.d.Logger.ErrorContext(r.Context(), "repo: render empty", "error", err)
235227
 	}
236228
 }
237229
 
internal/web/server.gomodified
@@ -186,6 +186,7 @@ func Run(ctx context.Context, opts Options) error {
186186
 			})
187187
 		}
188188
 		deps.RepoHomeMounter = repoH.MountRepoHome
189
+		deps.RepoCodeMounter = repoH.MountCode
189190
 		// Lifecycle danger-zone routes — also auth-required.
190191
 		deps.RepoLifecycleMounter = func(r chi.Router) {
191192
 			r.Group(func(r chi.Router) {