Parent directory

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}