d76cafed11711aba7d69065475981b6d8401ee8e
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/pkg/web/pages/browser.go b/pkg/web/pages/browser.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..79820c61b0d38e114a3ab64e6fd116be2ce03170
4--- /dev/null
5+++ b/pkg/web/pages/browser.go
6@@ -0,0 +1,281 @@
7+package pages
8+
9+import (
10+ "errors"
11+ "fmt"
12+ "html/template"
13+ "net/http"
14+ "net/url"
15+ "path"
16+ "strings"
17+
18+ "github.com/charmbracelet/soft-serve/git"
19+ "github.com/charmbracelet/soft-serve/pkg/config"
20+ "github.com/charmbracelet/soft-serve/pkg/proto"
21+ "github.com/charmbracelet/soft-serve/pkg/ui/common"
22+ "github.com/gorilla/mux"
23+)
24+
25+type repositoryPage struct {
26+ ServerName string
27+ Repository string
28+ Title string
29+ Description string
30+ Ref string
31+ SSHCloneURL string
32+ HTTPCloneURL string
33+ TreeURL string
34+ CommitsURL string
35+ RefsURL string
36+ Empty bool
37+ Readme template.HTML
38+}
39+
40+type treePage struct {
41+ ServerName string
42+ Repository string
43+ Ref string
44+ Path string
45+ OverviewURL string
46+ Entries []treePageEntry
47+ Truncated bool
48+ File *treePageFile
49+ ParentURL string
50+}
51+
52+type treePageEntry struct {
53+ Name string
54+ Mode string
55+ Size int64
56+ URL string
57+}
58+
59+type treePageFile struct {
60+ Name string
61+ Path string
62+ Mode string
63+ Size int64
64+ Source template.HTML
65+ DownloadURL string
66+ Message string
67+}
68+
69+func repositoryOverview(w http.ResponseWriter, r *http.Request) {
70+ repo, gitRepo, ok := openPublicRepository(w, r)
71+ if !ok {
72+ return
73+ }
74+ ref, empty, err := defaultRef(gitRepo)
75+ if err != nil {
76+ http.Error(w, "repository is unavailable", http.StatusNotFound)
77+ return
78+ }
79+
80+ cfg := config.FromContext(r.Context())
81+ title := repo.ProjectName()
82+ if title == "" {
83+ title = repo.Name()
84+ }
85+ page := repositoryPage{
86+ ServerName: cfg.Name,
87+ Repository: repo.Name(),
88+ Title: title,
89+ Description: repo.Description(),
90+ Empty: empty,
91+ SSHCloneURL: common.RepoURL(cfg.SSH.PublicURL, repo.Name()),
92+ HTTPCloneURL: common.RepoURL(cfg.HTTP.PublicURL, repo.Name()),
93+ }
94+ if !empty {
95+ page.Ref = ref.Name().String()
96+ page.TreeURL = TreeURL(repo.Name(), page.Ref, "")
97+ page.CommitsURL = PageURL(repo.Name(), "commits", url.Values{"ref": {page.Ref}})
98+ page.RefsURL = PageURL(repo.Name(), "refs", nil)
99+ if readme, readmeErr := LoadReadme(gitRepo, ref); readmeErr == nil && !readme.Binary {
100+ page.Readme, _ = RenderRepositoryMarkdown(repo.Name(), page.Ref, readme.Path, readme.Bytes)
101+ } else if readmeErr != nil && !errors.Is(readmeErr, ErrReadmeNotFound) && !errors.Is(readmeErr, ErrContentTooLarge) {
102+ http.Error(w, "repository is unavailable", http.StatusNotFound)
103+ return
104+ }
105+ }
106diff --git a/pkg/web/pages/browser_test.go b/pkg/web/pages/browser_test.go
107new file mode 100644
108index 0000000000000000000000000000000000000000..efdb33c279383046c65fdb4547a711f6eaaf31fe
109--- /dev/null
110+++ b/pkg/web/pages/browser_test.go
111@@ -0,0 +1,27 @@
112+package pages
113+
114+import (
115+ "strings"
116+ "testing"
117+)
118+
119+func TestRenderRepositoryMarkdownRewritesRelativeImages(t *testing.T) {
120+ rendered, err := RenderRepositoryMarkdown("team/repository", "refs/heads/main", "docs/README.md", []byte("\n\n[external](https://example.com/docs)\n"))
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ output := string(rendered)
125+ if !strings.Contains(output, `/team/repository/@/raw?path=images%2Flogo.png&ref=refs%2Fheads%2Fmain#preview`) {
126+ t.Fatalf("relative image was not rewritten to raw endpoint: %s", output)
127+ }
128+ if !strings.Contains(output, `https://example.com/docs`) {
129+ t.Fatalf("external URL was unexpectedly rewritten: %s", output)
130+ }
131+}
132+
133+func TestSafeDownloadFilenameRemovesHeaderControls(t *testing.T) {
134+ metadata := RawFileMetadata("image\r\nInjected: value.html")
135+ if strings.ContainsAny(metadata.ContentDisposition, "\r\n") {
136+ t.Fatalf("unsafe content disposition: %q", metadata.ContentDisposition)
137+ }
138+}
139diff --git a/pkg/web/pages/controller.go b/pkg/web/pages/controller.go
140index 6968f39d80c48af98c1374951c43e6bc3f3aefc8..8c85f1fad50cd91afe88cf0fcecf34150120fc46 100644
141--- a/pkg/web/pages/controller.go
142+++ b/pkg/web/pages/controller.go
143@@ -22,8 +22,13 @@ var (
144
145 // Controller registers public web UI routes.
146 func Controller(_ context.Context, r *mux.Router) {
147+ // Exact and infrastructure paths precede page operations; the generic
148+ // overview route must remain last so nested repository names are unambiguous.
149 r.HandleFunc("/", home).Methods(http.MethodGet)
150 r.HandleFunc("/@/assets/site.css", stylesheet).Methods(http.MethodGet)
151+ r.HandleFunc("/{repo:.*}/@/tree", treeBrowser).Methods(http.MethodGet)
152+ r.HandleFunc("/{repo:.*}/@/raw", rawFile).Methods(http.MethodGet)
153+ r.HandleFunc("/{repo:.*}", repositoryOverview).Methods(http.MethodGet)
154 }
155
156 type homePage struct {
157diff --git a/pkg/web/pages/raw.go b/pkg/web/pages/raw.go
158index fb5110d32bb122260576476839046631b799cbc8..1357b9a056163cde26949cbe32b40cca18da5e27 100644
159--- a/pkg/web/pages/raw.go
160+++ b/pkg/web/pages/raw.go
161@@ -4,6 +4,7 @@ import (
162 "mime"
163 "path"
164 "strings"
165+ "unicode"
166 )
167
168 // RawMetadata describes how a raw blob may be sent without executable content.
169@@ -39,6 +40,20 @@ func RawFileMetadata(filename string) RawMetadata {
170 }
171 return RawMetadata{
172 ContentType: "application/octet-stream",
173- ContentDisposition: mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(filename)}),
174+ ContentDisposition: mime.FormatMediaType("attachment", map[string]string{"filename": safeDownloadFilename(filename)}),
175 }
176 }
177+
178+func safeDownloadFilename(filename string) string {
179+ filename = path.Base(filename)
180+ filename = strings.Map(func(char rune) rune {
181+ if char == '/' || char == '\\' || unicode.IsControl(char) {
182+ return -1
183+ }
184+ return char
185+ }, filename)
186+ if filename == "" || filename == "." {
187+ return "download"
188+ }
189+ return filename
190+}
191diff --git a/pkg/web/pages/render.go b/pkg/web/pages/render.go
192index 49fdbe3eca4310af356156e7485d408ab5b71ffb..b7109de4daea8ea1b5e82b38c72d46b5296b087f 100644
193--- a/pkg/web/pages/render.go
194+++ b/pkg/web/pages/render.go
195@@ -11,6 +11,8 @@ import (
196 "github.com/alecthomas/chroma/v2/styles"
197 "github.com/microcosm-cc/bluemonday"
198 "github.com/yuin/goldmark"
199+ "github.com/yuin/goldmark/ast"
200+ "github.com/yuin/goldmark/text"
201 )
202
203 // SetSecurityHeaders applies the common security policy for dynamic page responses.
204@@ -29,6 +31,31 @@ func RenderMarkdown(source []byte) (template.HTML, error) {
205 return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(rendered.Bytes())), nil // #nosec G203 -- sanitizer output only
206 }
207
208+// RenderRepositoryMarkdown renders Markdown and rewrites safe relative links through page routes.
209+func RenderRepositoryMarkdown(repository, refName, sourcePath string, source []byte) (template.HTML, error) {
210+ markdown := goldmark.New()
211+ document := markdown.Parser().Parse(text.NewReader(source))
212+ if err := ast.Walk(document, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
213+ if !entering {
214+ return ast.WalkContinue, nil
215+ }
216+ switch node := node.(type) {
217+ case *ast.Link:
218+ node.Destination = []byte(safeRelativeMarkdownURL(repository, refName, sourcePath, string(node.Destination), false))
219+ case *ast.Image:
220+ node.Destination = []byte(safeRelativeMarkdownURL(repository, refName, sourcePath, string(node.Destination), true))
221+ }
222+ return ast.WalkContinue, nil
223+ }); err != nil {
224+ return "", err
225+ }
226+ var rendered bytes.Buffer
227+ if err := markdown.Renderer().Render(&rendered, source, document); err != nil {
228+ return "", err
229+ }
230+ return template.HTML(bluemonday.UGCPolicy().SanitizeBytes(rendered.Bytes())), nil // #nosec G203 -- sanitizer output only
231+}
232+
233 // RenderSource highlights bounded source using Chroma's escaping HTML formatter.
234 func RenderSource(filename string, source []byte) (template.HTML, error) {
235 lexer := lexers.Match(filename)
236@@ -44,7 +71,7 @@ func RenderSource(filename string, source []byte) (template.HTML, error) {
237 }
238
239 var rendered bytes.Buffer
240- formatter := chromahtml.New(chromahtml.WithClasses(true))
241+ formatter := chromahtml.New(chromahtml.WithClasses(true), chromahtml.WithLineNumbers(true))
242 if err := formatter.Format(&rendered, styles.Fallback, iterator); err != nil {
243 return "", err
244 }
245diff --git a/pkg/web/pages/templates/repository.gohtml b/pkg/web/pages/templates/repository.gohtml
246new file mode 100644
247index 0000000000000000000000000000000000000000..23973ab9e013f942245242cb27a765a9aafbd107
248--- /dev/null
249+++ b/pkg/web/pages/templates/repository.gohtml
250@@ -0,0 +1,66 @@
251+{{ define "repository" }}<!doctype html>
252+<html lang="en">
253+<head>
254+ <meta charset="utf-8">
255+ <meta name="viewport" content="width=device-width, initial-scale=1">
256+ <title>{{ .Title }} · {{ .ServerName }}</title>
257+ <link rel="stylesheet" href="/@/assets/site.css">
258+</head>
259+<body>
260+ <header class="site-header"><nav aria-label="Primary"><a class="site-name" href="/">{{ .ServerName }}</a></nav></header>
261+ <main>
262+ <article>
263+ <h1>{{ .Title }}</h1>
264+ {{ if ne .Title .Repository }}<p class="repository-name">{{ .Repository }}</p>{{ end }}
265+ {{ if .Description }}<p>{{ .Description }}</p>{{ end }}
266+ {{ if .Empty }}
267+ <p>This repository is empty.</p>
268+ {{ else }}
269+ <p>Default ref: <code>{{ .Ref }}</code></p>
270+ <nav class="repository-tabs" aria-label="Repository navigation">
271+ <a href="{{ .TreeURL }}">Files</a>
272+ <a href="{{ .CommitsURL }}">Commits</a>
273+ <a href="{{ .RefsURL }}">Branches and tags</a>
274+ </nav>
275+ {{ end }}
276+ <section aria-labelledby="clone-heading">
277+ <h2 id="clone-heading">Clone</h2>
278+ <label>SSH <input readonly value="{{ .SSHCloneURL }}" aria-label="SSH clone URL"></label>
279+ <label>HTTP <input readonly value="{{ .HTTPCloneURL }}" aria-label="HTTP clone URL"></label>
280+ </section>
281+ {{ if .Readme }}<section aria-labelledby="readme-heading"><h2 id="readme-heading">README</h2><div class="markdown">{{ .Readme }}</div></section>{{ end }}
282+ </article>
283+ </main>
284+</body>
285+</html>{{ end }}
286+
287+{{ define "tree" }}<!doctype html>
288+<html lang="en">
289+<head>
290+ <meta charset="utf-8">
291+ <meta name="viewport" content="width=device-width, initial-scale=1">
292+ <title>{{ .Repository }} · {{ .ServerName }}</title>
293+ <link rel="stylesheet" href="/@/assets/site.css">
294+</head>
295+<body>
296+ <header class="site-header"><nav aria-label="Primary"><a class="site-name" href="/">{{ .ServerName }}</a></nav></header>
297+ <main>
298+ <p><a href="{{ .OverviewURL }}">{{ .Repository }}</a> / <code>{{ .Ref }}</code>{{ if .Path }} / {{ .Path }}{{ end }}</p>
299+ {{ if .ParentURL }}<p><a href="{{ .ParentURL }}">Parent directory</a></p>{{ end }}
300+ {{ if .File }}
301+ <article>
302+ <h1>{{ .File.Name }}</h1>
303+ <p>{{ .File.Mode }} · {{ .File.Size }} bytes</p>
304+ {{ if .File.Message }}<p>{{ .File.Message }}{{ if .File.DownloadURL }} <a href="{{ .File.DownloadURL }}">Download {{ .File.Name }}</a>{{ end }}</p>{{ else }}<pre class="source"><code>{{ .File.Source }}</code></pre>{{ end }}
305+ </article>
306+ {{ else }}
307+ <h1>Files</h1>
308+ {{ if .Truncated }}<p>This directory has more than 1,000 entries; only the first 1,000 are shown.</p>{{ end }}
309+ <table>
310+ <thead><tr><th scope="col">Name</th><th scope="col">Mode</th><th scope="col">Size</th></tr></thead>
311+ <tbody>{{ range .Entries }}<tr><td><a href="{{ .URL }}">{{ .Name }}</a></td><td><code>{{ .Mode }}</code></td><td>{{ .Size }}</td></tr>{{ else }}<tr><td colspan="3">This directory is empty.</td></tr>{{ end }}</tbody>
312+ </table>
313+ {{ end }}
314+ </main>
315+</body>
316+</html>{{ end }}
317diff --git a/pkg/web/repository_browser_test.go b/pkg/web/repository_browser_test.go
318new file mode 100644
319index 0000000000000000000000000000000000000000..b40554f067f2204836ee78053553b24d6af500d0
320--- /dev/null
321+++ b/pkg/web/repository_browser_test.go
322@@ -0,0 +1,140 @@
323+package web
324+
325+import (
326+ "net/http"
327+ "net/http/httptest"
328+ "strings"
329+ "testing"
330+
331+ "github.com/charmbracelet/soft-serve/pkg/access"
332+ "github.com/charmbracelet/soft-serve/pkg/config"
333+ "github.com/charmbracelet/soft-serve/pkg/proto"
334+)
335+
336+func TestRepositoryBrowserRawSizeLimit(t *testing.T) {
337+ ctx, be, _ := newLFSTestContext(t)
338+ cfg := config.FromContext(ctx)
339+ cfg.HTTP.WebUI.Enabled = true
340+ allowKeyless := true
341+ cfg.AllowKeyless = &allowKeyless
342+ anonymous := access.ReadOnlyAccess
343+ cfg.AnonAccess = &anonymous
344+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ if _, err := be.CreateRepository(ctx, "large", owner, proto.RepositoryOptions{}); err != nil {
349+ t.Fatal(err)
350+ }
351+ commitHomepageFile(t, cfg.DataPath, "large", "large.bin", strings.Repeat("x", (16<<20)+1))
352+
353+ w := httptest.NewRecorder()
354+ NewRouter(ctx).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/large/@/raw?path=large.bin", nil))
355+ if w.Code != http.StatusRequestEntityTooLarge {
356+ t.Fatalf("oversized raw response = %d: %s", w.Code, w.Body.String())
357+ }
358+}
359+
360+func TestRepositoryBrowserOverviewAndTreeRoutes(t *testing.T) {
361+ ctx, be, _ := newLFSTestContext(t)
362+ cfg := config.FromContext(ctx)
363+ cfg.HTTP.WebUI.Enabled = true
364+ cfg.SSH.PublicURL = "ssh://git@ssh.example"
365+ cfg.HTTP.PublicURL = "https://git.example"
366+ allowKeyless := true
367+ cfg.AllowKeyless = &allowKeyless
368+ anonymous := access.ReadOnlyAccess
369+ cfg.AnonAccess = &anonymous
370+
371+ owner, err := be.CreateUser(ctx, "owner", proto.UserOptions{})
372+ if err != nil {
373+ t.Fatal(err)
374+ }
375+ if _, err := be.CreateRepository(ctx, "team/nested", owner, proto.RepositoryOptions{
376+ ProjectName: "Nested project", Description: "A public repository",
377+ }); err != nil {
378+ t.Fatal(err)
379+ }
380+ if _, err := be.CreateRepository(ctx, "team/empty", owner, proto.RepositoryOptions{}); err != nil {
381+ t.Fatal(err)
382+ }
383+ if _, err := be.CreateRepository(ctx, "team/private", owner, proto.RepositoryOptions{Private: true}); err != nil {
384+ t.Fatal(err)
385+ }
386+ commitHomepageFile(t, cfg.DataPath, "team/nested", "README.md", "# Hello\n\n\n")
387+ commitHomepageFile(t, cfg.DataPath, "team/nested", "dir/a.txt", "a")
388+ commitHomepageFile(t, cfg.DataPath, "team/nested", "dir/nested/b.txt", "b")
389+ commitHomepageFile(t, cfg.DataPath, "team/nested", "source.go", "<script>alert(1)</script>")
390+ commitHomepageFile(t, cfg.DataPath, "team/nested", "images/icon.png", string([]byte{'\x89', 'P', 'N', 'G'}))
391+ commitHomepageFile(t, cfg.DataPath, "team/nested", "unsafe.svg", "<svg></svg>")
392+ commitHomepageFile(t, cfg.DataPath, "team/nested", "unsafe.html", "<script></script>")
393+ commitHomepageFile(t, cfg.DataPath, "team/nested", "unknown.xyz", "unknown")
394+
395+ worktree := t.TempDir()
396+ runHomepageGit(t, "clone", cfg.DataPath+"/repos/team/nested.git", worktree)
397+ runHomepageGitIn(t, worktree, "config", "user.email", "test@example.com")
398+ runHomepageGitIn(t, worktree, "config", "user.name", "Test User")
399+ runHomepageGitIn(t, worktree, "checkout", "-qb", "feature/with-slash")
400+ runHomepageGitIn(t, worktree, "push", "origin", "HEAD")
401+
402+ router := NewRouter(ctx)
403+ request := func(target string) *httptest.ResponseRecorder {
404+ w := httptest.NewRecorder()
405+ router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
406+ return w
407+ }
408+
409+ overview := request("/team/nested")
410+ if overview.Code != http.StatusOK {
411+ t.Fatalf("overview status = %d: %s", overview.Code, overview.Body.String())
412+ }
413+ for _, expected := range []string{"Nested project", "git@ssh.example:team/nested.git", "https://git.example/team/nested.git", "/team/nested/@/tree", "/team/nested/@/raw?path=images%2Ficon.png"} {
414+ if !strings.Contains(overview.Body.String(), expected) {
415+ t.Errorf("overview missing %q: %s", expected, overview.Body.String())
416+ }
417+ }
418+
419+ empty := request("/team/empty")
420+ if empty.Code != http.StatusOK || !strings.Contains(empty.Body.String(), "repository is empty") {
421+ t.Errorf("empty repository response = %d: %s", empty.Code, empty.Body.String())