Render markdown and images in web UI
This commit is contained in:
+124
-24
@@ -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}}
|
||||
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1>
|
||||
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.Path}}">back to tree</a>{{if $d.CanWrite}} | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}&path={{urlquery $d.Path}}">edit</a>{{end}}</p>
|
||||
<pre style="white-space: pre-wrap">{{$d.Content}}</pre>
|
||||
<p><a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/tree?ref={{urlquery $d.Ref}}&path={{urlquery $d.ParentPath}}">back to tree</a>{{if $d.CanWrite}} | <a href="/{{$d.Repo.Owner}}/{{$d.Repo.Name}}/edit?ref={{urlquery $d.Ref}}&path={{urlquery $d.Path}}">edit</a>{{end}}</p>
|
||||
{{if $d.IsImage}}
|
||||
<p><img src="{{$d.ImageURL}}" alt="{{$d.Path}}" style="max-width: 100%; height: auto"></p>
|
||||
{{else if $d.IsMarkdown}}
|
||||
<article>{{$d.RenderedMarkdown}}</article>
|
||||
{{else}}
|
||||
<pre style="white-space: pre-wrap">{{$d.Content}}</pre>
|
||||
{{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")
|
||||
|
||||
@@ -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(), "<h1>Rendered Title</h1>") || 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(), `<img src="/alice/demo/raw?`) || !strings.Contains(rr.Body.String(), "logo.png") {
|
||||
t.Fatalf("image 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/raw?ref=main&path=logo.png", nil))
|
||||
if rr.Code != http.StatusOK || !strings.HasPrefix(rr.Header().Get("Content-Type"), "image/png") || len(rr.Body.Bytes()) == 0 {
|
||||
t.Fatalf("raw image status=%d content-type=%q len=%d", rr.Code, rr.Header().Get("Content-Type"), rr.Body.Len())
|
||||
}
|
||||
|
||||
expectBearerUser(mock, "tok", user)
|
||||
mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "newrepo", "public", "new desc").WillReturnResult(sqlmock.NewResult(20, 1))
|
||||
rr = httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user