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}}
back to tree{{if $d.CanWrite}} | edit{{end}}
-{{$d.Content}}
+back to tree{{if $d.CanWrite}} | edit{{end}}
+{{if $d.IsImage}} +{{$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(), "