coverage~bigbes/sr-ht-compare9720ccc2web/handlers.go

Coverage
76.9% 130/169 statements
Δ
Blob
9c283c4
1 package web
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "html/template"
9 "log/slog"
10 "net/http"
11 "strings"
12
13 "github.com/go-chi/chi/v5"
14 "go.bigb.es/auxilia/scribe"
15 "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
16 "sourcecraft.dev/bigbes/sr-ht-ecore/login"
17
18 "sourcecraft.dev/bigbes/sr-ht-compare/authz"
19 "sourcecraft.dev/bigbes/sr-ht-compare/core"
20 "sourcecraft.dev/bigbes/sr-ht-compare/gitx"
21 )
22
23 // recentCommitLimit bounds the first-parent history shown on the repo page.
24 const recentCommitLimit = 20
25
26 // compareLogLimit bounds the commit list on the compare page.
27 const compareLogLimit = 50
28
29 // ---- JSON transport (consumed by the front-end bundle) --------------------
30
31 // jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
32 // path with NO a/ or b/ prefix.
33 type jsonFile struct {
34 Path string `json:"path"`
35 OldPath string `json:"oldPath"`
36 Status string `json:"status"`
37 Additions int `json:"additions"`
38 Deletions int `json:"deletions"`
39 Binary bool `json:"binary"`
40 }
41
42 type jsonSpec struct {
43 Base string `json:"base"`
44 Head string `json:"head"`
45 ThreeDot bool `json:"threeDot"`
46 }
47
48 type compareData struct {
49 Mode string `json:"mode"`
50 Patch string `json:"patch"`
51 Truncated bool `json:"truncated"`
52 Files []jsonFile `json:"files"`
53 Spec jsonSpec `json:"spec"`
54 }
55
56 // buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
57 // (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
58 // <script> element even when a file path contains "</script>". The bytes are
59 // returned as template.JS: any <script> is a JS context to html/template, so a
60 // plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
61 // is emitted verbatim, and the marshaler's escaping already blocks a breakout.
62 9 func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
63 9 cd := compareData{
64 9 Mode: mode,
65 9 Patch: patch.Text,
66 9 Truncated: patch.Truncated,
67 9 Files: []jsonFile{},
68 9 Spec: spec,
69 9 }
70 14 for _, f := range files {
71 14 cd.Files = append(cd.Files, jsonFile{
72 14 Path: f.Path,
73 14 OldPath: f.OldPath,
74 14 Status: f.Status,
75 14 Additions: f.Additions,
76 14 Deletions: f.Deletions,
77 14 Binary: f.Binary,
78 14 })
79 14 }
80 9 b, err := json.Marshal(cd)
81 9 if err != nil {
82 0 return "", err
83 0 }
84 9 return template.JS(b), nil
85 }
86
87 // ---- error mapping --------------------------------------------------------
88
89 // httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
90 // core.ErrNotFound so a private repo is a 404, never a 403.
91 7 func httpStatusFor(err error) int {
92 7 switch {
93 4 case errors.Is(err, core.ErrNotFound):
94 4 return http.StatusNotFound
95 2 case errors.Is(err, core.ErrBadRef):
96 2 return http.StatusBadRequest
97 0 case errors.Is(err, core.ErrForbidden):
98 0 return http.StatusForbidden
99 1 default:
100 1 return http.StatusInternalServerError
101 }
102 }
103
104 // fail renders the chrome error page for err, logging 5xx causes.
105 //
106 // Only a 400 carries the error's own text, and it is the one class that should:
107 // "invalid git ref" tells the viewer what to change about what they typed.
108 // Every other status takes the instance's standard sentence — a 500 because an
109 // error from below names paths and queries, a 404 because the standard sentence
110 // is exactly the one a repository that never existed produces, which is what
111 // keeps a repository the viewer may not see indistinguishable from an absent
112 // one.
113 7 func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
114 7 status := httpStatusFor(err)
115 7 if status >= 500 {
116 1 // ErrorContext, so a cancelled or deadlined request is visible as such
117 1 // in the record rather than as an unexplained 500. scribe.Err expands a
118 1 // culpa error's message, code, hint and stacktrace into fields of their
119 1 // own instead of flattening the chain into one sentence.
120 1 slog.ErrorContext(r.Context(), "web: request failed",
121 1 scribe.Err(err), "method", r.Method, "path", r.URL.Path)
122 1 s.renderError(w, r, status, "")
123 1 return
124 1 }
125 6 if status == http.StatusBadRequest {
126 2 s.renderError(w, r, status, err.Error())
127 2 return
128 2 }
129 4 s.renderError(w, r, status, "")
130 }
131
132 // resolve authorizes and opens a repository, returning the git handle and the
133 // authz metadata. Any error is already mapped to the right HTTP status by the
134 // caller via fail.
135 18 func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
136 18 viewer := login.FromContext(ctx)
137 18 info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
138 18 if err != nil {
139 5 return nil, nil, err
140 5 }
141 12 g, err := gitx.Open(s.reposRoot, owner, repo)
142 12 if err != nil {
143 0 return nil, nil, err
144 0 }
145 12 return g, info, nil
146 }
147
148 // ---- index ----------------------------------------------------------------
149
150 type indexData struct {
151 LoggedIn bool
152
153 // Repos is the viewer's own repositories in the shape ecore's
154 // "srht-repo-list" partial renders, so the landing's listing is the same
155 // event-list card the sibling services draw for their own projects.
156 Repos chrome.RepoList
157 }
158
159 8 func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
160 8 ctx := r.Context()
161 8 username := login.FromContext(ctx)
162 8
163 8 // The title is built from the chrome's own brand fields rather than from a
164 8 // second read of site-name, so the tab and the nav cannot name the instance
165 8 // differently.
166 8 vd := s.view(r, "")
167 8 vd.Title = vd.SiteName + " " + vd.SiteLabel
168 8
169 8 if username == "" {
170 5 vd.Data = indexData{LoggedIn: false}
171 5 s.render(w, http.StatusOK, "index", vd)
172 5 return
173 5 }
174
175 3 repos, err := s.authorizer.MyRepos(ctx, username)
176 3 if err != nil {
177 0 s.fail(w, r, err)
178 0 return
179 0 }
180 3 vd.Data = indexData{LoggedIn: true, Repos: repoList(username, repos)}
181 3 s.render(w, http.StatusOK, "index", vd)
182 }
183
184 // repoList turns the authorizer's repositories into the listing ecore renders.
185 // The owner is always the viewer — MyRepos answers for one account — so the
186 // "~owner/name" title and the link are built from the same name and cannot point
187 // at somebody else's repository.
188 3 func repoList(owner string, repos []authz.RepoInfo) chrome.RepoList {
189 3 items := make([]chrome.ListItem, 0, len(repos))
190 6 for _, info := range repos {
191 6 items = append(items, chrome.ListItem{
192 6 Href: "/~" + owner + "/" + info.Name,
193 6 Title: "~" + owner + "/" + info.Name,
194 6 Visibility: info.Visibility,
195 6 Description: info.Description,
196 6 })
197 6 }
198 3 return chrome.RepoList{Items: items, Empty: "You have no repositories yet."}
199 }
200
201 // ---- repo page ------------------------------------------------------------
202
203 type repoData struct {
204 Owner string
205 Info *authz.RepoInfo
206 DefaultBranch string
207 Branches []gitx.Ref
208 Tags []gitx.Ref
209 Commits []gitx.CommitInfo
210 }
211
212 7 func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
213 7 ctx := r.Context()
214 7 owner := chi.URLParam(r, "owner")
215 7 repo := chi.URLParam(r, "repo")
216 7
217 7 g, info, err := s.resolve(ctx, owner, repo)
218 7 if err != nil {
219 4 s.fail(w, r, err)
220 4 return
221 4 }
222
223 2 branches, tags, err := g.Refs(ctx)
224 2 if err != nil {
225 0 s.fail(w, r, err)
226 0 return
227 0 }
228 2 def, _ := g.DefaultBranch(ctx)
229 2 commits, _ := recentCommits(ctx, g, def, recentCommitLimit)
230 2
231 2 vd := s.view(r, "~"+owner+"/"+repo)
232 2 vd.Data = repoData{
233 2 Owner: owner,
234 2 Info: info,
235 2 DefaultBranch: def,
236 2 Branches: branches,
237 2 Tags: tags,
238 2 Commits: commits,
239 2 }
240 2 s.render(w, http.StatusOK, "repo", vd)
241 }
242
243 // recentCommits walks first-parent history from rev, returning up to limit
244 // commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
245 // range. An unresolvable starting revision (e.g. an empty repository) yields an
246 // empty slice rather than an error.
247 2 func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
248 2 if rev == "" {
249 0 return nil, nil
250 0 }
251 2 var out []gitx.CommitInfo
252 2 cur := rev
253 4 for i := 0; i < limit; i++ {
254 4 ci, err := g.ResolveCommit(ctx, cur)
255 4 if err != nil {
256 0 if i == 0 {
257 0 return nil, nil
258 0 }
259 0 break
260 }
261 4 out = append(out, *ci)
262 4 if len(ci.ParentSHAs) == 0 {
263 2 break
264 }
265 2 cur = ci.ParentSHAs[0]
266 }
267 2 return out, nil
268 }
269
270 // ---- compare page ---------------------------------------------------------
271
272 type compareView struct {
273 Owner string
274 RepoName string
275 Info *authz.RepoInfo
276 Spec core.CompareSpec
277 MergeBase string
278 Commits []gitx.CommitInfo
279 Files []gitx.FileChange
280 Truncated bool
281 CompareURL string
282 PatchURL string
283 JSON template.JS
284 }
285
286 9 func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
287 9 ctx := r.Context()
288 9 owner := chi.URLParam(r, "owner")
289 9 repo := chi.URLParam(r, "repo")
290 9 raw := chi.URLParam(r, "*")
291 9
292 9 // Empty wildcard: this is the compare form's GET target. Canonicalize the
293 9 // base/head/mode query into a clean compare URL and redirect.
294 9 if raw == "" {
295 0 s.compareRedirect(w, r, owner, repo)
296 0 return
297 0 }
298
299 9 patchMode := strings.HasSuffix(raw, ".patch")
300 9 specRaw := strings.TrimSuffix(raw, ".patch")
301 9
302 9 spec, err := core.ParseCompareSpec(specRaw)
303 9 if err != nil {
304 2 s.fail(w, r, err)
305 2 return
306 2 }
307
308 7 g, info, err := s.resolve(ctx, owner, repo)
309 7 if err != nil {
310 1 s.fail(w, r, err)
311 1 return
312 1 }
313
314 6 compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)
315 6
316 6 if patchMode {
317 1 patch, err := g.RawDiff(ctx, spec)
318 1 if err != nil {
319 0 s.fail(w, r, err)
320 0 return
321 0 }
322 1 s.writePatch(w, patch.Text)
323 1 return
324 }
325
326 5 patch, err := g.Diff(ctx, spec)
327 5 if err != nil {
328 0 s.fail(w, r, err)
329 0 return
330 0 }
331 5 files, err := g.DiffStat(ctx, spec)
332 5 if err != nil {
333 0 s.fail(w, r, err)
334 0 return
335 0 }
336 5 commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
337 5 if err != nil {
338 0 s.fail(w, r, err)
339 0 return
340 0 }
341
342 5 var mergeBase string
343 5 if spec.ThreeDot {
344 4 mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
345 4 }
346
347 5 jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
348 5 Base: spec.Base,
349 5 Head: spec.Head,
350 5 ThreeDot: spec.ThreeDot,
351 5 })
352 5 if err != nil {
353 0 s.fail(w, r, err)
354 0 return
355 0 }
356
357 5 vd := s.view(r, fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head))
358 5 vd.ContainerClass = "container-fluid"
359 5 vd.Data = compareView{
360 5 Owner: owner,
361 5 RepoName: repo,
362 5 Info: info,
363 5 Spec: spec,
364 5 MergeBase: mergeBase,
365 5 Commits: commits,
366 5 Files: files,
367 5 Truncated: patch.Truncated,
368 5 CompareURL: compareURL,
369 5 PatchURL: compareURL + ".patch",
370 5 JSON: jsonPayload,
371 5 }
372 5 s.render(w, http.StatusOK, "compare", vd)
373 }
374
375 // compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
376 // "two" selects the two-dot range; anything else (the default) is three-dot.
377 0 func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
378 0 q := r.URL.Query()
379 0 base := strings.TrimSpace(q.Get("base"))
380 0 head := strings.TrimSpace(q.Get("head"))
381 0 if base == "" || head == "" {
382 0 s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
383 0 return
384 0 }
385 0 sep := "..."
386 0 if q.Get("mode") == "two" {
387 0 sep = ".."
388 0 }
389 0 http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
390 }
391
392 // ---- commit page ----------------------------------------------------------
393
394 type commitView struct {
395 Owner string
396 RepoName string
397 Info *authz.RepoInfo
398 Commit *gitx.CommitInfo
399 Files []gitx.FileChange
400 IsMerge bool
401 Truncated bool
402 CommitURL string
403 PatchURL string
404 JSON template.JS
405 }
406
407 4 func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
408 4 ctx := r.Context()
409 4 owner := chi.URLParam(r, "owner")
410 4 repo := chi.URLParam(r, "repo")
411 4 rev := chi.URLParam(r, "rev")
412 4
413 4 patchMode := strings.HasSuffix(rev, ".patch")
414 4 rev = strings.TrimSuffix(rev, ".patch")
415 4
416 4 g, info, err := s.resolve(ctx, owner, repo)
417 4 if err != nil {
418 0 s.fail(w, r, err)
419 0 return
420 0 }
421
422 4 patch, files, ci, err := g.CommitPatch(ctx, rev)
423 4 if err != nil {
424 0 s.fail(w, r, err)
425 0 return
426 0 }
427
428 4 if patchMode {
429 1 s.writePatch(w, patch.Text)
430 1 return
431 1 }
432
433 3 commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)
434 3
435 3 base := ""
436 3 if len(ci.ParentSHAs) > 0 {
437 3 base = ci.ParentSHAs[0]
438 3 }
439 3 jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
440 3 Base: base,
441 3 Head: ci.SHA,
442 3 ThreeDot: false,
443 3 })
444 3 if err != nil {
445 0 s.fail(w, r, err)
446 0 return
447 0 }
448
449 3 vd := s.view(r, fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA))
450 3 vd.ContainerClass = "container-fluid"
451 3 vd.Data = commitView{
452 3 Owner: owner,
453 3 RepoName: repo,
454 3 Info: info,
455 3 Commit: ci,
456 3 Files: files,
457 3 IsMerge: len(ci.ParentSHAs) > 1,
458 3 Truncated: patch.Truncated,
459 3 CommitURL: commitURL,
460 3 PatchURL: commitURL + ".patch",
461 3 JSON: jsonPayload,
462 3 }
463 3 s.render(w, http.StatusOK, "commit", vd)
464 }
465
466 // writePatch emits a raw unified diff as an inline text/plain document.
467 2 func (s *Server) writePatch(w http.ResponseWriter, text string) {
468 2 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
469 2 w.Header().Set("Content-Disposition", "inline")
470 2 w.WriteHeader(http.StatusOK)
471 2 _, _ = w.Write([]byte(text))
472 2 }