tenseleyflow/shithub / ce07386

Browse files

S18: commits/commit/blame/atom handlers + history mounter

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ce07386c4dd59e60b75299e7f2a142802af90b13
Parents
f424d35
Tree
31e5dec

4 changed files

StatusFile+-
M internal/web/handlers/handlers.go 9 2
A internal/web/handlers/repo/atom.go 71 0
A internal/web/handlers/repo/history.go 303 0
M internal/web/server.go 1 0
internal/web/handlers/handlers.gomodified
@@ -63,6 +63,9 @@ type Deps struct {
6363
 	// /find/* under the repo two-segment prefix. Public for read; the
6464
 	// handler runs the policy gate per request.
6565
 	RepoCodeMounter func(chi.Router)
66
+	// RepoHistoryMounter registers /commits/{ref}, /commit/{sha},
67
+	// /blame/{ref}/{path...}, /commits/{ref}.atom (S18).
68
+	RepoHistoryMounter func(chi.Router)
6669
 	// GitHTTPMounter, when non-nil, registers the smart-HTTP git routes
6770
 	// (`*.git/info/refs`, `git-upload-pack`, `git-receive-pack`). MUST
6871
 	// land in a route group that bypasses CSRF, response compression,
@@ -166,11 +169,15 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
166169
 		if deps.RepoNewMounter != nil {
167170
 			deps.RepoNewMounter(r)
168171
 		}
169
-		// Code-tab routes register BEFORE RepoHome's two-segment route
170
-		// so /{owner}/{repo}/tree/* doesn't get swallowed.
172
+		// Code-tab + history routes register BEFORE RepoHome's two-segment
173
+		// route so /{owner}/{repo}/tree/* and /commit/* don't get
174
+		// swallowed.
171175
 		if deps.RepoCodeMounter != nil {
172176
 			deps.RepoCodeMounter(r)
173177
 		}
178
+		if deps.RepoHistoryMounter != nil {
179
+			deps.RepoHistoryMounter(r)
180
+		}
174181
 		if deps.RepoHomeMounter != nil {
175182
 			deps.RepoHomeMounter(r)
176183
 		}
internal/web/handlers/repo/atom.goadded
@@ -0,0 +1,71 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"encoding/xml"
7
+	"fmt"
8
+	"io"
9
+	"time"
10
+
11
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
12
+)
13
+
14
+// writeAtom emits an RFC 4287 Atom feed of the most recent commits.
15
+// The feed is intentionally minimal — title, author, updated, link,
16
+// per-entry id/title/updated/author/summary. Skips fancy bits like
17
+// `<content>` HTML to keep the surface tight (S25 owns rich
18
+// content-rendering; this feed targets bots and aggregators).
19
+func writeAtom(w io.Writer, owner, repoName, ref string, commits []repogit.Commit) {
20
+	type atomAuthor struct {
21
+		Name  string `xml:"name"`
22
+		Email string `xml:"email,omitempty"`
23
+	}
24
+	type atomEntry struct {
25
+		ID      string     `xml:"id"`
26
+		Title   string     `xml:"title"`
27
+		Updated string     `xml:"updated"`
28
+		Author  atomAuthor `xml:"author"`
29
+		Summary string     `xml:"summary"`
30
+		Link    struct {
31
+			Href string `xml:"href,attr"`
32
+			Rel  string `xml:"rel,attr,omitempty"`
33
+		} `xml:"link"`
34
+	}
35
+	type atomFeed struct {
36
+		XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
37
+		Title   string   `xml:"title"`
38
+		ID      string   `xml:"id"`
39
+		Updated string   `xml:"updated"`
40
+		Link    struct {
41
+			Href string `xml:"href,attr"`
42
+			Rel  string `xml:"rel,attr,omitempty"`
43
+		} `xml:"link"`
44
+		Entries []atomEntry `xml:"entry"`
45
+	}
46
+
47
+	now := time.Now().UTC().Format(time.RFC3339)
48
+	feed := atomFeed{
49
+		Title:   fmt.Sprintf("%s/%s commits — %s", owner, repoName, ref),
50
+		ID:      fmt.Sprintf("urn:shithub:%s:%s:%s", owner, repoName, ref),
51
+		Updated: now,
52
+	}
53
+	feed.Link.Href = fmt.Sprintf("/%s/%s/commits/%s", owner, repoName, ref)
54
+	feed.Link.Rel = "self"
55
+	for _, c := range commits {
56
+		var e atomEntry
57
+		e.ID = "urn:shithub:commit:" + c.OID
58
+		e.Title = c.Subject
59
+		e.Updated = c.AuthorWhen.UTC().Format(time.RFC3339)
60
+		e.Author.Name = c.AuthorName
61
+		e.Author.Email = c.AuthorEmail
62
+		e.Summary = c.Body
63
+		e.Link.Href = fmt.Sprintf("/%s/%s/commit/%s", owner, repoName, c.OID)
64
+		feed.Entries = append(feed.Entries, e)
65
+	}
66
+	enc := xml.NewEncoder(w)
67
+	enc.Indent("", "  ")
68
+	_, _ = io.WriteString(w, xml.Header)
69
+	_ = enc.Encode(feed)
70
+	_ = enc.Flush()
71
+}
internal/web/handlers/repo/history.goadded
@@ -0,0 +1,303 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package repo
4
+
5
+import (
6
+	"errors"
7
+	"html/template"
8
+	"net/http"
9
+	"regexp"
10
+	"strconv"
11
+	"strings"
12
+	"time"
13
+
14
+	"github.com/go-chi/chi/v5"
15
+
16
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
17
+	"github.com/tenseleyFlow/shithub/internal/repos/git"
18
+	repogit "github.com/tenseleyFlow/shithub/internal/repos/git"
19
+	"github.com/tenseleyFlow/shithub/internal/repos/identity"
20
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
21
+)
22
+
23
+// MountHistory registers the S18 routes:
24
+//
25
+//	GET /{owner}/{repo}/commits/{ref}/*       — list, with ?path= filter
26
+//	GET /{owner}/{repo}/commits/{ref}.atom    — Atom feed
27
+//	GET /{owner}/{repo}/commit/{sha}          — single commit
28
+//	GET /{owner}/{repo}/blame/{ref}/{path...} — blame
29
+//
30
+// Mount BEFORE /tree/{ref}/* so the more-specific paths win chi's
31
+// match-by-registration-order.
32
+func (h *Handlers) MountHistory(r chi.Router) {
33
+	// Atom is a literal-suffix match; chi can't combine wildcard with a
34
+	// trailing literal, so we keep `{ref}.atom` for atom-only and use
35
+	// `commits/*` for the HTML list (which resolves ref-with-slash).
36
+	r.Get("/{owner}/{repo}/commits/{ref}.atom", h.commitsAtom)
37
+	r.Get("/{owner}/{repo}/commits/*", h.commitsList)
38
+	r.Get("/{owner}/{repo}/commit/{sha}", h.commitView)
39
+	r.Get("/{owner}/{repo}/blame/*", h.blameView)
40
+}
41
+
42
+// commitsList renders the paginated commit history for a ref, with an
43
+// optional `path` filter (set when the user clicks "history" on a blob
44
+// or follows the deferred-from-S17 tree column).
45
+func (h *Handlers) commitsList(w http.ResponseWriter, r *http.Request) {
46
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
47
+	if !ok {
48
+		return
49
+	}
50
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
51
+	if err != nil {
52
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
53
+		return
54
+	}
55
+	refs, _ := repogit.ListRefs(r.Context(), gitDir)
56
+	allNames := make([]string, 0, len(refs.Branches)+len(refs.Tags))
57
+	for _, b := range refs.Branches {
58
+		allNames = append(allNames, b.Name)
59
+	}
60
+	for _, t := range refs.Tags {
61
+		allNames = append(allNames, t.Name)
62
+	}
63
+	rest := strings.Trim(chi.URLParam(r, "*"), "/")
64
+	ref := row.DefaultBranch
65
+	if rest != "" {
66
+		segs := strings.Split(rest, "/")
67
+		if matched, _, ok := repogit.ResolveRef(allNames, segs); ok {
68
+			ref = matched
69
+		} else if len(segs[0]) == 40 && isHex(segs[0]) {
70
+			ref = segs[0]
71
+		} else {
72
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
73
+			return
74
+		}
75
+	}
76
+
77
+	q := r.URL.Query()
78
+	const perPage = 30
79
+	page, _ := strconv.Atoi(q.Get("page"))
80
+	if page < 1 {
81
+		page = 1
82
+	}
83
+	pathFilter := strings.TrimSpace(q.Get("path"))
84
+	authorFilter := strings.TrimSpace(q.Get("author"))
85
+	since := parseDateParam(q.Get("since"))
86
+	until := parseDateParam(q.Get("until"))
87
+
88
+	commits, err := repogit.Log(r.Context(), gitDir, repogit.LogOptions{
89
+		Ref:      ref,
90
+		MaxCount: perPage,
91
+		Skip:     (page - 1) * perPage,
92
+		Path:     pathFilter,
93
+		Author:   authorFilter,
94
+		Since:    since,
95
+		Until:    until,
96
+		Follow:   pathFilter != "",
97
+	})
98
+	if err != nil {
99
+		h.d.Logger.WarnContext(r.Context(), "commits: Log", "error", err)
100
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
101
+		return
102
+	}
103
+
104
+	resolver := identity.New(h.d.Pool)
105
+	rows := make([]commitRow, 0, len(commits))
106
+	for _, c := range commits {
107
+		rows = append(rows, commitRow{Commit: c, Author: resolver.Resolve(r.Context(), c.AuthorEmail)})
108
+	}
109
+
110
+	h.d.Render.RenderPage(w, r, "repo/commits", map[string]any{
111
+		"Title":      "Commits · " + row.Name,
112
+		"CSRFToken":  middleware.CSRFTokenForRequest(r),
113
+		"Owner":      owner.Username,
114
+		"Repo":       row,
115
+		"Ref":        ref,
116
+		"PathFilter": pathFilter,
117
+		"Author":     authorFilter,
118
+		"Since":      q.Get("since"),
119
+		"Until":      q.Get("until"),
120
+		"Rows":       rows,
121
+		"Page":       page,
122
+		"NextPage":   page + 1,
123
+		"PrevPage":   page - 1,
124
+		"HasMore":    len(commits) == perPage,
125
+		"Branches":   refs.Branches,
126
+		"Tags":       refs.Tags,
127
+	})
128
+}
129
+
130
+// commitView renders the single-commit page: subject + body, parents,
131
+// committer, file-changed table. Per-file diff bodies are S19 — for
132
+// now the file rows show stats only.
133
+func (h *Handlers) commitView(w http.ResponseWriter, r *http.Request) {
134
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
135
+	if !ok {
136
+		return
137
+	}
138
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
139
+	if err != nil {
140
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
141
+		return
142
+	}
143
+	sha := chi.URLParam(r, "sha")
144
+	if !validateSHA(sha) {
145
+		h.d.Render.HTTPError(w, r, http.StatusBadRequest, "")
146
+		return
147
+	}
148
+	detail, err := repogit.GetCommit(r.Context(), gitDir, sha)
149
+	if err != nil {
150
+		if errors.Is(err, repogit.ErrCommitNotFound) {
151
+			h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
152
+			return
153
+		}
154
+		h.d.Logger.WarnContext(r.Context(), "commit: GetCommit", "error", err)
155
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
156
+		return
157
+	}
158
+	resolver := identity.New(h.d.Pool)
159
+	author := resolver.Resolve(r.Context(), detail.AuthorEmail)
160
+	committer := resolver.Resolve(r.Context(), detail.CommitterEmail)
161
+
162
+	h.d.Render.RenderPage(w, r, "repo/commit", map[string]any{
163
+		"Title":     detail.Subject + " · " + row.Name,
164
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
165
+		"Owner":     owner.Username,
166
+		"Repo":      row,
167
+		"Detail":    detail,
168
+		"Author":    author,
169
+		"Committer": committer,
170
+		"BodyHTML":  template.HTML(linkifyCommitBody(detail.Body)), //nolint:gosec // escaped inside
171
+	})
172
+}
173
+
174
+// blameView renders blame for a file. Reuses codeContext so the
175
+// branch dropdown + breadcrumbs match the tree/blob pages.
176
+func (h *Handlers) blameView(w http.ResponseWriter, r *http.Request) {
177
+	cc, ok := h.loadCodeContext(w, r)
178
+	if !ok {
179
+		return
180
+	}
181
+	chunks, err := repogit.Blame(r.Context(), cc.gitDir, repogit.BlameOptions{
182
+		Ref:  cc.ref,
183
+		Path: cc.subpath,
184
+	})
185
+	tooLarge := errors.Is(err, repogit.ErrBlameTooLarge)
186
+	notBlob := errors.Is(err, repogit.ErrBlameOnBinary)
187
+	if err != nil && !tooLarge && !notBlob {
188
+		h.d.Logger.WarnContext(r.Context(), "blame: Blame", "error", err)
189
+		h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "")
190
+		return
191
+	}
192
+
193
+	resolver := identity.New(h.d.Pool)
194
+	chunkRows := make([]blameChunkRow, 0, len(chunks))
195
+	for _, c := range chunks {
196
+		chunkRows = append(chunkRows, blameChunkRow{
197
+			Chunk:  c,
198
+			Author: resolver.Resolve(r.Context(), c.AuthorEmail),
199
+		})
200
+	}
201
+
202
+	h.d.Render.RenderPage(w, r, "repo/blame", map[string]any{
203
+		"Title":     "Blame · " + cc.subpath,
204
+		"CSRFToken": middleware.CSRFTokenForRequest(r),
205
+		"Owner":     cc.owner,
206
+		"Repo":      cc.row,
207
+		"Ref":       cc.ref,
208
+		"Path":      cc.subpath,
209
+		"Crumbs":    breadcrumbs(cc.owner, cc.row.Name, cc.ref, cc.subpath),
210
+		"Chunks":    chunkRows,
211
+		"TooLarge":  tooLarge,
212
+		"NotBlob":   notBlob,
213
+	})
214
+}
215
+
216
+// commitsAtom serves the lightweight Atom feed of recent commits.
217
+func (h *Handlers) commitsAtom(w http.ResponseWriter, r *http.Request) {
218
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionRepoRead)
219
+	if !ok {
220
+		return
221
+	}
222
+	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
223
+	if err != nil {
224
+		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
225
+		return
226
+	}
227
+	ref := chi.URLParam(r, "ref")
228
+	commits, err := repogit.Log(r.Context(), gitDir, repogit.LogOptions{
229
+		Ref: ref, MaxCount: 50,
230
+	})
231
+	if err != nil {
232
+		h.d.Logger.WarnContext(r.Context(), "atom: Log", "error", err)
233
+		http.Error(w, "internal error", http.StatusInternalServerError)
234
+		return
235
+	}
236
+	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
237
+	writeAtom(w, owner.Username, row.Name, ref, commits)
238
+}
239
+
240
+// commitRow / blameChunkRow attach the resolved identity to the bare
241
+// git data so templates can render avatars and profile links without
242
+// re-running the resolver.
243
+type commitRow struct {
244
+	Commit git.Commit
245
+	Author identity.Resolved
246
+}
247
+
248
+type blameChunkRow struct {
249
+	Chunk  git.BlameChunk
250
+	Author identity.Resolved
251
+}
252
+
253
+// validateSHA accepts 7..40 hex chars. Git resolves short SHAs when
254
+// unambiguous; we cap at 40 (full).
255
+func validateSHA(s string) bool {
256
+	if len(s) < 7 || len(s) > 40 {
257
+		return false
258
+	}
259
+	return isHex(s)
260
+}
261
+
262
+// parseDateParam takes a YYYY-MM-DD param and returns a UTC time. Any
263
+// parse error returns the zero time, which the Log helper treats as
264
+// "no filter."
265
+func parseDateParam(s string) time.Time {
266
+	if s == "" {
267
+		return time.Time{}
268
+	}
269
+	t, err := time.Parse("2006-01-02", s)
270
+	if err != nil {
271
+		return time.Time{}
272
+	}
273
+	return t
274
+}
275
+
276
+// linkifyCommitBody produces escaped + linkified HTML from a commit
277
+// message body. Two transformations:
278
+//   1. URL detection (http/https) → `<a href="...">URL</a>`
279
+//   2. Issue refs (`#NNN` and `owner/repo#NNN`) → `<span data-ref="...">…</span>`
280
+//      so the S21 issue layer can post-render-link them without
281
+//      re-rendering the page.
282
+//
283
+// The output is HTML-escaped at every entry point — the only raw HTML
284
+// is the wrapper tags this function emits.
285
+func linkifyCommitBody(body string) string {
286
+	if body == "" {
287
+		return ""
288
+	}
289
+	escaped := template.HTMLEscapeString(body)
290
+	escaped = issueRefRE.ReplaceAllStringFunc(escaped, func(m string) string {
291
+		return `<span data-ref="` + template.HTMLEscapeString(m) + `">` + m + `</span>`
292
+	})
293
+	escaped = urlRE.ReplaceAllStringFunc(escaped, func(m string) string {
294
+		return `<a href="` + m + `" rel="nofollow noopener">` + m + `</a>`
295
+	})
296
+	// Preserve newlines as <br> for plaintext-style rendering.
297
+	return strings.ReplaceAll(escaped, "\n", "<br>")
298
+}
299
+
300
+var (
301
+	issueRefRE = regexp.MustCompile(`(?:[a-z0-9][a-z0-9-]*\/[a-z0-9._-]+)?#\d+`)
302
+	urlRE      = regexp.MustCompile(`https?:\/\/[^\s<>"']+`)
303
+)
internal/web/server.gomodified
@@ -187,6 +187,7 @@ func Run(ctx context.Context, opts Options) error {
187187
 		}
188188
 		deps.RepoHomeMounter = repoH.MountRepoHome
189189
 		deps.RepoCodeMounter = repoH.MountCode
190
+		deps.RepoHistoryMounter = repoH.MountHistory
190191
 		// Lifecycle danger-zone routes — also auth-required.
191192
 		deps.RepoLifecycleMounter = func(r chi.Router) {
192193
 			r.Group(func(r chi.Router) {