bdec06fca065121838440a7e92546fc600627c85

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): add public history and refs pages

Diff

This diff is truncated to protect this page.

  1diff --git a/git/repo.go b/git/repo.go
  2index 107fc571aa8f595443b838883d236fb8a0d08d38..92b52238ade8d3e5499d5b2ae5eab8dff3dbbb70 100644
  3--- a/git/repo.go
  4+++ b/git/repo.go
  5@@ -140,9 +140,17 @@ func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
  6 	return t.SubTree(path)
  7 }
  8 
  9-// Diff returns the diff for the given commit.
 10-func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 11-	diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
 12+// DiffLimits bounds the amount of diff data Git parses for one request.
 13+type DiffLimits struct {
 14+	MaxFiles     int
 15+	MaxFileLines int
 16+	MaxLineChars int
 17+}
 18+
 19+// DiffWithLimits returns the diff for commit using request-local limits.
 20+// It does not modify the legacy package-level DiffMax* defaults.
 21+func (r *Repository) DiffWithLimits(commit *Commit, limits DiffLimits) (*Diff, error) {
 22+	diff, err := r.Repository.Diff(commit.ID.String(), limits.MaxFiles, limits.MaxFileLines, limits.MaxLineChars, git.DiffOptions{
 23 		CommandOptions: git.CommandOptions{
 24 			Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
 25 		},
 26@@ -153,6 +161,15 @@ func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 27 	return toDiff(diff), nil
 28 }
 29 
 30+// Diff returns the diff for the given commit using the legacy default limits.
 31+func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 32+	return r.DiffWithLimits(commit, DiffLimits{
 33+		MaxFiles:     DiffMaxFiles,
 34+		MaxFileLines: DiffMaxFileLines,
 35+		MaxLineChars: DiffMaxLineChars,
 36+	})
 37+}
 38+
 39 // Patch returns the patch for the given reference.
 40 func (r *Repository) Patch(commit *Commit) (string, error) {
 41 	diff, err := r.Diff(commit)
 42diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
 43index 8c85f1fad50cd91afe88cf0fcecf34150120fc46..332b8ef0f5f9dbf7295569aa3f59f8ced75f356c 100644
 44--- a/pkg/web/pages/controller.go
 45+++ b/pkg/web/pages/controller.go
 46@@ -28,6 +28,9 @@ func Controller(_ context.Context, r *mux.Router) {
 47 	r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
 48 	r.HandleFunc("/{repo:.*}/@/tree", treeBrowser).Methods(http.MethodGet)
 49 	r.HandleFunc("/{repo:.*}/@/raw", rawFile).Methods(http.MethodGet)
 50+	r.HandleFunc("/{repo:.*}/@/commits", commits).Methods(http.MethodGet)
 51+	r.HandleFunc("/{repo:.*}/@/commit", commit).Methods(http.MethodGet)
 52+	r.HandleFunc("/{repo:.*}/@/refs", refs).Methods(http.MethodGet)
 53 	r.HandleFunc("/{repo:.*}", repositoryOverview).Methods(http.MethodGet)
 54 }
 55 
 56diff --git a/pkg/web/pages/history.go b/pkg/web/pages/history.go
 57new file mode 100644
 58index 0000000000000000000000000000000000000000..4979d459da77a78bade6ca479b47b080a88709ff
 59--- /dev/null
 60+++ b/pkg/web/pages/history.go
 61@@ -0,0 +1,347 @@
 62+package pages
 63+
 64+import (
 65+	"errors"
 66+	"html/template"
 67+	"net/http"
 68+	"net/url"
 69+	"sort"
 70+	"strconv"
 71+	"strings"
 72+	"unicode/utf8"
 73+
 74+	"github.com/charmbracelet/soft-serve/git"
 75+	"github.com/charmbracelet/soft-serve/pkg/config"
 76+)
 77+
 78+const (
 79+	commitsPerPage   = 30
 80+	maxCommitPage    = 1000
 81+	maxDiffFiles     = 50
 82+	maxDiffFileLines = 100
 83+	maxDiffLineChars = 250
 84+	maxDiffBytes     = 2 << 20
 85+)
 86+
 87+var errInvalidPage = errors.New("invalid page")
 88+
 89+type commitsPage struct {
 90+	ServerName  string
 91+	Repository  string
 92+	Ref         string
 93+	OverviewURL string
 94+	Entries     []commitListEntry
 95+	PreviousURL string
 96+	NextURL     string
 97+}
 98+
 99+type commitListEntry struct {
100+	Subject   string
101+	ShortHash string
102+	Author    string
103+	Date      string
104+	URL       string
105+}
106+
107+type commitPage struct {
108+	ServerName  string
109+	Repository  string
110+	OverviewURL string
111+	Hash        string
112+	Author      string
113+	Committer   string
114+	Timestamp   string
115+	Message     string
116+	Diff        template.HTML
117+	Truncated   bool
118+}
119+
120+type refsPage struct {
121+	ServerName  string
122+	Repository  string
123+	OverviewURL string
124+	Branches    []refPageEntry
125+	Tags        []refPageEntry
126+}
127+
128+type refPageEntry struct {
129+	Name      string
130+	ShortHash string
131+	Subject   string
132+	Date      string
133+	CommitURL string
134+	TreeURL   string
135+}
136+
137+func commits(w http.ResponseWriter, r *http.Request) {
138+	repo, gitRepo, ok := openPublicRepository(w, r)
139+	if !ok {
140+		return
141+	}
142+	ref, err := ResolveRef(gitRepo, r.URL.Query().Get("ref"))
143+	if err != nil {
144+		http.Error(w, "not found", http.StatusNotFound)
145+		return
146+	}
147+	pageNumber, err := historyPageNumber(r.URL.Query().Get("page"))
148+	if err != nil {
149+		http.Error(w, "not found", http.StatusNotFound)
150+		return
151+	}
152+
153+	// Request one extra commit so pagination never needs a full-history count.
154+	commitList, err := gitRepo.CommitsByPage(ref, pageNumber, commitsPerPage+1)
155+	if err != nil {
156+		http.Error(w, "not found", http.StatusNotFound)
157+		return
158+	}
159+	hasNext := len(commitList) > commitsPerPage
160+	if hasNext {
161diff --git a/pkg/web/pages/history_test.go b/pkg/web/pages/history_test.go
162new file mode 100644
163index 0000000000000000000000000000000000000000..3515df64a0da69aae86bfa20fe268ac84d900f79
164--- /dev/null
165+++ b/pkg/web/pages/history_test.go
166@@ -0,0 +1,138 @@
167+package pages
168+
169+import (
170+	"bytes"
171+	"os"
172+	"os/exec"
173+	"path/filepath"
174+	"strings"
175+	"testing"
176+
177+	"github.com/charmbracelet/soft-serve/git"
178+)
179+
180+func TestHistoryPaginationUsesThirtyItemsAndBounds(t *testing.T) {
181+	repo := newTestRepository(t, map[string]string{"log.txt": "0\n"})
182+	for i := 1; i <= commitsPerPage; i++ {
183+		appendCommit(t, repo.Path, "log.txt", "next "+strings.Repeat("x", i))
184+	}
185+	ref, err := ResolveRef(repo, "")
186+	if err != nil {
187+		t.Fatal(err)
188+	}
189+	commits, err := repo.CommitsByPage(ref, 1, commitsPerPage+1)
190+	if err != nil {
191+		t.Fatal(err)
192+	}
193+	if len(commits) != commitsPerPage+1 {
194+		t.Fatalf("extra commit request returned %d commits, want %d", len(commits), commitsPerPage+1)
195+	}
196+	if got := len(commitEntries("project", commits[:commitsPerPage])); got != commitsPerPage {
197+		t.Fatalf("display entries = %d, want %d", got, commitsPerPage)
198+	}
199+	if _, err := historyPageNumber("0"); err == nil {
200+		t.Fatal("zero page was accepted")
201+	}
202+	if got, err := historyPageNumber("1001"); err != nil || got != maxCommitPage {
203+		t.Fatalf("out-of-range page = %d, %v; want %d, nil", got, err, maxCommitPage)
204+	}
205+}
206+
207+func TestRefsSupportSlashBranchesAndAnnotatedTags(t *testing.T) {
208+	repo := newTestRepository(t, map[string]string{"README.md": "hello"})
209+	runGit(t, repo.Path, "branch", "feature/with-slash")
210+	runGit(t, repo.Path, "tag", "-a", "release/v1", "-m", "release")
211+	references, err := repo.References()
212+	if err != nil {
213+		t.Fatal(err)
214+	}
215+	var branch, tag *git.Reference
216+	for _, ref := range references {
217+		switch ref.Name().String() {
218+		case "refs/heads/feature/with-slash":
219+			branch = ref
220+		case "refs/tags/release/v1":
221+			tag = ref
222+		}
223+	}
224+	if branch == nil || tag == nil {
225+		t.Fatalf("missing branch or tag in %#v", references)
226+	}
227+	branchEntry := refEntry(repo, "team/project", branch)
228+	tagEntry := refEntry(repo, "team/project", tag)
229+	for _, entry := range []refPageEntry{branchEntry, tagEntry} {
230+		if entry.CommitURL == "" || !strings.Contains(entry.CommitURL, "/@/commit?hash=") {
231+			t.Fatalf("commit link missing: %#v", entry)
232+		}
233+	}
234+	if !strings.Contains(branchEntry.TreeURL, "ref=refs%2Fheads%2Ffeature%2Fwith-slash") {
235+		t.Fatalf("branch tree link does not encode full ref: %q", branchEntry.TreeURL)
236+	}
237+	if !strings.Contains(tagEntry.TreeURL, "ref=refs%2Ftags%2Frelease%2Fv1") {
238+		t.Fatalf("tag tree link does not encode full ref: %q", tagEntry.TreeURL)
239+	}
240+}
241+
242+func TestCommitLookupValidationAndRepositoryBoundary(t *testing.T) {
243+	first := newTestRepository(t, map[string]string{"first.txt": "first"})
244+	second := newTestRepository(t, map[string]string{"second.txt": "second"})
245+	if _, err := LookupCommit(first, "not-a-hash"); err == nil {
246+		t.Fatal("invalid hash was accepted")
247+	}
248+	treeID := strings.TrimSpace(runGitOutput(t, first.Path, "rev-parse", "HEAD^{tree}"))
249+	if _, err := LookupCommit(first, treeID); err == nil {
250+		t.Fatal("non-commit object was accepted")
251+	}
252+	secondID := strings.TrimSpace(runGitOutput(t, second.Path, "rev-parse", "HEAD"))
253+	if _, err := LookupCommit(first, secondID); err == nil {
254+		t.Fatal("commit from another repository was disclosed")
255+	}
256+}
257+
258+func TestCommitMessageEscapingAndBoundedDiff(t *testing.T) {
259+	repo := newTestRepository(t, map[string]string{"changed.txt": "start\n"})
260+	appendCommit(t, repo.Path, "changed.txt", strings.Repeat("line\n", 120))
261+	hash := strings.TrimSpace(runGitOutput(t, repo.Path, "rev-parse", "HEAD"))
262+	commit, err := LookupCommit(repo, hash)
263+	if err != nil {
264+		t.Fatal(err)
265+	}
266diff --git a/pkg/web/pages/templates/history.gohtml b/pkg/web/pages/templates/history.gohtml
267new file mode 100644
268index 0000000000000000000000000000000000000000..df92a1705fcc67abc8f101f461cd1dcf60a386c0
269--- /dev/null
270+++ b/pkg/web/pages/templates/history.gohtml
271@@ -0,0 +1,61 @@
272+{{ define "commits" }}<!doctype html>
273+<html lang="en">
274+<head>
275+  <meta charset="utf-8">
276+  <meta name="viewport" content="width=device-width, initial-scale=1">
277+  <title>Commits · {{ .Repository }} · {{ .ServerName }}</title>
278+  <link rel="stylesheet" href="/@/assets/site.css">
279+</head>
280+<body>
281+  <header class="site-header"><nav aria-label="Primary"><a class="site-name" href="/">{{ .ServerName }}</a></nav></header>
282+  <main>
283+    <p><a href="{{ .OverviewURL }}">{{ .Repository }}</a> / <code>{{ .Ref }}</code></p>
284+    <h1>Commits</h1>
285+    <ol class="commit-list">{{ range .Entries }}
286+      <li><article><h2><a href="{{ .URL }}">{{ .Subject }}</a></h2><p><code>{{ .ShortHash }}</code> · {{ .Author }} · <time>{{ .Date }}</time></p></article></li>
287+    {{ else }}<li>No commits found.</li>{{ end }}</ol>
288+    {{ if or .PreviousURL .NextURL }}<nav aria-label="Commit history pages">{{ if .PreviousURL }}<a href="{{ .PreviousURL }}">Previous</a>{{ end }} {{ if .NextURL }}<a href="{{ .NextURL }}">Next</a>{{ end }}</nav>{{ end }}
289+  </main>
290+</body>
291+</html>{{ end }}
292+
293+{{ define "commit" }}<!doctype html>
294+<html lang="en">
295+<head>
296+  <meta charset="utf-8">
297+  <meta name="viewport" content="width=device-width, initial-scale=1">
298+  <title>Commit {{ .Hash }} · {{ .Repository }} · {{ .ServerName }}</title>
299+  <link rel="stylesheet" href="/@/assets/site.css">
300+</head>
301+<body>
302+  <header class="site-header"><nav aria-label="Primary"><a class="site-name" href="/">{{ .ServerName }}</a></nav></header>
303+  <main>
304+    <p><a href="{{ .OverviewURL }}">{{ .Repository }}</a></p>
305+    <article>
306+      <h1>Commit <code>{{ .Hash }}</code></h1>
307+      <dl><dt>Author</dt><dd>{{ .Author }}</dd><dt>Committer</dt><dd>{{ .Committer }}</dd><dt>Date</dt><dd><time>{{ .Timestamp }}</time></dd></dl>
308+      <h2>Message</h2><pre>{{ .Message }}</pre>
309+      <h2>Diff</h2>{{ if .Truncated }}<p>This diff is truncated to protect this page.</p>{{ end }}<pre class="source"><code>{{ .Diff }}</code></pre>
310+    </article>
311+  </main>
312+</body>
313+</html>{{ end }}
314+
315+{{ define "refs" }}<!doctype html>
316+<html lang="en">
317+<head>
318+  <meta charset="utf-8">
319+  <meta name="viewport" content="width=device-width, initial-scale=1">
320+  <title>Branches and tags · {{ .Repository }} · {{ .ServerName }}</title>
321+  <link rel="stylesheet" href="/@/assets/site.css">
322+</head>
323+<body>
324+  <header class="site-header"><nav aria-label="Primary"><a class="site-name" href="/">{{ .ServerName }}</a></nav></header>
325+  <main>
326+    <p><a href="{{ .OverviewURL }}">{{ .Repository }}</a></p>
327+    <h1>Branches and tags</h1>