d76cafed11711aba7d69065475981b6d8401ee8e

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat(web): add public repository browser

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("![logo](../images/logo.png#preview)\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&amp;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![icon](images/icon.png)\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())