~gitpocket/gitpocket

1

feat(markdown): image attachments and preview

commit
a6b5bf4fe7d9f18f1112ee9ff36b3b2eceedf83b browse tree
parent
ece10ec87b759061dbb6158366d0cd96f0662987
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Unverified signed with an unregistered key
date
2026-11-04 23:10 UTC

15 files changed, +277 −11

INSTALL.md modified

@@ -118,14 +118,14 @@ Save it as `/srv/gitpocket/smtp.json`, readable only by `git`, restart the serve
118118
119119 ## Backups
120120
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.
122122
123123 ```
124124 install -m 644 deploy/gitpocket-backup.service deploy/gitpocket-backup.timer /etc/systemd/system/
125125 systemctl enable --now gitpocket-backup.timer
126126 ```
127127
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.
129129
130130 ## Upgrading
131131

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
1717
1818 - Public, unlisted and private repositories under `~user/name`, with tree, log, blame, compare, history search and tar.gz or zip downloads of any ref
1919 - 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
2021 - 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
2122 - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push
2223 - 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 {
4646 os.Remove(b)
4747 }
4848 }
49+ if err := copyAttachments(filepath.Join(root, "attachments"), filepath.Join(dir, "attachments")); err != nil {
50+ failed = append(failed, "attachments: "+err.Error())
51+ }
4952 if len(failed) > 0 {
5053 return fmt.Errorf("could not bundle %s", strings.Join(failed, "; "))
5154 }
@@ -73,3 +76,30 @@ func bundle(repo, out string) error {
7376 }
7477 return os.Rename(tmp, out)
7578 }
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 @@
11 package main
22
33 import (
4+ "bytes"
5+ "encoding/json"
6+ "image"
7+ "image/png"
48 "net/http"
59 "net/http/httptest"
610 "net/url"
@@ -388,3 +392,44 @@ func TestAppearance(t *testing.T) {
388392 t.Error("theme leaked to logged out visitors")
389393 }
390394 }
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> ![a](" + got.URL + ")"}}, 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, "![" + name + "](" + j.url + ")"); })
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}
477477 :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}
478478 :root[data-theme=dark] nav.site .brand img{filter:brightness(2.2) saturate(.9)}
479479 @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 @@
1010 <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>
1111 {{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}}
1212 {{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>
1414 <div class="actions"><button class="btn">Comment ▸</button></div>
1515 </form>{{else if not .User}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
1616 </div>

templates/layout.html modified

@@ -14,6 +14,7 @@
1414 <script defer src="/static/katex/katex.min.js?v={{$.Version}}"></script>
1515 <script defer src="/static/katex/auto-render.min.js?v={{$.Version}}"></script>
1616 <script defer src="/static/math.js?v={{$.Version}}"></script>{{end}}
17+{{if .User}}<script defer src="/static/editor.js?v={{.Version}}"></script>{{end}}
1718 </head>
1819 <body{{if .Fluid}} class="fluid"{{end}}>
1920 <header>

templates/newissue.html modified

@@ -3,7 +3,7 @@
33 <h3>New issue</h3>
44 <form method="post" action="/{{$p.Repo.Path}}/issues/new" class="stack wide">
55 <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>
77 <div class="actions"><button class="btn primary">Open issue ▸</button></div>
88 </form>
99 {{end}}

templates/newpull.html modified

@@ -4,7 +4,7 @@
44 <form method="post" action="/{{$p.Repo.Path}}/pulls/new" class="twocol">
55 <div>
66 <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>
88 <div class="actions"><button class="btn primary">Create pull request ▸</button></div>
99 </div>
1010 <div>

templates/orgsettings.html modified

@@ -25,7 +25,7 @@
2525 <label>Location <input name="location" value="{{$p.Location}}" maxlength="100"></label>
2626 <label>URL <input name="website" value="{{$p.Website}}" maxlength="200" placeholder="https://"></label>
2727 </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>
2929 <div class="actions"><button class="btn primary">Save changes ▸</button></div>
3030 </form>
3131 <h3 class="rule">Members</h3>

templates/pull.html modified

@@ -11,7 +11,7 @@
1111 {{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}}
1212 <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>
1313 {{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>
1515 <div class="actions"><button class="btn">Comment ▸</button></div>
1616 </form>
1717 {{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 @@
1919 <label class="radio"><input type="radio" name="state" value="approved"> Approve<small>The changes are good to merge</small></label>
2020 <label class="radio"><input type="radio" name="state" value="changes"> Request changes<small>Something needs to change first</small></label>
2121 <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>
2323 <div class="actions"><button class="btn primary">Submit review ▸</button></div>
2424 </form>{{end}}
2525 {{else}}<p class="muted"><a href="/login">Log in</a> to comment.</p>{{end}}
@@ -74,7 +74,7 @@
7474 {{range .Lines}}{{$side := "n"}}{{$num := .New}}{{if eq .Kind "del"}}{{$side = "o"}}{{$num = .Old}}{{end}}{{$key := printf "%s:%s:%d" $file.New $side $num}}
7575 <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>
7676 {{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}}
7878 {{end}}
7979 </table>
8080 {{end}}

templates/settings.html modified

@@ -35,7 +35,7 @@
3535 <label>Workplace <input name="workplace" value="{{$p.Workplace}}" maxlength="100"></label>
3636 </div>
3737 <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>
3939 <div class="actions"><button class="btn primary">Save changes ▸</button></div>
4040 </form>
4141 {{end}}

web.go modified

@@ -47,6 +47,7 @@ type Server struct {
4747 limiter *limiter
4848 requests *buckets
4949 heavy *buckets
50+ uploads *limiter
5051 store *Store
5152 root string
5253 host string
@@ -78,7 +79,7 @@ type page struct {
7879 }
7980
8081 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)}
8283 funcs := template.FuncMap{
8384 "short": func(h string) string {
8485 if len(h) > 10 {
@@ -265,6 +266,9 @@ func (s *Server) handler() http.Handler {
265266 mux.HandleFunc("GET /settings", s.settings)
266267 mux.HandleFunc("GET /settings/security", s.security)
267268 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)
268272 mux.HandleFunc("GET /settings/appearance", s.appearance)
269273 mux.HandleFunc("POST /settings/appearance", s.appearance)
270274 mux.HandleFunc("GET /settings/tokens", s.tokensPage)