~bigbes/sr-ht-compare

ref: e01e9ed02ec22b7bdebac9ae8327784c4fac2245 sr-ht-compare/web/handlers.go -rw-r--r-- 12.8 KiB
e01e9ed0 — bigbes chimw: the request line in the journal, HEAD routes, and a 405 page 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"log/slog"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
	"sourcecraft.dev/bigbes/sr-ht-compare/core"
	"sourcecraft.dev/bigbes/sr-ht-compare/gitx"
)

// recentCommitLimit bounds the first-parent history shown on the repo page.
const recentCommitLimit = 20

// compareLogLimit bounds the commit list on the compare page.
const compareLogLimit = 50

// ---- JSON transport (consumed by the front-end bundle) --------------------

// jsonFile mirrors one gitx.FileChange for the browser. path is the plain repo
// path with NO a/ or b/ prefix.
type jsonFile struct {
	Path      string `json:"path"`
	OldPath   string `json:"oldPath"`
	Status    string `json:"status"`
	Additions int    `json:"additions"`
	Deletions int    `json:"deletions"`
	Binary    bool   `json:"binary"`
}

type jsonSpec struct {
	Base     string `json:"base"`
	Head     string `json:"head"`
	ThreeDot bool   `json:"threeDot"`
}

type compareData struct {
	Mode      string     `json:"mode"`
	Patch     string     `json:"patch"`
	Truncated bool       `json:"truncated"`
	Files     []jsonFile `json:"files"`
	Spec      jsonSpec   `json:"spec"`
}

// buildCompareJSON marshals the browser payload. json.Marshal escapes <, > and &
// (Go's default HTML-safe mode), so the result is safe to drop verbatim inside a
// <script> element even when a file path contains "</script>". The bytes are
// returned as template.JS: any <script> is a JS context to html/template, so a
// plain string (or template.HTML) would be JS-escaped and corrupted; template.JS
// is emitted verbatim, and the marshaler's escaping already blocks a breakout.
func buildCompareJSON(mode string, patch *gitx.Patch, files []gitx.FileChange, spec jsonSpec) (template.JS, error) {
	cd := compareData{
		Mode:      mode,
		Patch:     patch.Text,
		Truncated: patch.Truncated,
		Files:     []jsonFile{},
		Spec:      spec,
	}
	for _, f := range files {
		cd.Files = append(cd.Files, jsonFile{
			Path:      f.Path,
			OldPath:   f.OldPath,
			Status:    f.Status,
			Additions: f.Additions,
			Deletions: f.Deletions,
			Binary:    f.Binary,
		})
	}
	b, err := json.Marshal(cd)
	if err != nil {
		return "", err
	}
	return template.JS(b), nil
}

// ---- error mapping --------------------------------------------------------

// httpStatusFor maps a domain error to an HTTP status. Repo visibility uses
// core.ErrNotFound so a private repo is a 404, never a 403.
func httpStatusFor(err error) int {
	switch {
	case errors.Is(err, core.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, core.ErrBadRef):
		return http.StatusBadRequest
	case errors.Is(err, core.ErrForbidden):
		return http.StatusForbidden
	default:
		return http.StatusInternalServerError
	}
}

// fail renders the chrome error page for err, logging 5xx causes.
//
// Only a 400 carries the error's own text, and it is the one class that should:
// "invalid git ref" tells the viewer what to change about what they typed.
// Every other status takes the instance's standard sentence — a 500 because an
// error from below names paths and queries, a 404 because the standard sentence
// is exactly the one a repository that never existed produces, which is what
// keeps a repository the viewer may not see indistinguishable from an absent
// one.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		// ErrorContext, so a cancelled or deadlined request is visible as such
		// in the record rather than as an unexplained 500. scribe.Err expands a
		// culpa error's message, code, hint and stacktrace into fields of their
		// own instead of flattening the chain into one sentence.
		slog.ErrorContext(r.Context(), "web: request failed",
			scribe.Err(err), "method", r.Method, "path", r.URL.Path)
		s.renderError(w, r, status, "")
		return
	}
	if status == http.StatusBadRequest {
		s.renderError(w, r, status, err.Error())
		return
	}
	s.renderError(w, r, status, "")
}

