diff --git a/README.md b/README.md index 7db9446..cf9ef6e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ CLI auth config defaults to: ## Web UI -The web UI is intentionally minimal and server-rendered. It supports repository search, login/register/logout, repository creation, repository file browsing, plain-text file viewing, simple textarea-based file editing for users with write access, and forking public repositories. Private repositories cannot be forked. +The web UI is intentionally minimal and server-rendered. It supports repository search, login/register/logout, repository creation, repository file browsing, Markdown rendering, image rendering for common image files, plain-text file viewing, simple textarea-based file editing for users with write access, and forking public repositories. Private repositories cannot be forked. ## Development diff --git a/go.mod b/go.mod index c1dc42b..2e0f5ec 100644 --- a/go.mod +++ b/go.mod @@ -10,4 +10,5 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect + github.com/yuin/goldmark v1.7.8 // indirect ) diff --git a/go.sum b/go.sum index a14db02..3fd60a0 100644 --- a/go.sum +++ b/go.sum @@ -5,5 +5,7 @@ github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= diff --git a/internal/app/web.go b/internal/app/web.go index 1b5e0a2..c4e2558 100644 --- a/internal/app/web.go +++ b/internal/app/web.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "html/template" + "mime" "net/http" "net/url" "os" @@ -19,6 +20,7 @@ import ( "time" "unicode/utf8" + "github.com/yuin/goldmark" "golang.org/x/crypto/bcrypt" ) @@ -56,11 +58,16 @@ type webRepoData struct { } type webBlobData struct { - Repo Repository - Ref string - Path string - Content string - CanWrite bool + Repo Repository + Ref string + Path string + ParentPath string + Content string + RenderedMarkdown template.HTML + IsMarkdown bool + IsImage bool + ImageURL string + CanWrite bool } type webEditData struct { @@ -173,8 +180,14 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{ {{define "File"}} {{$d := .Data}}

{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}

-

back to tree{{if $d.CanWrite}} | edit{{end}}

-
{{$d.Content}}
+

back to tree{{if $d.CanWrite}} | edit{{end}}

+{{if $d.IsImage}} +

{{$d.Path}}

