Render markdown and images in web UI
This commit is contained in:
@@ -48,7 +48,7 @@ CLI auth config defaults to:
|
|||||||
|
|
||||||
## Web UI
|
## 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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
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/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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
|||||||
+117
-17
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,7 +61,12 @@ type webBlobData struct {
|
|||||||
Repo Repository
|
Repo Repository
|
||||||
Ref string
|
Ref string
|
||||||
Path string
|
Path string
|
||||||
|
ParentPath string
|
||||||
Content string
|
Content string
|
||||||
|
RenderedMarkdown template.HTML
|
||||||
|
IsMarkdown bool
|
||||||
|
IsImage bool
|
||||||
|
ImageURL string
|
||||||
CanWrite bool
|
CanWrite bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +180,14 @@ var webTemplates = template.Must(template.New("web").Funcs(template.FuncMap{
|
|||||||
{{define "File"}}
|
{{define "File"}}
|
||||||
{{$d := .Data}}
|
{{$d := .Data}}
|
||||||
<h1>{{$d.Repo.Owner}}/{{$d.Repo.Name}}: {{$d.Path}}</h1>
|
<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>
|
||||||
<pre style="white-space: pre-wrap">{{$d.Content}}</pre>
|
{{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}}
|
{{end}}
|
||||||
|
|
||||||
{{define "Edit File"}}
|
{{define "Edit File"}}
|
||||||
@@ -437,6 +450,12 @@ func (s *Server) webRepoRoute(w http.ResponseWriter, r *http.Request, parts []st
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.webRepoBlob(w, r, owner, name)
|
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":
|
case "edit":
|
||||||
if r.Method == http.MethodGet {
|
if r.Method == http.MethodGet {
|
||||||
s.webRepoEdit(w, r, owner, name, "")
|
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())
|
webError(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
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)
|
content, err := gitReadBlob(s.repoPath(repo.Owner, repo.Name), ref, p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
webError(w, r, http.StatusNotFound, err.Error())
|
webError(w, r, http.StatusNotFound, err.Error())
|
||||||
return
|
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, "")
|
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) {
|
func (s *Server) webRepoEdit(w http.ResponseWriter, r *http.Request, owner, name, errMsg string) {
|
||||||
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
|
repo, user, authed, ok := s.webRepoContext(w, r, owner, name)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -738,21 +804,9 @@ func gitListTree(repoPath, ref, p string) ([]webTreeEntry, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func gitReadBlob(repoPath, ref, p string) (string, error) {
|
func gitReadBlob(repoPath, ref, p string) (string, error) {
|
||||||
if !branchRE.MatchString(ref) {
|
out, err := gitReadBlobBytes(repoPath, ref, p)
|
||||||
return "", errors.New("invalid branch")
|
|
||||||
}
|
|
||||||
spec := ref + ":" + p
|
|
||||||
sizeOut, err := exec.Command("git", "--git-dir", repoPath, "cat-file", "-s", spec).CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("file not found")
|
return "", err
|
||||||
}
|
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
if strings.Contains(string(out), "\x00") || !utf8.Valid(out) {
|
if strings.Contains(string(out), "\x00") || !utf8.Valid(out) {
|
||||||
return "", fmt.Errorf("binary file cannot be displayed")
|
return "", fmt.Errorf("binary file cannot be displayed")
|
||||||
@@ -760,6 +814,52 @@ func gitReadBlob(repoPath, ref, p string) (string, error) {
|
|||||||
return string(out), nil
|
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 {
|
func (s *Server) commitEditedFile(repo Repository, ref, p, content string, user User) error {
|
||||||
if !branchRE.MatchString(ref) {
|
if !branchRE.MatchString(ref) {
|
||||||
return errors.New("invalid branch")
|
return errors.New("invalid branch")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"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) {
|
func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
|
||||||
requireGitForApp(t)
|
requireGitForApp(t)
|
||||||
s, mock, cleanup := newMockServer(t)
|
s, mock, cleanup := newMockServer(t)
|
||||||
@@ -81,6 +117,7 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
seedRepoWithFeatureBranch(t, bare)
|
seedRepoWithFeatureBranch(t, bare)
|
||||||
|
addWebRenderFilesToRepo(t, bare)
|
||||||
|
|
||||||
expectLoadRepo(mock, "alice", "demo", repo)
|
expectLoadRepo(mock, "alice", "demo", repo)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
@@ -93,10 +130,31 @@ func TestWebRepoPagesCreateEditAndForkRules(t *testing.T) {
|
|||||||
expectLoadRepo(mock, "alice", "demo", repo)
|
expectLoadRepo(mock, "alice", "demo", repo)
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/alice/demo/blob?ref=main&path=README.md", nil))
|
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())
|
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)
|
expectBearerUser(mock, "tok", user)
|
||||||
mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "newrepo", "public", "new desc").WillReturnResult(sqlmock.NewResult(20, 1))
|
mock.ExpectExec("INSERT INTO repositories").WithArgs(user.ID, "newrepo", "public", "new desc").WillReturnResult(sqlmock.NewResult(20, 1))
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|||||||
@@ -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}` repository overview and root file tree
|
||||||
- [x] `/{username}/{repo}/tree` file tree browsing
|
- [x] `/{username}/{repo}/tree` file tree browsing
|
||||||
- [x] `/{username}/{repo}/blob` file viewing
|
- [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}/edit` simple textarea editor for files
|
||||||
- [x] `/{username}/{repo}/fork` public-repository-only fork action
|
- [x] `/{username}/{repo}/fork` public-repository-only fork action
|
||||||
- [x] Minimal CSRF protection for cookie-backed POST forms
|
- [x] Minimal CSRF protection for cookie-backed POST forms
|
||||||
|
|||||||
Reference in New Issue
Block a user