Parent directory

history.go

8715 bytes
  1package pages
  2
  3import (
  4	"errors"
  5	"html/template"
  6	"net/http"
  7	"net/url"
  8	"sort"
  9	"strconv"
 10	"strings"
 11	"unicode/utf8"
 12
 13	"github.com/charmbracelet/soft-serve/git"
 14	"github.com/charmbracelet/soft-serve/pkg/config"
 15)
 16
 17const (
 18	commitsPerPage   = 30
 19	maxCommitPage    = 1000
 20	maxDiffFiles     = 50
 21	maxDiffFileLines = 100
 22	maxDiffLineChars = 250
 23	maxDiffBytes     = 2 << 20
 24)
 25
 26var errInvalidPage = errors.New("invalid page")
 27
 28type commitsPage struct {
 29	ServerName  string
 30	Repository  string
 31	Ref         string
 32	OverviewURL string
 33	Entries     []commitListEntry
 34	PreviousURL string
 35	NextURL     string
 36}
 37
 38type commitListEntry struct {
 39	Subject   string
 40	ShortHash string
 41	Author    string
 42	Date      string
 43	URL       string
 44}
 45
 46type commitPage struct {
 47	ServerName  string
 48	Repository  string
 49	OverviewURL string
 50	Hash        string
 51	Author      string
 52	Committer   string
 53	Timestamp   string
 54	Message     string
 55	Diff        template.HTML
 56	Truncated   bool
 57}
 58
 59type refsPage struct {
 60	ServerName  string
 61	Repository  string
 62	OverviewURL string
 63	Branches    []refPageEntry
 64	Tags        []refPageEntry
 65}
 66
 67type refPageEntry struct {
 68	Name      string
 69	ShortHash string
 70	Subject   string
 71	Date      string
 72	CommitURL string
 73	TreeURL   string
 74}
 75
 76func commits(w http.ResponseWriter, r *http.Request) {
 77	repo, gitRepo, ok := openPublicRepository(w, r)
 78	if !ok {
 79		return
 80	}
 81	ref, err := ResolveRef(gitRepo, r.URL.Query().Get("ref"))
 82	if err != nil {
 83		http.Error(w, "not found", http.StatusNotFound)
 84		return
 85	}
 86	pageNumber, err := historyPageNumber(r.URL.Query().Get("page"))
 87	if err != nil {
 88		http.Error(w, "not found", http.StatusNotFound)
 89		return
 90	}
 91
 92	// Request one extra commit so pagination never needs a full-history count.
 93	commitList, err := gitRepo.CommitsByPage(ref, pageNumber, commitsPerPage+1)
 94	if err != nil {
 95		http.Error(w, "not found", http.StatusNotFound)
 96		return
 97	}
 98	hasNext := len(commitList) > commitsPerPage
 99	if hasNext {
100		commitList = commitList[:commitsPerPage]
101	}
102
103	page := commitsPage{
104		ServerName:  config.FromContext(r.Context()).Name,
105		Repository:  repo.Name(),
106		Ref:         ref.Name().String(),
107		OverviewURL: RepositoryURL(repo.Name()),
108		Entries:     commitEntries(repo.Name(), commitList),
109	}
110	if pageNumber > 1 {
111		page.PreviousURL = commitsURL(repo.Name(), page.Ref, pageNumber-1)
112	}
113	if hasNext && pageNumber < maxCommitPage {
114		page.NextURL = commitsURL(repo.Name(), page.Ref, pageNumber+1)
115	}
116	renderPage(w, "commits", page)
117}
118
119func commit(w http.ResponseWriter, r *http.Request) {
120	repo, gitRepo, ok := openPublicRepository(w, r)
121	if !ok {
122		return
123	}
124	commitObject, err := LookupCommit(gitRepo, r.URL.Query().Get("hash"))
125	if err != nil {
126		http.Error(w, "not found", http.StatusNotFound)
127		return
128	}
129	diff, err := gitRepo.DiffWithLimits(commitObject, git.DiffLimits{
130		MaxFiles:     maxDiffFiles,
131		MaxFileLines: maxDiffFileLines,
132		MaxLineChars: maxDiffLineChars,
133	})
134	if err != nil {
135		http.Error(w, "not found", http.StatusNotFound)
136		return
137	}
138	patch, truncated, err := boundedDiffPatch(diff)
139	if err != nil {
140		http.Error(w, "failed to render page", http.StatusInternalServerError)
141		return
142	}
143	rendered, err := RenderSource("changes.diff", []byte(patch))
144	if err != nil {
145		http.Error(w, "failed to render page", http.StatusInternalServerError)
146		return
147	}
148
149	page := commitPage{
150		ServerName:  config.FromContext(r.Context()).Name,
151		Repository:  repo.Name(),
152		OverviewURL: RepositoryURL(repo.Name()),
153		Hash:        commitObject.ID.String(),
154		Author:      commitAuthor(commitObject),
155		Committer:   commitCommitter(commitObject),
156		Timestamp:   commitTime(commitObject),
157		Message:     commitObject.Message,
158		Diff:        rendered,
159		Truncated:   truncated,
160	}
161	renderPage(w, "commit", page)
162}
163
164func refs(w http.ResponseWriter, r *http.Request) {
165	repo, gitRepo, ok := openPublicRepository(w, r)
166	if !ok {
167		return
168	}
169	references, err := gitRepo.References()
170	if err != nil {
171		http.Error(w, "not found", http.StatusNotFound)
172		return
173	}
174
175	page := refsPage{
176		ServerName:  config.FromContext(r.Context()).Name,
177		Repository:  repo.Name(),
178		OverviewURL: RepositoryURL(repo.Name()),
179	}
180	for _, ref := range references {
181		if !ref.IsBranch() && !ref.IsTag() {
182			continue
183		}
184		entry := refEntry(gitRepo, repo.Name(), ref)
185		if ref.IsBranch() {
186			page.Branches = append(page.Branches, entry)
187		} else {
188			page.Tags = append(page.Tags, entry)
189		}
190	}
191	sort.Slice(page.Branches, func(i, j int) bool { return page.Branches[i].Name < page.Branches[j].Name })
192	sort.Slice(page.Tags, func(i, j int) bool { return page.Tags[i].Name < page.Tags[j].Name })
193	renderPage(w, "refs", page)
194}
195
196func historyPageNumber(value string) (int, error) {
197	if value == "" {
198		return 1, nil
199	}
200	page, err := strconv.Atoi(value)
201	if err != nil || page < 1 {
202		return 0, errInvalidPage
203	}
204	if page > maxCommitPage {
205		return maxCommitPage, nil
206	}
207	return page, nil
208}
209
210func commitEntries(repository string, commits git.Commits) []commitListEntry {
211	entries := make([]commitListEntry, 0, len(commits))
212	for _, commit := range commits {
213		entries = append(entries, commitListEntry{
214			Subject:   commit.Summary(),
215			ShortHash: shortHash(commit.ID.String()),
216			Author:    commitAuthor(commit),
217			Date:      commitTime(commit),
218			URL:       CommitURL(repository, commit.ID.String()),
219		})
220	}
221	return entries
222}
223
224func refEntry(repo *git.Repository, repository string, ref *git.Reference) refPageEntry {
225	entry := refPageEntry{
226		Name:      ref.Name().Short(),
227		ShortHash: shortHash(ref.ID),
228		TreeURL:   TreeURL(repository, ref.Name().String(), ""),
229	}
230
231	var commitObject *git.Commit
232	var err error
233	if ref.IsTag() {
234		var tag *git.Tag
235		tag, err = repo.Tag(ref.Name().Short())
236		if err == nil {
237			commitObject, err = tag.Commit()
238		}
239	} else {
240		commitObject, err = repo.CatFileCommit(ref.ID)
241	}
242	if err == nil && commitObject != nil {
243		entry.ShortHash = shortHash(commitObject.ID.String())
244		entry.Subject = commitObject.Summary()
245		entry.Date = commitTime(commitObject)
246		entry.CommitURL = CommitURL(repository, commitObject.ID.String())
247	}
248	return entry
249}
250
251func commitsURL(repository, ref string, page int) string {
252	return PageURL(repository, "commits", url.Values{"ref": {ref}, "page": {strconv.Itoa(page)}})
253}
254
255// CommitURL builds a commit page URL from a full object ID.
256func CommitURL(repository, hash string) string {
257	return PageURL(repository, "commit", url.Values{"hash": {hash}})
258}
259
260func commitAuthor(commit *git.Commit) string {
261	if commit == nil || commit.Author == nil {
262		return ""
263	}
264	return signatureString(commit.Author.Name, commit.Author.Email)
265}
266
267func commitCommitter(commit *git.Commit) string {
268	if commit == nil || commit.Committer == nil {
269		return ""
270	}
271	return signatureString(commit.Committer.Name, commit.Committer.Email)
272}
273
274func signatureString(name, email string) string {
275	if name == "" {
276		return ""
277	}
278	if email == "" {
279		return name
280	}
281	return name + " <" + email + ">"
282}
283
284func commitTime(commit *git.Commit) string {
285	if commit == nil || commit.Committer == nil {
286		return ""
287	}
288	return commit.Committer.When.UTC().Format("2006-01-02 15:04 UTC")
289}
290
291func shortHash(hash string) string {
292	if len(hash) <= 12 {
293		return hash
294	}
295	return hash[:12]
296}
297
298// boundedDiffPatch enforces display limits after parsing as a defense in depth
299// measure; the underlying parser can receive an entire oversized hunk at once.
300func boundedDiffPatch(diff *git.Diff) (string, bool, error) {
301	truncated := diff.IsIncomplete()
302	var patch strings.Builder
303	for fileIndex, file := range diff.Files {
304		if fileIndex >= maxDiffFiles {
305			truncated = true
306			break
307		}
308		fileCopy := *file
309		fileCopy.Sections = make([]*git.DiffSection, 0, len(file.Sections))
310		remainingLines := maxDiffFileLines
311		for _, section := range file.Sections {
312			if remainingLines == 0 {
313				truncated = true
314				break
315			}
316			sectionCopy := *section
317			if len(section.Lines) > remainingLines {
318				sectionCopy.Lines = section.Lines[:remainingLines]
319				truncated = true
320			} else {
321				sectionCopy.Lines = section.Lines
322			}
323			for _, line := range sectionCopy.Lines {
324				if utf8.RuneCountInString(line.Content) > maxDiffLineChars {
325					truncated = true
326				}
327			}
328			remainingLines -= len(sectionCopy.Lines)
329			fileCopy.Sections = append(fileCopy.Sections, &sectionCopy)
330		}
331		patch.WriteString((&git.Diff{Files: []*git.DiffFile{&fileCopy}}).Patch())
332		if patch.Len() > maxDiffBytes {
333			truncated = true
334			break
335		}
336	}
337	output := patch.String()
338	if len(output) > maxDiffBytes {
339		const marker = "\n[diff output truncated]\n"
340		output = output[:maxDiffBytes-len(marker)]
341		for !utf8.ValidString(output) {
342			output = output[:len(output)-1]
343		}
344		output += marker
345	}
346	return output, truncated, nil
347}