// resolve authorizes and opens a repository, returning the git handle and the
// authz metadata. Any error is already mapped to the right HTTP status by the
// caller via fail.
func (s *Server) resolve(ctx context.Context, owner, repo string) (*gitx.Repo, *authz.RepoInfo, error) {
	viewer := login.FromContext(ctx)
	info, err := s.authorizer.Repo(ctx, viewer, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	g, err := gitx.Open(s.reposRoot, owner, repo)
	if err != nil {
		return nil, nil, err
	}
	return g, info, nil
}

// ---- index ----------------------------------------------------------------

type indexData struct {
	LoggedIn bool

	// Repos is the viewer's own repositories in the shape ecore's
	// "srht-repo-list" partial renders, so the landing's listing is the same
	// event-list card the sibling services draw for their own projects.
	Repos chrome.RepoList
}

func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	username := login.FromContext(ctx)

	// The title is built from the chrome's own brand fields rather than from a
	// second read of site-name, so the tab and the nav cannot name the instance
	// differently.
	vd := s.view(r, "")
	vd.Title = vd.SiteName + " " + vd.SiteLabel

	if username == "" {
		vd.Data = indexData{LoggedIn: false}
		s.render(w, http.StatusOK, "index", vd)
		return
	}

	repos, err := s.authorizer.MyRepos(ctx, username)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	vd.Data = indexData{LoggedIn: true, Repos: repoList(username, repos)}
	s.render(w, http.StatusOK, "index", vd)
}

// repoList turns the authorizer's repositories into the listing ecore renders.
// The owner is always the viewer — MyRepos answers for one account — so the
// "~owner/name" title and the link are built from the same name and cannot point
// at somebody else's repository.
func repoList(owner string, repos []authz.RepoInfo) chrome.RepoList {
	items := make([]chrome.ListItem, 0, len(repos))
	for _, info := range repos {
		items = append(items, chrome.ListItem{
			Href:        "/~" + owner + "/" + info.Name,
			Title:       "~" + owner + "/" + info.Name,
			Visibility:  info.Visibility,
			Description: info.Description,
		})
	}
	return chrome.RepoList{Items: items, Empty: "You have no repositories yet."}
}

// ---- repo page ------------------------------------------------------------

type repoData struct {
	Owner         string
	Info          *authz.RepoInfo
	DefaultBranch string
	Branches      []gitx.Ref
	Tags          []gitx.Ref
	Commits       []gitx.CommitInfo
}

func (s *Server) handleRepo(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	branches, tags, err := g.Refs(ctx)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	def, _ := g.DefaultBranch(ctx)
	commits, _ := recentCommits(ctx, g, def, recentCommitLimit)

	vd := s.view(r, "~"+owner+"/"+repo)
	vd.Data = repoData{
		Owner:         owner,
		Info:          info,
		DefaultBranch: def,
		Branches:      branches,
		Tags:          tags,
		Commits:       commits,
	}
	s.render(w, http.StatusOK, "repo", vd)
}

// recentCommits walks first-parent history from rev, returning up to limit
// commits. It relies only on gitx.ResolveCommit so it needs no dedicated log
// range. An unresolvable starting revision (e.g. an empty repository) yields an
// empty slice rather than an error.
func recentCommits(ctx context.Context, g *gitx.Repo, rev string, limit int) ([]gitx.CommitInfo, error) {
	if rev == "" {
		return nil, nil
	}
	var out []gitx.CommitInfo
	cur := rev
	for i := 0; i < limit; i++ {
		ci, err := g.ResolveCommit(ctx, cur)
		if err != nil {
			if i == 0 {
				return nil, nil
			}
			break
		}
		out = append(out, *ci)
		if len(ci.ParentSHAs) == 0 {
			break
		}
		cur = ci.ParentSHAs[0]
	}
	return out, nil
}

// ---- compare page ---------------------------------------------------------

type compareView struct {
	Owner      string
	RepoName   string
	Info       *authz.RepoInfo
	Spec       core.CompareSpec
	MergeBase  string
	Commits    []gitx.CommitInfo
	Files      []gitx.FileChange
	Truncated  bool
	CompareURL string
	PatchURL   string
	JSON       template.JS
}

