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, §ionCopy)
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}