+{{else if $d.IsMarkdown}} +
{{$d.RenderedMarkdown}}
+{{else}} +
{{$d.Content}}
+{{end}} {{end}} {{define "Edit File"}} @@ -437,6 +450,12 @@ func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []st return } s.webRepoBlob(w, r, owner, name) + case "raw": + if r.Method != http.MethodGet { + webError(w, r, http.StatusMethodNotAllowed, "method not allowed") + return + } + s.webRepoRaw(w, r, owner, name) case "edit": if r.Method == http.MethodGet { s.webRepoEdit(w, r, owner, name, "") @@ -504,13 +523,60 @@ func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name webError(w, r, http.StatusBadRequest, err.Error()) return } - content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p) + data := webBlobData{Repo: repo, Ref: ref, Path: p, ParentPath: parentRepoPath(p), CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived} + if isWebImagePath(p) { + if _, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p); err != nil { + webError(w, r, http.StatusNotFound, err.Error()) + return + } + data.IsImage = true + data.ImageURL = "/" + repo.Owner + "/" + repo.Name + "/raw?ref=" + url.QueryEscape(ref) + "&path=" + url.QueryEscape(p) + } else { + content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p) + if err != nil { + webError(w, r, http.StatusNotFound, err.Error()) + return + } + data.Content = content + if isMarkdownPath(p) { + md, err := renderMarkdown(content) + if err != nil { + webError(w, r, http.StatusInternalServerError, err.Error()) + return + } + data.IsMarkdown = true + data.RenderedMarkdown = md + } + } + s.renderWeb(w, r, "File", data, "") +} + +func (s *Server) webRepoRaw(w http.ResponseWriter, r *http.Request, owner, name string) { + repo, _, _, ok := s.webRepoContext(w, r, owner, name) + if !ok { + return + } + ref := webRef(r, repo) + p, err := cleanRepoFilePath(r.URL.Query().Get("path"), false) + if err != nil { + webError(w, r, http.StatusBadRequest, err.Error()) + return + } + if !isWebImagePath(p) { + webError(w, r, http.StatusBadRequest, "raw web rendering is only available for images") + return + } + b, err := gitReadBlobBytes(s.repoPath(repo.Owner, repo.Name), ref, p) if err != nil { webError(w, r, http.StatusNotFound, err.Error()) return } - data := webBlobData{Repo: repo, Ref: ref, Path: p, Content: content, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived} - s.renderWeb(w, r, "File", data, "") + ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(p))) + if ct == "" { + ct = http.DetectContentType(b) + } + w.Header().Set("Content-Type", ct) + _, _ = w.Write(b) } func (s *Server) webRepoEdit(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) { @@ -738,21 +804,9 @@ func gitListTree(repoPath, ref, p string) ([]webTreeEntry, error) { } func gitReadBlob(repoPath, ref, p string) (string, error) { - if !branchRE.MatchString(ref) { - return "", errors.New("invalid branch") - } - spec := ref + ":" + p - sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput() + out, err := gitReadBlobBytes(repoPath, ref, p) if err != nil { - return "", fmt.Errorf("file not found") - } - size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) - if size > maxWebFileBytes { - return "", fmt.Errorf("file is too large to display") - } - out, err := exec.Command("git", "--git-dir", repoPath, "show", spec).CombinedOutput() - if err != nil { - return "", fmt.Errorf("file not found") + return "", err } if strings.Contains(string(out), "\x00") || !utf8.Valid(out) { return "", fmt.Errorf("binary file cannot be displayed") @@ -760,6 +814,52 @@ func gitReadBlob(repoPath, ref, p string) (string, error) { return string(out), nil } +func gitReadBlobBytes(repoPath, ref, p string) ([]byte, error) { + if !branchRE.MatchString(ref) { + return nil, errors.New("invalid branch") + } + spec := ref + ":" + p + sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("file not found") + } + size, _ := strconv.ParseInt(strings.TrimSpace(string(sizeOut)), 10, 64) + if size > maxWebFileBytes { + return nil, fmt.Errorf("file is too large to display") + } + out, err := exec.Command("git", "--git-dir", repoPath, "show", spec).CombinedOutput() + if err != nil { + return nil, fmt.Errorf("file not found") + } + return out, nil +} + +func isMarkdownPath(p string) bool { + switch strings.ToLower(filepath.Ext(p)) { + case ".md", ".markdown", ".mdown", ".mkd": + return true + default: + return false + } +} + +func isWebImagePath(p string) bool { + switch strings.ToLower(filepath.Ext(p)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg": + return true + default: + return false + } +} + +func renderMarkdown(s string) (template.HTML, error) { + var buf bytes.Buffer + if err := goldmark.Convert([]byte(s), &buf); err != nil { + return "", err + } + return template.HTML(buf.String()), nil +} + func (s *Server) commitEditedFile(repo Repository, ref, p, content string, user User) error { if !branchRE.MatchString(ref) { return errors.New("invalid branch") diff --git a/internal/app/web_test.go b/internal/app/web_test.go index 7a1fdd5..37d7fb6 100644 --- a/internal/app/web_test.go +++ b/internal/app/web_test.go @@ -1,6 +1,7 @@ package app import ( + "encoding/base64" "net/http" "net/http/httptest" "os" @@ -70,6 +71,41 @@ func TestWebReposAndLogin(t *testing.T) { } } +func addWebRenderFilesToRepo(t *testing.T, bare string) { + t.Helper() + work := filepath.Join(t.TempDir(), "render-work") + if err := runGit("", "clone", bare, work); err != nil { + t.Fatal(err) + } + if err := runGit(work, "checkout", "main"); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"config", "user.name", "Test User"}, {"config", "user.email", "test@example.com"}} { + if err := runGit(work, args...); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(work, "docs.md"), []byte("# Rendered Title\n\nThis is **markdown**.\n"), 0644); err != nil { + t.Fatal(err) + } + png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "logo.png"), png, 0644); err != nil { + t.Fatal(err) + } + if err := runGit(work, "add", "docs.md", "logo.png"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "commit", "-m", "add render files"); err != nil { + t.Fatal(err) + } + if err := runGit(work, "push", "origin", "HEAD:main"); err != nil { + t.Fatal(err) + } +} + func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) { requireGitForApp(t) s, mock, cleanup := newMockServer(t) @@ -81,6 +117,7 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) { t.Fatal(err) } seedRepoWithFeatureBranch(t, bare) + addWebRenderFilesToRepo(t, bare) expectLoadRepo(mock, "alice", "demo", repo) rr := httptest.NewRecorder() @@ -93,10 +130,31 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) { expectLoadRepo(mock, "alice", "demo", repo) rr = httptest.NewRecorder() s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/blob?ref=main&path=README.md", nil)) - if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") { + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "hello") || !strings.Contains(rr.Body.String(), `/alice/demo/tree?ref=main&path=`) { t.Fatalf("blob page status=%d body=%s", rr.Code, rr.Body.String()) } + expectLoadRepo(mock, "alice", "demo", repo) + rr = httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/blob?ref=main&path=docs.md", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "

Rendered Title

") || strings.Contains(rr.Body.String(), "# Rendered Title") { + t.Fatalf("markdown page status=%d body=%s", rr.Code, rr.Body.String()) + } + + expectLoadRepo(mock, "alice", "demo", repo) + rr = httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/blob?ref=main&path=logo.png", nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), `