browser.go
9930 bytes
1package pages
2
3import (
4 "errors"
5 "fmt"
6 "html/template"
7 "net/http"
8 "net/url"
9 "path"
10 "strings"
11 "sync"
12
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/pkg/config"
15 "github.com/charmbracelet/soft-serve/pkg/proto"
16 "github.com/charmbracelet/soft-serve/pkg/ui/common"
17 "github.com/gorilla/mux"
18)
19
20type repositoryPage struct {
21 ServerName string
22 Repository string
23 Title string
24 Description string
25 Ref string
26 HTTPCloneURL string
27 TreeURL string
28 CommitsURL string
29 RefsURL string
30 Empty bool
31 Readme template.HTML
32 BuildStatus repositoryBuildStatus
33}
34
35type treePage struct {
36 ServerName string
37 Repository string
38 Ref string
39 Path string
40 OverviewURL string
41 Entries []treePageEntry
42 Truncated bool
43 File *treePageFile
44 ParentURL string
45}
46
47type treePageEntry struct {
48 Name string
49 Size int64
50 URL string
51 LastCommit *treePageCommit
52}
53
54type treePageCommit struct {
55 ShortHash string
56 Subject string
57 URL string
58}
59
60type treePageFile struct {
61 Name string
62 Path string
63 Size int64
64 Markdown template.HTML
65 Source template.HTML
66 DownloadURL string
67 Message string
68}
69
70func repositoryOverview(w http.ResponseWriter, r *http.Request) {
71 repo, gitRepo, ok := openPublicRepository(w, r)
72 if !ok {
73 return
74 }
75 ref, empty, err := defaultRef(gitRepo)
76 if err != nil {
77 http.Error(w, "repository is unavailable", http.StatusNotFound)
78 return
79 }
80
81 cfg := config.FromContext(r.Context())
82 title := repo.ProjectName()
83 if title == "" {
84 title = repo.Name()
85 }
86 status := LoadBuildStatuses(r.Context(), cfg.HTTP.WebUI.BuildStatusURL, []string{repo.Name()})
87 page := repositoryPage{
88 ServerName: cfg.Name,
89 Repository: repo.Name(),
90 Title: title,
91 Description: repo.Description(),
92 Empty: empty,
93 HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
94 BuildStatus: status[repo.Name()],
95 }
96 if !empty {
97 page.Ref = ref.Name().String()
98 page.TreeURL = TreeURL(repo.Name(), page.Ref, "")
99 page.CommitsURL = PageURL(repo.Name(), "commits", url.Values{"ref": {page.Ref}})
100 page.RefsURL = PageURL(repo.Name(), "refs", nil)
101 if readme, readmeErr := LoadReadme(gitRepo, ref); readmeErr == nil && !readme.Binary {
102 page.Readme, _ = RenderRepositoryMarkdown(repo.Name(), page.Ref, readme.Path, readme.Bytes)
103 } else if readmeErr != nil && !errors.Is(readmeErr, ErrReadmeNotFound) && !errors.Is(readmeErr, ErrContentTooLarge) {
104 http.Error(w, "repository is unavailable", http.StatusNotFound)
105 return
106 }
107 }
108 renderPage(w, "repository", page)
109}
110
111func treeBrowser(w http.ResponseWriter, r *http.Request) {
112 repo, gitRepo, ok := openPublicRepository(w, r)
113 if !ok {
114 return
115 }
116 ref, err := ResolveRef(gitRepo, r.URL.Query().Get("ref"))
117 if err != nil {
118 http.Error(w, "not found", http.StatusNotFound)
119 return
120 }
121 treePath, err := ValidateTreePath(r.URL.Query().Get("path"))
122 if err != nil {
123 http.Error(w, "not found", http.StatusNotFound)
124 return
125 }
126 cfg := config.FromContext(r.Context())
127 page := treePage{
128 ServerName: cfg.Name,
129 Repository: repo.Name(),
130 Ref: ref.Name().String(),
131 Path: treePath,
132 OverviewURL: RepositoryURL(repo.Name()),
133 }
134
135 if treePath == "" {
136 page.Entries, page.Truncated, err = treePageEntries(gitRepo, ref, repo.Name(), page.Ref, treePath)
137 } else {
138 entry, entryErr := TreeEntry(gitRepo, ref, treePath)
139 if entryErr != nil {
140 err = entryErr
141 } else if entry.IsTree() {
142 page.Entries, page.Truncated, err = treePageEntries(gitRepo, ref, repo.Name(), page.Ref, treePath)
143 page.ParentURL = TreeURL(repo.Name(), page.Ref, path.Dir(treePath))
144 } else {
145 page.File, err = treeFilePage(entry, cfg.HTTP.PublicURL, repo.Name(), page.Ref, treePath)
146 page.ParentURL = TreeURL(repo.Name(), page.Ref, path.Dir(treePath))
147 }
148 }
149 if err != nil {
150 http.Error(w, "not found", http.StatusNotFound)
151 return
152 }
153 renderPage(w, "tree", page)
154}
155
156const treeCommitLookupWorkers = 8
157
158func treePageEntries(repo *git.Repository, ref *git.Reference, repository, refName, treePath string) ([]treePageEntry, bool, error) {
159 entries, truncated, err := TreeEntries(repo, ref, treePath)
160 if err != nil {
161 return nil, false, err
162 }
163 entryPaths := make([]string, len(entries))
164 pageEntries := make([]treePageEntry, len(entries))
165 for index, entry := range entries {
166 entryPaths[index] = path.Join(treePath, entry.Name())
167 pageEntries[index] = treePageEntry{
168 Name: entry.Name(),
169 Size: entry.Size(),
170 URL: TreeURL(repository, refName, entryPaths[index]),
171 }
172 }
173 for index, commit := range treeEntryCommits(repo, ref, entryPaths) {
174 if commit == nil {
175 continue
176 }
177 pageEntries[index].LastCommit = &treePageCommit{
178 ShortHash: shortHash(commit.ID.String()),
179 Subject: commit.Summary(),
180 URL: CommitURL(repository, commit.ID.String()),
181 }
182 }
183 return pageEntries, truncated, nil
184}
185
186// treeEntryCommits performs one bounded git-log lookup per displayed entry.
187// Directory paths deliberately include their descendants, matching git log's
188// path behavior. Failures leave the entry without commit metadata.
189func treeEntryCommits(repo *git.Repository, ref *git.Reference, paths []string) []*git.Commit {
190 commits := make([]*git.Commit, len(paths))
191 jobs := make(chan int)
192 var workers sync.WaitGroup
193 workerCount := treeCommitLookupWorkers
194 if workerCount > len(paths) {
195 workerCount = len(paths)
196 }
197 workers.Add(workerCount)
198 for range workerCount {
199 go func() {
200 defer workers.Done()
201 for index := range jobs {
202 commit, err := repo.CommitByPath(ref, paths[index])
203 if err == nil {
204 commits[index] = commit
205 }
206 }
207 }()
208 }
209 for index := range paths {
210 jobs <- index
211 }
212 close(jobs)
213 workers.Wait()
214 return commits
215}
216
217func treeFilePage(entry *git.TreeEntry, publicURL, repository, refName, treePath string) (*treePageFile, error) {
218 page := &treePageFile{
219 Name: entry.Name(),
220 Path: treePath,
221 Size: entry.Size(),
222 DownloadURL: AbsoluteRawURL(publicURL, repository, refName, treePath),
223 }
224 content, err := LoadBlob(entry, TextLimit)
225 if errors.Is(err, ErrContentTooLarge) {
226 page.Message = "This file is too large to display. Download it instead."
227 return page, nil
228 }
229 if err != nil {
230 // Symlinks and submodules are intentionally not followed or rendered.
231 page.DownloadURL = ""
232 page.Message = "This file type cannot be displayed."
233 return page, nil
234 }
235 if content.Binary {
236 page.Message = "This binary file cannot be displayed. Download it instead."
237 return page, nil
238 }
239 if isMarkdownFile(entry.Name()) {
240 page.Markdown, err = RenderRepositoryMarkdown(repository, refName, treePath, content.Bytes)
241 return page, err
242 }
243 page.Source, err = RenderSource(entry.Name(), content.Bytes)
244 return page, err
245}
246
247func isMarkdownFile(name string) bool {
248 extension := strings.ToLower(path.Ext(name))
249 return extension == ".md" || extension == ".markdown"
250}
251
252func rawFile(w http.ResponseWriter, r *http.Request) {
253 _, gitRepo, ok := openPublicRepository(w, r)
254 if !ok {
255 return
256 }
257 ref, err := ResolveRef(gitRepo, r.URL.Query().Get("ref"))
258 if err != nil {
259 http.Error(w, "not found", http.StatusNotFound)
260 return
261 }
262 treePath, err := ValidateTreePath(r.URL.Query().Get("path"))
263 if err != nil || treePath == "" {
264 http.Error(w, "not found", http.StatusNotFound)
265 return
266 }
267 entry, err := TreeEntry(gitRepo, ref, treePath)
268 if err != nil || (!entry.IsBlob() && !entry.IsExec()) {
269 http.Error(w, "not found", http.StatusNotFound)
270 return
271 }
272 content, err := LoadBlob(entry, RawLimit)
273 if err != nil {
274 status := http.StatusNotFound
275 if errors.Is(err, ErrContentTooLarge) {
276 status = http.StatusRequestEntityTooLarge
277 }
278 http.Error(w, "file is unavailable", status)
279 return
280 }
281 metadata := RawFileMetadata(entry.Name())
282 SetContentSafetyHeaders(w)
283 w.Header().Set("Content-Type", metadata.ContentType)
284 if metadata.ContentDisposition != "" {
285 w.Header().Set("Content-Disposition", metadata.ContentDisposition)
286 }
287 w.Header().Set("Content-Length", fmt.Sprint(len(content.Bytes)))
288 _, _ = w.Write(content.Bytes)
289}
290
291func openPublicRepository(w http.ResponseWriter, r *http.Request) (proto.Repository, *git.Repository, bool) {
292 repo, err := PublicRepository(r.Context(), mux.Vars(r)["repo"])
293 if err != nil {
294 http.Error(w, "not found", http.StatusNotFound)
295 return nil, nil, false
296 }
297 gitRepo, err := repo.Open()
298 if err != nil {
299 http.Error(w, "repository is unavailable", http.StatusNotFound)
300 return nil, nil, false
301 }
302 return repo, gitRepo, true
303}
304
305func defaultRef(repo *git.Repository) (*git.Reference, bool, error) {
306 ref, err := ResolveRef(repo, "")
307 if err == nil {
308 return ref, false, nil
309 }
310 // Git reports an unborn HEAD as an error. The overview is still useful for
311 // a valid public repository with no commits, so render its empty state.
312 return nil, true, nil
313}
314
315func renderPage(w http.ResponseWriter, name string, data any) {
316 SetSecurityHeaders(w)
317 w.Header().Set("Content-Type", "text/html; charset=utf-8")
318 if err := pageTemplates.ExecuteTemplate(w, name, data); err != nil {
319 http.Error(w, "failed to render page", http.StatusInternalServerError)
320 }
321}
322
323func safeRelativeMarkdownURL(repository, refName, sourcePath, destination string, raw bool) string {
324 u, err := url.Parse(destination)
325 if err != nil || destination == "" || strings.HasPrefix(destination, "#") || u.IsAbs() || u.Host != "" || strings.HasPrefix(u.Path, "/") {
326 return destination
327 }
328 resolved, err := ValidateTreePath(path.Join(path.Dir(sourcePath), u.Path))
329 if err != nil || resolved == "" {
330 return destination
331 }
332 fragment := u.Fragment
333 if raw {
334 u, _ = url.Parse(RawURL(repository, refName, resolved))
335 } else {
336 u, _ = url.Parse(TreeURL(repository, refName, resolved))
337 }
338 // Relative link query parameters are not resource selectors; keep only its anchor.
339 u.Fragment = fragment
340 return u.String()
341}