func (s *Server) handleCompare(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	raw := chi.URLParam(r, "*")

	// Empty wildcard: this is the compare form's GET target. Canonicalize the
	// base/head/mode query into a clean compare URL and redirect.
	if raw == "" {
		s.compareRedirect(w, r, owner, repo)
		return
	}

	patchMode := strings.HasSuffix(raw, ".patch")
	specRaw := strings.TrimSuffix(raw, ".patch")

	spec, err := core.ParseCompareSpec(specRaw)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	compareURL := fmt.Sprintf("/~%s/%s/compare/%s", owner, repo, specRaw)

	if patchMode {
		patch, err := g.RawDiff(ctx, spec)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		s.writePatch(w, patch.Text)
		return
	}

	patch, err := g.Diff(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	files, err := g.DiffStat(ctx, spec)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	commits, err := g.Log(ctx, spec.Base, spec.Head, compareLogLimit)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	var mergeBase string
	if spec.ThreeDot {
		mergeBase, _ = g.MergeBase(ctx, spec.Base, spec.Head)
	}

	jsonPayload, err := buildCompareJSON("compare", patch, files, jsonSpec{
		Base:     spec.Base,
		Head:     spec.Head,
		ThreeDot: spec.ThreeDot,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.view(r, fmt.Sprintf("~%s/%s: %s...%s", owner, repo, spec.Base, spec.Head))
	vd.ContainerClass = "container-fluid"
	vd.Data = compareView{
		Owner:      owner,
		RepoName:   repo,
		Info:       info,
		Spec:       spec,
		MergeBase:  mergeBase,
		Commits:    commits,
		Files:      files,
		Truncated:  patch.Truncated,
		CompareURL: compareURL,
		PatchURL:   compareURL + ".patch",
		JSON:       jsonPayload,
	}
	s.render(w, http.StatusOK, "compare", vd)
}

// compareRedirect turns ?base=&head=&mode= into a canonical compare URL. mode
// "two" selects the two-dot range; anything else (the default) is three-dot.
func (s *Server) compareRedirect(w http.ResponseWriter, r *http.Request, owner, repo string) {
	q := r.URL.Query()
	base := strings.TrimSpace(q.Get("base"))
	head := strings.TrimSpace(q.Get("head"))
	if base == "" || head == "" {
		s.renderError(w, r, http.StatusBadRequest, "both base and head are required")
		return
	}
	sep := "..."
	if q.Get("mode") == "two" {
		sep = ".."
	}
	http.Redirect(w, r, fmt.Sprintf("/~%s/%s/compare/%s%s%s", owner, repo, base, sep, head), http.StatusFound)
}

// ---- commit page ----------------------------------------------------------

type commitView struct {
	Owner     string
	RepoName  string
	Info      *authz.RepoInfo
	Commit    *gitx.CommitInfo
	Files     []gitx.FileChange
	IsMerge   bool
	Truncated bool
	CommitURL string
	PatchURL  string
	JSON      template.JS
}

func (s *Server) handleCommit(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	owner := chi.URLParam(r, "owner")
	repo := chi.URLParam(r, "repo")
	rev := chi.URLParam(r, "rev")

	patchMode := strings.HasSuffix(rev, ".patch")
	rev = strings.TrimSuffix(rev, ".patch")

	g, info, err := s.resolve(ctx, owner, repo)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	patch, files, ci, err := g.CommitPatch(ctx, rev)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	if patchMode {
		s.writePatch(w, patch.Text)
		return
	}

	commitURL := fmt.Sprintf("/~%s/%s/commit/%s", owner, repo, rev)

	base := ""
	if len(ci.ParentSHAs) > 0 {
		base = ci.ParentSHAs[0]
	}
	jsonPayload, err := buildCompareJSON("commit", patch, files, jsonSpec{
		Base:     base,
		Head:     ci.SHA,
		ThreeDot: false,
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.view(r, fmt.Sprintf("~%s/%s: %s", owner, repo, ci.ShortSHA))
	vd.ContainerClass = "container-fluid"
	vd.Data = commitView{
		Owner:     owner,
		RepoName:  repo,
		Info:      info,
		Commit:    ci,
		Files:     files,
		IsMerge:   len(ci.ParentSHAs) > 1,
		Truncated: patch.Truncated,
		CommitURL: commitURL,
		PatchURL:  commitURL + ".patch",
		JSON:      jsonPayload,
	}
	s.render(w, http.StatusOK, "commit", vd)
}

// writePatch emits a raw unified diff as an inline text/plain document.
func (s *Server) writePatch(w http.ResponseWriter, text string) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.Header().Set("Content-Disposition", "inline")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(text))
}