Render markdown and images in web UI

This commit is contained in:
2026-06-08 15:03:41 -05:00
parent fca1dd89f2
commit 66a8242980
6 changed files with 189 additions and 26 deletions
+1 -1
View File
@@ -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
+1
View File
@@ -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
)
+2
View File
@@ -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=
+116 -16
View File
@@ -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"
)
@@ -59,7 +61,12 @@ type webBlobData struct {
Repo Repository
Ref string
Path string
ParentPath string
Content string
RenderedMarkdown template.HTML
IsMarkdown bool
IsImage bool
ImageURL string
CanWrite bool
}
@@ -173,9 +180,15 @@ 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>
<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"}}
{{$d := .Data}}
@@ -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,15 +523,62 @@ func (s *Server) webRepoBlob(w http.ResponseWriter, r *http.Request, owner, name
webError(w, r, http.StatusBadRequest, err.Error())
return
}
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 := webBlobData{Repo: repo, Ref: ref, Path: p, Content: content, CanWrite: authed && s.canWriteRepo(repo, user) && !repo.Archived}
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
}
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) {
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
if !ok {
@@ -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")
+59 -1
View File
@@ -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()
+2
View File
@@ -12,6 +12,8 @@ Goal: add a very small server-rendered web UI with minimal styling while preserv
- [x] `/{username}/{repo}` repository overview and root file tree
- [x] `/{username}/{repo}/tree` file tree browsing
- [x] `/{username}/{repo}/blob` file viewing
- [x] Markdown rendering for Markdown files
- [x] Image rendering for common image files
- [x] `/{username}/{repo}/edit` simple textarea editor for files
- [x] `/{username}/{repo}/fork` public-repository-only fork action
- [x] Minimal CSRF protection for cookie-backed POST forms