feat(markdown): image attachments and preview
15 files changed, +277 −11
- INSTALL.md modified +2 −2
- README.md modified +1 −0
- attach.go added +108 −0
- backup.go modified +30 −0
- flow_test.go modified +45 −0
- static/editor.js added +70 −0
- static/style.css modified +7 −0
- templates/issue.html modified +1 −1
- templates/layout.html modified +1 −0
- templates/newissue.html modified +1 −1
- templates/newpull.html modified +1 −1
- templates/orgsettings.html modified +1 −1
- templates/pull.html modified +3 −3
- templates/settings.html modified +1 −1
- web.go modified +5 −1
INSTALL.md modified
| @@ -118,14 +118,14 @@ Save it as `/srv/gitpocket/smtp.json`, readable only by `git`, restart the serve | ||
| 118 | 118 | |
| 119 | 119 | ## Backups |
| 120 | 120 | |
| 121 | -`gitpocket backup <dir>` writes a dated copy of the database with `vacuum into`, keeps the last fourteen, and bundles every repository whose refs changed since its previous bundle. The supplied timer runs it nightly into `/srv/gitpocket/backups`, which should then be copied off the host. | |
| 121 | +`gitpocket backup <dir>` writes a dated copy of the database with `vacuum into`, keeps the last fourteen, and bundles every repository whose refs changed since its previous bundle and copies new image attachments. The supplied timer runs it nightly into `/srv/gitpocket/backups`, which should then be copied off the host. | |
| 122 | 122 | |
| 123 | 123 | ``` |
| 124 | 124 | install -m 644 deploy/gitpocket-backup.service deploy/gitpocket-backup.timer /etc/systemd/system/ |
| 125 | 125 | systemctl enable --now gitpocket-backup.timer |
| 126 | 126 | ``` |
| 127 | 127 | |
| 128 | -To restore, stop the server, put the newest database back as `gitpocket.db`, and `git clone --mirror` each bundle to `repos/<owner>/<name>.git`. The server reinstalls repository hooks when it starts. | |
| 128 | +To restore, stop the server, put the newest database back as `gitpocket.db`, and `git clone --mirror` each bundle to `repos/<owner>/<name>.git` and copy `attachments` back. The server reinstalls repository hooks when it starts. | |
| 129 | 129 | |
| 130 | 130 | ## Upgrading |
| 131 | 131 | |
README.md modified
| @@ -17,6 +17,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 17 | 17 | |
| 18 | 18 | - Public, unlisted and private repositories under `~user/name`, with tree, log, blame, compare, history search and tar.gz or zip downloads of any ref |
| 19 | 19 | - Branches, a branch switcher on every file view and a notice when a branch with new commits was pushed that links straight to a new pull request |
| 20 | +- Markdown in pull requests, issues and comments with a preview tab and images you paste, drop or attach | |
| 20 | 21 | - Pull requests from a branch or a fork with inline review, range diffs between force pushes and merge, squash or rebase without a working tree |
| 21 | 22 | - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push |
| 22 | 23 | - Profiles with a pinned repository list, an activity graph and a README from your `~you/you` repository |
attach.go added
| @@ -0,0 +1,108 @@ | ||
| 1 | +package main | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "crypto/sha256" | |
| 6 | + "encoding/hex" | |
| 7 | + "encoding/json" | |
| 8 | + "image" | |
| 9 | + "io" | |
| 10 | + "net/http" | |
| 11 | + "os" | |
| 12 | + "path/filepath" | |
| 13 | + "regexp" | |
| 14 | + "time" | |
| 15 | +) | |
| 16 | + | |
| 17 | +const maxAttachment = 5 << 20 | |
| 18 | + | |
| 19 | +var ( | |
| 20 | + attachName = regexp.MustCompile(`^[0-9a-f]{64}\.(png|jpg|gif|webp)$`) | |
| 21 | + attachExt = map[string]string{"png": "png", "jpeg": "jpg", "gif": "gif", "webp": "webp"} | |
| 22 | + attachType = map[string]string{"png": "image/png", "jpg": "image/jpeg", "gif": "image/gif", "webp": "image/webp"} | |
| 23 | +) | |
| 24 | + | |
| 25 | +func (s *Server) upload(w http.ResponseWriter, r *http.Request) { | |
| 26 | + u := s.current(r) | |
| 27 | + if u == nil { | |
| 28 | + http.Error(w, "log in to attach images", http.StatusUnauthorized) | |
| 29 | + return | |
| 30 | + } | |
| 31 | + if s.uploads.blocked(u.Name) { | |
| 32 | + http.Error(w, "too many uploads, try again later", http.StatusTooManyRequests) | |
| 33 | + return | |
| 34 | + } | |
| 35 | + data, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAttachment)) | |
| 36 | + if err != nil { | |
| 37 | + http.Error(w, "images are limited to 5 MB", http.StatusRequestEntityTooLarge) | |
| 38 | + return | |
| 39 | + } | |
| 40 | + cfg, format, err := image.DecodeConfig(bytes.NewReader(data)) | |
| 41 | + ext, ok := attachExt[format] | |
| 42 | + if err != nil || !ok || cfg.Width*cfg.Height > 50_000_000 { | |
| 43 | + http.Error(w, "only png, jpeg, gif and webp images can be attached", http.StatusUnsupportedMediaType) | |
| 44 | + return | |
| 45 | + } | |
| 46 | + sum := sha256.Sum256(data) | |
| 47 | + name := hex.EncodeToString(sum[:]) + "." + ext | |
| 48 | + dir := filepath.Join(s.root, "attachments") | |
| 49 | + path := filepath.Join(dir, name) | |
| 50 | + if _, err := os.Stat(path); err != nil { | |
| 51 | + tmp, err := os.CreateTemp(dir, "upload") | |
| 52 | + if os.IsNotExist(err) { | |
| 53 | + os.MkdirAll(dir, 0o755) | |
| 54 | + tmp, err = os.CreateTemp(dir, "upload") | |
| 55 | + } | |
| 56 | + if err != nil { | |
| 57 | + s.fail(w, r, 500, err.Error()) | |
| 58 | + return | |
| 59 | + } | |
| 60 | + _, err = tmp.Write(data) | |
| 61 | + if cerr := tmp.Close(); err == nil { | |
| 62 | + err = cerr | |
| 63 | + } | |
| 64 | + if err == nil { | |
| 65 | + err = os.Chmod(tmp.Name(), 0o644) | |
| 66 | + } | |
| 67 | + if err == nil { | |
| 68 | + err = os.Rename(tmp.Name(), path) | |
| 69 | + } | |
| 70 | + if err != nil { | |
| 71 | + os.Remove(tmp.Name()) | |
| 72 | + s.fail(w, r, 500, err.Error()) | |
| 73 | + return | |
| 74 | + } | |
| 75 | + } | |
| 76 | + s.uploads.fail(u.Name) | |
| 77 | + w.Header().Set("Content-Type", "application/json") | |
| 78 | + json.NewEncoder(w).Encode(map[string]string{"url": "/attachments/" + name}) | |
| 79 | +} | |
| 80 | + | |
| 81 | +func (s *Server) attachment(w http.ResponseWriter, r *http.Request) { | |
| 82 | + name := r.PathValue("name") | |
| 83 | + if !attachName.MatchString(name) { | |
| 84 | + http.NotFound(w, r) | |
| 85 | + return | |
| 86 | + } | |
| 87 | + f, err := os.Open(filepath.Join(s.root, "attachments", name)) | |
| 88 | + if err != nil { | |
| 89 | + http.NotFound(w, r) | |
| 90 | + return | |
| 91 | + } | |
| 92 | + defer f.Close() | |
| 93 | + w.Header().Set("Content-Type", attachType[filepath.Ext(name)[1:]]) | |
| 94 | + w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 95 | + w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox") | |
| 96 | + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 97 | + http.ServeContent(w, r, "", time.Time{}, f) | |
| 98 | +} | |
| 99 | + | |
| 100 | +func (s *Server) preview(w http.ResponseWriter, r *http.Request) { | |
| 101 | + if s.current(r) == nil { | |
| 102 | + http.Error(w, "log in to preview", http.StatusUnauthorized) | |
| 103 | + return | |
| 104 | + } | |
| 105 | + r.Body = http.MaxBytesReader(w, r.Body, 256<<10) | |
| 106 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 107 | + io.WriteString(w, string(s.renderMarkdown([]byte(r.FormValue("body"))))) | |
| 108 | +} | |
backup.go modified
| @@ -46,6 +46,9 @@ func backup(root, dir string, keep int) error { | ||
| 46 | 46 | os.Remove(b) |
| 47 | 47 | } |
| 48 | 48 | } |
| 49 | + if err := copyAttachments(filepath.Join(root, "attachments"), filepath.Join(dir, "attachments")); err != nil { | |
| 50 | + failed = append(failed, "attachments: "+err.Error()) | |
| 51 | + } | |
| 49 | 52 | if len(failed) > 0 { |
| 50 | 53 | return fmt.Errorf("could not bundle %s", strings.Join(failed, "; ")) |
| 51 | 54 | } |
| @@ -73,3 +76,30 @@ func bundle(repo, out string) error { | ||
| 73 | 76 | } |
| 74 | 77 | return os.Rename(tmp, out) |
| 75 | 78 | } |
| 79 | + | |
| 80 | +func copyAttachments(src, dst string) error { | |
| 81 | + files, err := os.ReadDir(src) | |
| 82 | + if err != nil || len(files) == 0 { | |
| 83 | + return nil | |
| 84 | + } | |
| 85 | + if err := os.MkdirAll(dst, 0o750); err != nil { | |
| 86 | + return err | |
| 87 | + } | |
| 88 | + for _, f := range files { | |
| 89 | + out := filepath.Join(dst, f.Name()) | |
| 90 | + if !attachName.MatchString(f.Name()) { | |
| 91 | + continue | |
| 92 | + } | |
| 93 | + if _, err := os.Stat(out); err == nil { | |
| 94 | + continue | |
| 95 | + } | |
| 96 | + data, err := os.ReadFile(filepath.Join(src, f.Name())) | |
| 97 | + if err == nil { | |
| 98 | + err = os.WriteFile(out, data, 0o640) | |
| 99 | + } | |
| 100 | + if err != nil { | |
| 101 | + return err | |
| 102 | + } | |
| 103 | + } | |
| 104 | + return nil | |
| 105 | +} | |
flow_test.go modified
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | package main |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "bytes" | |
| 5 | + "encoding/json" | |
| 6 | + "image" | |
| 7 | + "image/png" | |
| 4 | 8 | "net/http" |
| 5 | 9 | "net/http/httptest" |
| 6 | 10 | "net/url" |
| @@ -388,3 +392,44 @@ func TestAppearance(t *testing.T) { | ||
| 388 | 392 | t.Error("theme leaked to logged out visitors") |
| 389 | 393 | } |
| 390 | 394 | } |
| 395 | + | |
| 396 | +func TestAttachments(t *testing.T) { | |
| 397 | + s, h := testServer(t) | |
| 398 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 399 | + c, _ := s.store.CreateSession(alice.ID) | |
| 400 | + send := func(body []byte, cookie string) *httptest.ResponseRecorder { | |
| 401 | + r := httptest.NewRequest("POST", "/attachments", bytes.NewReader(body)) | |
| 402 | + r.Header.Set("Sec-Fetch-Site", "same-origin") | |
| 403 | + r.RemoteAddr = "192.0.2.1:1000" | |
| 404 | + if cookie != "" { | |
| 405 | + r.Header.Set("Cookie", "session="+cookie) | |
| 406 | + } | |
| 407 | + w := httptest.NewRecorder() | |
| 408 | + h.ServeHTTP(w, r) | |
| 409 | + return w | |
| 410 | + } | |
| 411 | + var img bytes.Buffer | |
| 412 | + png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 4, 4))) | |
| 413 | + if w := send(img.Bytes(), ""); w.Code != http.StatusUnauthorized { | |
| 414 | + t.Errorf("anonymous upload gave %d", w.Code) | |
| 415 | + } | |
| 416 | + if w := send([]byte("<svg onload=alert(1)>"), c); w.Code != http.StatusUnsupportedMediaType { | |
| 417 | + t.Errorf("non image accepted: %d", w.Code) | |
| 418 | + } | |
| 419 | + w := send(img.Bytes(), c) | |
| 420 | + var got struct{ URL string } | |
| 421 | + if w.Code != 200 || json.NewDecoder(w.Body).Decode(&got) != nil || !strings.HasSuffix(got.URL, ".png") { | |
| 422 | + t.Fatalf("upload gave %d %q", w.Code, got.URL) | |
| 423 | + } | |
| 424 | + served := get(h, got.URL, "") | |
| 425 | + if served.Code != 200 || served.Header().Get("Content-Type") != "image/png" || !strings.Contains(served.Header().Get("Content-Security-Policy"), "sandbox") || !bytes.Equal(served.Body.Bytes(), img.Bytes()) { | |
| 426 | + t.Errorf("attachment served as %d %v", served.Code, served.Header()) | |
| 427 | + } | |
| 428 | + if w := get(h, "/attachments/..%2fgitpocket.db", ""); w.Code != 404 { | |
| 429 | + t.Errorf("traversal gave %d", w.Code) | |
| 430 | + } | |
| 431 | + p := post(h, "/preview", url.Values{"body": {"**hi** <script>x</script> "}}, c) | |
| 432 | + if body := p.Body.String(); !strings.Contains(body, "<strong>hi</strong>") || strings.Contains(body, "<script") || !strings.Contains(body, `src="`+got.URL+`"`) { | |
| 433 | + t.Errorf("preview gave %q", body) | |
| 434 | + } | |
| 435 | +} | |
static/editor.js added
| @@ -0,0 +1,70 @@ | ||
| 1 | +document.querySelectorAll("textarea[data-md]").forEach(function (area) { | |
| 2 | + var bar = document.createElement("div"); | |
| 3 | + bar.className = "mdbar"; | |
| 4 | + var write = button("Write"), view = button("Preview"), attach = button("Attach image"); | |
| 5 | + var pick = document.createElement("input"); | |
| 6 | + pick.type = "file"; | |
| 7 | + pick.accept = "image/png,image/jpeg,image/gif,image/webp"; | |
| 8 | + pick.multiple = true; | |
| 9 | + pick.hidden = true; | |
| 10 | + var shown = document.createElement("div"); | |
| 11 | + shown.className = "readme mdpreview"; | |
| 12 | + shown.hidden = true; | |
| 13 | + write.classList.add("on"); | |
| 14 | + bar.append(write, view, attach, pick); | |
| 15 | + area.before(bar); | |
| 16 | + area.after(shown); | |
| 17 | + | |
| 18 | + function button(label) { | |
| 19 | + var b = document.createElement("button"); | |
| 20 | + b.type = "button"; | |
| 21 | + b.textContent = label; | |
| 22 | + return b; | |
| 23 | + } | |
| 24 | + | |
| 25 | + function mode(preview) { | |
| 26 | + write.classList.toggle("on", !preview); | |
| 27 | + view.classList.toggle("on", preview); | |
| 28 | + area.hidden = preview; | |
| 29 | + shown.hidden = !preview; | |
| 30 | + } | |
| 31 | + | |
| 32 | + write.onclick = function () { mode(false); area.focus(); }; | |
| 33 | + if (area.form) area.form.addEventListener("submit", function () { mode(false); }); | |
| 34 | + view.onclick = function () { | |
| 35 | + shown.textContent = "Loading…"; | |
| 36 | + mode(true); | |
| 37 | + fetch("/preview", { method: "POST", body: new URLSearchParams({ body: area.value }) }) | |
| 38 | + .then(function (r) { return r.text(); }) | |
| 39 | + .then(function (html) { shown.innerHTML = area.value.trim() ? html : "<p class=\"muted\">Nothing to preview.</p>"; }); | |
| 40 | + }; | |
| 41 | + attach.onclick = function () { pick.click(); }; | |
| 42 | + pick.onchange = function () { send(pick.files); pick.value = ""; }; | |
| 43 | + | |
| 44 | + area.addEventListener("paste", function (e) { | |
| 45 | + var files = e.clipboardData && e.clipboardData.files; | |
| 46 | + if (files && files.length) { e.preventDefault(); send(files); } | |
| 47 | + }); | |
| 48 | + area.addEventListener("dragover", function (e) { e.preventDefault(); }); | |
| 49 | + area.addEventListener("drop", function (e) { | |
| 50 | + if (e.dataTransfer.files.length) { e.preventDefault(); send(e.dataTransfer.files); } | |
| 51 | + }); | |
| 52 | + | |
| 53 | + function insert(text) { | |
| 54 | + var at = area.selectionStart, end = area.selectionEnd; | |
| 55 | + area.value = area.value.slice(0, at) + text + area.value.slice(end); | |
| 56 | + area.selectionStart = area.selectionEnd = at + text.length; | |
| 57 | + } | |
| 58 | + | |
| 59 | + function send(files) { | |
| 60 | + Array.prototype.forEach.call(files, function (file) { | |
| 61 | + var name = file.name.replace(/[\[\]()]/g, "") || "image"; | |
| 62 | + var marker = "![Uploading " + name + "…]()"; | |
| 63 | + insert(marker + "\n"); | |
| 64 | + fetch("/attachments", { method: "POST", body: file }) | |
| 65 | + .then(function (r) { return r.ok ? r.json() : r.text().then(function (t) { throw new Error(t.trim()); }); }) | |
| 66 | + .then(function (j) { area.value = area.value.replace(marker, ""); }) | |
| 67 | + .catch(function (err) { area.value = area.value.replace(marker + "\n", ""); alert(err.message); }); | |
| 68 | + }); | |
| 69 | + } | |
| 70 | +}); | |
static/style.css modified
| @@ -477,3 +477,10 @@ label.radio input{width:auto;margin:0} | ||
| 477 | 477 | :root[data-theme=dark]{--accent:#5b5fa8;--accentdark:#4a4d91;--accentsoft:#2c2e48;--focus:#4a4d91;--link:#a3a7ee;--heat0:#2f3034;--heat1:#2f3262;--heat2:#454991;--heat3:#666bc4;--heat4:#9ca0f0;--fg:#f4f4f4;--muted:#a8a8a8;--line:#3e3f45;--soft:#28292d;--bar:#303136;--add:#42be65;--del:#fa4d56;--addbg:#0e2a17;--delbg:#3a1518;--code:#28292d;--bg:#212225;--raised:#2e2f34;--raisedhover:#393a40;--edge:#57585f;--hl:#3d3412;--warnbg:#2d2510;--warnedge:#5c4b10;--warnfg:#f1c21b;--merged:#a56eff;--dotedge:rgba(255,255,255,.15);--tip:#f4f4f4;--tipfg:#212225;color-scheme:dark} |
| 478 | 478 | :root[data-theme=dark] nav.site .brand img{filter:brightness(2.2) saturate(.9)} |
| 479 | 479 | @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--accent:#5b5fa8;--accentdark:#4a4d91;--accentsoft:#2c2e48;--focus:#4a4d91;--link:#a3a7ee;--heat0:#2f3034;--heat1:#2f3262;--heat2:#454991;--heat3:#666bc4;--heat4:#9ca0f0;--fg:#f4f4f4;--muted:#a8a8a8;--line:#3e3f45;--soft:#28292d;--bar:#303136;--add:#42be65;--del:#fa4d56;--addbg:#0e2a17;--delbg:#3a1518;--code:#28292d;--bg:#212225;--raised:#2e2f34;--raisedhover:#393a40;--edge:#57585f;--hl:#3d3412;--warnbg:#2d2510;--warnedge:#5c4b10;--warnfg:#f1c21b;--merged:#a56eff;--dotedge:rgba(255,255,255,.15);--tip:#f4f4f4;--tipfg:#212225;color-scheme:dark}:root:not([data-theme=light]) nav.site .brand img{filter:brightness(2.2) saturate(.9)}} |
| 480 | +.mdbar{display:flex;gap:.2rem;margin:.2rem 0 .3rem;font-size:.85rem} | |
| 481 | +.mdbar button{font:inherit;background:none;border:1px solid transparent;color:var(--muted);padding:.1rem .55rem;cursor:pointer} | |
| 482 | +.mdbar button:hover{color:var(--fg)} | |
| 483 | +.mdbar button.on{border-color:var(--line);background:var(--soft);color:var(--fg)} | |
| 484 | +.mdbar button:last-of-type{margin-left:auto} | |
| 485 | +.mdpreview{border:1px solid var(--line);padding:.5rem .7rem;min-height:6rem} | |
| 486 | +.readme img{max-width:100%} | |
templates/issue.html modified
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | <div class="comment"><div class="who"><a href="/~{{$i.Author.Name}}">~{{$i.Author.Name}}</a> <span class="muted">{{ago $i.Created}}</span></div><div class="readme">{{if $i.Body}}{{.Data.Body}}{{else}}<p class="muted">No description.</p>{{end}}</div></div> |
| 11 | 11 | {{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}} |
| 12 | 12 | {{if and .User (not $p.Repo.Archived)}}<form method="post" action="/{{$p.Repo.Path}}/issues/{{$i.Number}}/comment" class="stack wide"> |
| 13 | -<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label> | |
| 13 | +<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported, paste or drop images to attach them" data-md></textarea></label> | |
| 14 | 14 | <div class="actions"><button class="btn">Comment ▸</button></div> |
| 15 | 15 | </form>{{else if not .User}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}} |
| 16 | 16 | </div> |
templates/layout.html modified
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | <script defer src="/static/katex/katex.min.js?v={{$.Version}}"></script> |
| 15 | 15 | <script defer src="/static/katex/auto-render.min.js?v={{$.Version}}"></script> |
| 16 | 16 | <script defer src="/static/math.js?v={{$.Version}}"></script>{{end}} |
| 17 | +{{if .User}}<script defer src="/static/editor.js?v={{.Version}}"></script>{{end}} | |
| 17 | 18 | </head> |
| 18 | 19 | <body{{if .Fluid}} class="fluid"{{end}}> |
| 19 | 20 | <header> |
templates/newissue.html modified
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | <h3>New issue</h3> |
| 4 | 4 | <form method="post" action="/{{$p.Repo.Path}}/issues/new" class="stack wide"> |
| 5 | 5 | <label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.IssueTitle}}"></label> |
| 6 | -<label>Description <textarea name="body" rows="10" placeholder="Markdown supported">{{.Data.Body}}</textarea></label> | |
| 6 | +<label>Description <textarea name="body" rows="10" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{.Data.Body}}</textarea></label> | |
| 7 | 7 | <div class="actions"><button class="btn primary">Open issue ▸</button></div> |
| 8 | 8 | </form> |
| 9 | 9 | {{end}} |
templates/newpull.html modified
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | <form method="post" action="/{{$p.Repo.Path}}/pulls/new" class="twocol"> |
| 5 | 5 | <div> |
| 6 | 6 | <label>Title <input name="title" required autofocus maxlength="200" value="{{.Data.PullTitle}}"></label> |
| 7 | -<label>Description <textarea name="body" rows="8" placeholder="Markdown supported">{{.Data.Body}}</textarea></label> | |
| 7 | +<label>Description <textarea name="body" rows="8" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{.Data.Body}}</textarea></label> | |
| 8 | 8 | <div class="actions"><button class="btn primary">Create pull request ▸</button></div> |
| 9 | 9 | </div> |
| 10 | 10 | <div> |
templates/orgsettings.html modified
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | <label>Location <input name="location" value="{{$p.Location}}" maxlength="100"></label> |
| 26 | 26 | <label>URL <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label> |
| 27 | 27 | </div> |
| 28 | -<label>Bio <textarea name="bio" rows="5" maxlength="4000" placeholder="Markdown supported">{{$p.Bio}}</textarea></label> | |
| 28 | +<label>Bio <textarea name="bio" rows="5" maxlength="4000" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{$p.Bio}}</textarea></label> | |
| 29 | 29 | <div class="actions"><button class="btn primary">Save changes ▸</button></div> |
| 30 | 30 | </form> |
| 31 | 31 | <h3 class="rule">Members</h3> |
templates/pull.html modified
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | {{range .Data.Comments}}<div class="comment"><div class="who"><a href="/~{{.Comment.Author.Name}}">~{{.Comment.Author.Name}}</a> <span class="muted">{{ago .Comment.Created}}</span></div><div class="readme">{{.Body}}</div></div>{{end}} |
| 12 | 12 | <div id="reviews">{{range .Data.Reviews}}<div class="comment review {{.State}}"><div class="who"><a href="/~{{.Author.Name}}">~{{.Author.Name}}</a> <span class="verdict {{.State}}">{{if eq .State "approved"}}approved{{else if eq .State "changes"}}requested changes{{else}}reviewed{{end}}</span> <span class="muted">{{ago .Created}}{{if ne .Head $.Data.Head}} · on an earlier revision{{end}}</span></div>{{if .Body}}<div class="readme">{{.HTML}}</div>{{end}}</div>{{end}}</div> |
| 13 | 13 | {{if .User}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/comment" class="stack wide"> |
| 14 | -<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported"></textarea></label> | |
| 14 | +<label>Leave a comment <textarea name="body" rows="5" required placeholder="Markdown supported, paste or drop images to attach them" data-md></textarea></label> | |
| 15 | 15 | <div class="actions"><button class="btn">Comment ▸</button></div> |
| 16 | 16 | </form> |
| 17 | 17 | {{if and (eq $pr.State "open") (ne .User.ID $pr.Author.ID)}}<form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/verdict" class="stack wide reviewverdict"> |
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | <label class="radio"><input type="radio" name="state" value="approved"> Approve<small>The changes are good to merge</small></label> |
| 20 | 20 | <label class="radio"><input type="radio" name="state" value="changes"> Request changes<small>Something needs to change first</small></label> |
| 21 | 21 | <label class="radio"><input type="radio" name="state" value="comment" checked> Comment<small>Feedback without a verdict</small></label> |
| 22 | -<label>Summary <textarea name="body" rows="3" placeholder="Markdown supported"></textarea></label> | |
| 22 | +<label>Summary <textarea name="body" rows="3" placeholder="Markdown supported, paste or drop images to attach them" data-md></textarea></label> | |
| 23 | 23 | <div class="actions"><button class="btn primary">Submit review ▸</button></div> |
| 24 | 24 | </form>{{end}} |
| 25 | 25 | {{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}} |
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | {{range .Lines}}{{$side := "n"}}{{$num := .New}}{{if eq .Kind "del"}}{{$side = "o"}}{{$num = .Old}}{{end}}{{$key := printf "%s:%s:%d" $file.New $side $num}} |
| 75 | 75 | <tr class="{{.Kind}}" id="r-{{$key}}"><td class="n">{{if .Old}}{{if and $.User (ne .Kind "meta")}}<a href="?comment={{$file.New}}:o:{{.Old}}#r-{{$file.New}}:o:{{.Old}}">{{.Old}}</a>{{else}}{{.Old}}{{end}}{{end}}</td><td class="n">{{if .New}}{{if and $.User (ne .Kind "meta")}}<a href="?comment={{$file.New}}:n:{{.New}}#r-{{$file.New}}:n:{{.New}}">{{.New}}</a>{{else}}{{.New}}{{end}}{{end}}</td><td class="l">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else if eq .Kind "ctx"}} {{end}}{{.Text}}</td></tr> |
| 76 | 76 | {{template "reviews" (index $.Data.Inline $key)}} |
| 77 | -{{if eq $.Data.CommentAt $key}}<tr class="inline"><td colspan="3"><form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/review" class="stack wide reviewform"><input type="hidden" name="path" value="{{$file.New}}"><input type="hidden" name="side" value="{{$side}}"><input type="hidden" name="line" value="{{$num}}"><textarea name="body" rows="4" required autofocus placeholder="Markdown supported"></textarea><div class="actions left"><button class="btn primary">Comment ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}#diff">Cancel</a></div></form></td></tr>{{end}} | |
| 77 | +{{if eq $.Data.CommentAt $key}}<tr class="inline"><td colspan="3"><form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/review" class="stack wide reviewform"><input type="hidden" name="path" value="{{$file.New}}"><input type="hidden" name="side" value="{{$side}}"><input type="hidden" name="line" value="{{$num}}"><textarea name="body" rows="4" required autofocus placeholder="Markdown supported, paste or drop images to attach them" data-md></textarea><div class="actions left"><button class="btn primary">Comment ▸</button><a class="btn" href="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}#diff">Cancel</a></div></form></td></tr>{{end}} | |
| 78 | 78 | {{end}} |
| 79 | 79 | </table> |
| 80 | 80 | {{end}} |
templates/settings.html modified
| @@ -35,7 +35,7 @@ | ||
| 35 | 35 | <label>Workplace <input name="workplace" value="{{$p.Workplace}}" maxlength="100"></label> |
| 36 | 36 | </div> |
| 37 | 37 | <label>URL <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label> |
| 38 | -<label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported">{{$p.Bio}}</textarea></label> | |
| 38 | +<label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{$p.Bio}}</textarea></label> | |
| 39 | 39 | <div class="actions"><button class="btn primary">Save changes ▸</button></div> |
| 40 | 40 | </form> |
| 41 | 41 | {{end}} |
web.go modified
| @@ -47,6 +47,7 @@ type Server struct { | ||
| 47 | 47 | limiter *limiter |
| 48 | 48 | requests *buckets |
| 49 | 49 | heavy *buckets |
| 50 | + uploads *limiter | |
| 50 | 51 | store *Store |
| 51 | 52 | root string |
| 52 | 53 | host string |
| @@ -78,7 +79,7 @@ type page struct { | ||
| 78 | 79 | } |
| 79 | 80 | |
| 80 | 81 | func newServer(store *Store, root, host, name string, sshUser string) *Server { |
| 81 | - s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20)} | |
| 82 | + s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour)} | |
| 82 | 83 | funcs := template.FuncMap{ |
| 83 | 84 | "short": func(h string) string { |
| 84 | 85 | if len(h) > 10 { |
| @@ -265,6 +266,9 @@ func (s *Server) handler() http.Handler { | ||
| 265 | 266 | mux.HandleFunc("GET /settings", s.settings) |
| 266 | 267 | mux.HandleFunc("GET /settings/security", s.security) |
| 267 | 268 | mux.HandleFunc("GET /settings/keys", s.keysPage) |
| 269 | + mux.HandleFunc("POST /attachments", s.upload) | |
| 270 | + mux.HandleFunc("GET /attachments/{name}", s.attachment) | |
| 271 | + mux.HandleFunc("POST /preview", s.preview) | |
| 268 | 272 | mux.HandleFunc("GET /settings/appearance", s.appearance) |
| 269 | 273 | mux.HandleFunc("POST /settings/appearance", s.appearance) |
| 270 | 274 | mux.HandleFunc("GET /settings/tokens", s.tokensPage) |