web: release assets, milestones, org labels and milestones !530

merged merged by cmc on 2026-09-29 06:19 UTC · krz/gitbay:web-parity-296b into main

15 files changed, +801 −28

Layout: unified · split

.gitbay/wiki/Parity.org +12 −10
@@ -126,9 +126,9 @@ reviews, since an approval was of the diff against the old branch.
126126| choose body markup | yes | yes | yes |
127127| preview body markup | n/a | yes | no |
128128| issue templates | yes | yes | yes |
129| milestone create, close, reopen | yes | no | yes |
130| org labels: set, list, remove | yes | list | yes |
131| org milestones: create, list, close, reopen | yes | list | yes |
129| milestone create, close, reopen | yes | yes | yes |
130| org labels: set, list, remove | yes | yes | yes |
131| org milestones: create, list, close, reopen | yes | yes | yes |
132132| closes across repositories | yes | yes | yes |
133133
134134Labels are created on the fly by =issue label --add= and =mr label
@@ -139,11 +139,13 @@ chip and derives one from the name when none is set. The set itself is
139139at =/<owner>/<repo>/labels=, linked from the issue list: create,
140140recolour and remove, dispatching the same commands.
141141
142Org labels and milestones are managed on the CLI, the API and the iOS
143client's org screen; =/<org>/-/labels= and =/<org>/-/milestones= show
144them on the web. The repository
145label page's form exists for colour alone, and three org forms nobody
146asked for were not worth their handlers.
142Org labels and milestones are managed at =/<org>/-/labels= and
143=/<org>/-/milestones=, where org admins see the create, colour, remove,
144close and reopen forms and everyone else sees the list. The repository
145milestones page carries create, close and reopen for writers; a
146milestone the org holds shows no buttons there. Uploading a release
147asset from the releases page streams the file to =release asset add= on
148stdin, capped at =max_asset_bytes=.
147149
148150Issue, MR and release bodies, and their comments, carry the markup they
149151were written in — =--format md|org= on create, comment and edit, stored
@@ -234,8 +236,8 @@ rather than the one the web page shows.
234236| delete, transfer | yes | yes | no |
235237| rename | yes | yes | yes |
236238| release delete | yes | yes | yes |
237| release asset add | yes | no | n/a |
238| release asset remove | yes | no | yes |
239| release asset add | yes | yes | n/a |
240| release asset remove | yes | yes | yes |
239241| snippet create, edit, delete | yes | yes | yes |
240242| snippet show, list | yes | yes | yes |
241243| snippet file set, get, remove | yes | yes | yes |
.gitbay/wiki/Users.org +7 −3
@@ -393,7 +393,9 @@ gitbay release list / show v1.0 / delete v1.0 --yes
393393#+end_src
394394
395395The web shows them under the repository's =releases= tab with rendered
396notes, sha256 sums, and download links. Feed readers subscribe at
396notes, sha256 sums, and download links; writers add and remove assets
397there (a file upload, up to =max_asset_bytes=; removal asks for the file
398name). Feed readers subscribe at
397399=/you/project/releases.atom=; commits are at =/you/project/log.atom=
398400(the default branch) or =/you/project/log.atom/<ref>=, and an owner's
399401activity on their public repositories at =/you/activity.atom=. The
@@ -414,7 +416,8 @@ repository there. A bare =owner/name#N= links and does nothing.
414416
415417Milestones group issues and MRs toward a release (write access to
416418manage, attach with =issue milestone= / =mr milestone=; progress shows
417on the web at =/owner/name/milestones=):
419on the web at =/owner/name/milestones=, where writers create, close and
420reopen them):
418421
419422#+begin_src sh
420423gitbay milestone create v1.0 --description "first release" --due 2027-01-01
@@ -428,7 +431,8 @@ milestone= and =mr milestone= resolve the org's row first; a repository
428431cannot create a label or milestone with a name its org holds. Creating
429432an org label or milestone whose name repositories under the org already
430433use folds them in: their issues and merge requests move to the org's
431row. Org admins manage them; counts span the repositories you can read.
434row. Org admins manage them, on the CLI or at =/<org>/-/labels= and
435=/<org>/-/milestones=; counts span the repositories you can read.
432436
433437#+begin_src sh
434438gitbay org label set acme bug --color cf222e
CHANGELOG.org +5
@@ -6,6 +6,11 @@ anything beyond "replace the binary and restart" is needed.
66
77* Unreleased
88
9- The releases page uploads and removes release assets (the file is
10 streamed to =release asset add=; removal asks for the name). The
11 repository milestones page creates, closes and reopens milestones, and
12 the org labels and milestones pages give org admins set, remove,
13 create, close and reopen (#296).
914- The repository settings page gains access grants and effective access,
1015 webhooks (add, remove, deliveries, redeliver; the secret is a form
1116 field passed on stdin and never shown again), rename, transfer and
e2e/assetweb_test.go added +85
@@ -0,0 +1,85 @@
1package e2e
2
3import (
4 "bytes"
5 "mime/multipart"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// TestReleaseAssetUploadFromTheWeb uploads a release asset through the
13// releases page's multipart form. The bytes match what the CLI reads back
14// and what the download route serves, and an upload over max_asset_bytes
15// stores nothing (#296).
16func TestReleaseAssetUploadFromTheWeb(t *testing.T) {
17 t.Parallel()
18 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n[limits]\nmax_asset_bytes = 4096\n")
19 aliceKey := inst.newKey(t, "alice")
20 inst.admin(t, "admin", "user", "create", "alice",
21 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
22 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
23 t.Fatalf("repo create: %s", errOut)
24 }
25 env := inst.gitEnv(aliceKey)
26 work := t.TempDir()
27 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
28 dir := filepath.Join(work, "w")
29 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
30 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
31 mustGit(t, dir, env, "add", ".")
32 mustGit(t, dir, env, "commit", "-q", "-m", "base")
33 mustGit(t, dir, env, "tag", "-a", "v1.0", "-m", "first")
34 mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
35 if _, errOut, code := inst.ssh(t, aliceKey, "", "release", "create", "alice/app", "v1.0"); code != 0 {
36 t.Fatalf("release create: %s", errOut)
37 }
38 alice := inst.login(t, aliceKey)
39 base := inst.base() + "/alice/app"
40
41 send := func(name string, data []byte) (int, string) {
42 var buf bytes.Buffer
43 mw := multipart.NewWriter(&buf)
44 mw.WriteField("tag", "v1.0")
45 fw, _ := mw.CreateFormFile("file", name)
46 fw.Write(data)
47 mw.Close()
48 resp, err := alice.Post(base+"/releases/assets", mw.FormDataContentType(), &buf)
49 if err != nil {
50 t.Fatal(err)
51 }
52 defer resp.Body.Close()
53 var out bytes.Buffer
54 out.ReadFrom(resp.Body)
55 return resp.StatusCode, out.String()
56 }
57
58 payload := []byte("BINARY\x00\x01\x02 asset from the browser\n")
59 if status, page := send("tool-linux-amd64", payload); status != 200 || !strings.Contains(page, "tool-linux-amd64") {
60 t.Fatalf("upload: %d\n%s", status, page)
61 }
62 got, _, code := inst.ssh(t, aliceKey, "", "release", "asset", "get", "alice/app", "v1.0", "tool-linux-amd64")
63 if code != 0 || got != string(payload) {
64 t.Fatalf("CLI read back %q (exit %d)", got, code)
65 }
66 if status, body := browserGet(t, alice, base+"/releases/download/v1.0/tool-linux-amd64"); status != 200 || body != string(payload) {
67 t.Fatalf("download: %d %q", status, body)
68 }
69
70 if _, page := send("big.bin", bytes.Repeat([]byte("x"), 8192)); !strings.Contains(page, "max_asset_bytes") {
71 t.Fatalf("oversized upload not refused:\n%s", page)
72 }
73 out, _, _ := inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json")
74 if strings.Contains(out, "big.bin") {
75 t.Fatalf("oversized asset stored: %s", out)
76 }
77
78 if status, _ := browserPost(t, alice, base+"/releases/assets", map[string][]string{
79 "action": {"remove"}, "tag": {"v1.0"}, "name": {"tool-linux-amd64"}, "confirm": {"tool-linux-amd64"}}); status != 200 {
80 t.Fatal("remove failed")
81 }
82 if out, _, _ = inst.ssh(t, aliceKey, "", "release", "show", "alice/app", "v1.0", "--json"); strings.Contains(out, "tool-linux-amd64") {
83 t.Fatalf("asset still listed: %s", out)
84 }
85}
internal/httpd/control.go +7 −1
@@ -101,6 +101,12 @@ func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg
101101}
102102
103103func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) (msg string, code int) {
104 return s.runControlReader(u, argv, strings.NewReader(stdin))
105}
106
107// runControlReader is runControlStdinCode for a body too large to hold
108// as a string: the command reads it from stdin as a stream.
109func (s *Server) runControlReader(u store.User, argv []string, stdin io.Reader) (msg string, code int) {
104110 var stdout, stderr bytes.Buffer
105111 ctx := &control.Ctx{
106112 User: u,
@@ -108,7 +114,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
108114 Scope: "full",
109115 Store: s.st,
110116 Cfg: s.cfg,
111 Stdin: strings.NewReader(stdin),
117 Stdin: stdin,
112118 Stdout: &stdout,
113119 Stderr: &stderr,
114120 ViaAPI: true,
internal/httpd/milestoneactions.go added +183
@@ -0,0 +1,183 @@
1package httpd
2
3import (
4 "errors"
5 "fmt"
6 "net/http"
7 "path/filepath"
8 "strings"
9
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/store"
12)
13
14// Milestone, org label and release asset forms. Each dispatches the
15// command the CLI runs; who may do it is the command's decision, and the
16// pages only show the forms to those it will accept.
17
18// milestoneCreateArgs builds the create argv: the flags, then the
19// positionals after "--" so a title starting with "-" is not a flag.
20func milestoneCreateArgs(r *http.Request, head []string, target string) []string {
21 argv := head
22 if d := strings.TrimSpace(r.FormValue("description")); d != "" {
23 argv = append(argv, "--description", d)
24 }
25 if d := strings.TrimSpace(r.FormValue("due")); d != "" {
26 argv = append(argv, "--due", d)
27 }
28 return append(argv, "--", target, strings.TrimSpace(r.FormValue("title")))
29}
30
31func (s *Server) milestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
32 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
33 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "milestones", msg) }
34 title := strings.TrimSpace(r.FormValue("title"))
35 if title == "" {
36 back(w, r, "name the milestone")
37 return
38 }
39 var argv []string
40 switch r.FormValue("action") {
41 case "close":
42 argv = []string{"milestone", "close", repo, title}
43 case "reopen":
44 argv = []string{"milestone", "reopen", repo, title}
45 default:
46 argv = milestoneCreateArgs(r, []string{"milestone", "create"}, repo)
47 }
48 _, msg, code := s.runControlCode(u, argv)
49 s.done(w, r, code, msg, back)
50}
51
52func (s *Server) orgBack(w http.ResponseWriter, r *http.Request, page, msg string) {
53 s.setFlash(w, msg)
54 http.Redirect(w, r, "/"+r.PathValue("owner")+"/-/"+page, http.StatusSeeOther)
55}
56
57func (s *Server) orgLabelSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
58 org := r.PathValue("owner")
59 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.orgBack(w, r, "labels", msg) }
60 name := strings.TrimSpace(r.FormValue("name"))
61 if name == "" {
62 back(w, r, "name the label")
63 return
64 }
65 argv := []string{"org", "label", "set", "--color", strings.TrimSpace(r.FormValue("color")), "--", org, name}
66 if r.FormValue("action") == "remove" {
67 if ok, msg := confirmed(r, name); !ok {
68 back(w, r, msg)
69 return
70 }
71 argv = []string{"org", "label", "remove", org, name}
72 }
73 _, msg, code := s.runControlCode(u, argv)
74 s.done(w, r, code, msg, back)
75}
76
77func (s *Server) orgMilestoneSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
78 org := r.PathValue("owner")
79 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.orgBack(w, r, "milestones", msg) }
80 title := strings.TrimSpace(r.FormValue("title"))
81 if title == "" {
82 back(w, r, "name the milestone")
83 return
84 }
85 var argv []string
86 switch r.FormValue("action") {
87 case "close":
88 argv = []string{"org", "milestone", "close", org, title}
89 case "reopen":
90 argv = []string{"org", "milestone", "reopen", org, title}
91 default:
92 argv = milestoneCreateArgs(r, []string{"org", "milestone", "create"}, org)
93 }
94 _, msg, code := s.runControlCode(u, argv)
95 s.done(w, r, code, msg, back)
96}
97
98// releaseAssetSubmit uploads or removes a release asset. The upload is
99// parsed with a small memory budget, so the rest of the file spills to a
100// temporary file, and reaches release asset add as a stream on stdin.
101// The body is capped a little above max_asset_bytes so an oversized file
102// is refused without being read to the end; the command applies the
103// exact limit.
104func (s *Server) releaseAssetSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
105 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
106 back := func(w http.ResponseWriter, r *http.Request, msg string) { s.backTo(w, r, "releases", msg) }
107 // Authorise before reading a byte: a body nobody may upload is never
108 // spooled to disk.
109 rp, err := s.st.RepoByPath(repo)
110 if err == nil {
111 grant, _ := s.st.AccessRole(rp.ID, u.ID)
112 if !policyCanRead(u, rp, grant) {
113 err = store.ErrNotFound
114 } else if !policy.CanWrite(u, rp, grant) {
115 back(w, r, "you need write access to change releases")
116 return
117 } else if rp.Settings.Archived {
118 back(w, r, rp.Path()+" is archived and read-only; unarchive it first")
119 return
120 }
121 }
122 if err != nil {
123 s.notFound(w, r)
124 return
125 }
126 limit := s.cfg.Limits.MaxAssetBytes
127 if r.ContentLength > limit+1<<20 {
128 back(w, r, fmt.Sprintf("asset exceeds max_asset_bytes (%d)", limit))
129 return
130 }
131 if _, busy := s.uploads.LoadOrStore(u.ID, struct{}{}); busy {
132 back(w, r, "another upload of yours is still running; wait for it to finish")
133 return
134 }
135 defer s.uploads.Delete(u.ID)
136 r.Body = http.MaxBytesReader(w, r.Body, limit+1<<20)
137 if err := r.ParseMultipartForm(1 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) {
138 var tooBig *http.MaxBytesError
139 if errors.As(err, &tooBig) {
140 back(w, r, fmt.Sprintf("asset exceeds max_asset_bytes (%d)", limit))
141 return
142 }
143 back(w, r, "unreadable upload")
144 return
145 }
146 if r.MultipartForm != nil {
147 defer r.MultipartForm.RemoveAll()
148 }
149 tag := strings.TrimSpace(r.FormValue("tag"))
150 if tag == "" {
151 back(w, r, "pick a release")
152 return
153 }
154 if r.FormValue("action") == "remove" {
155 name := strings.TrimSpace(r.FormValue("name"))
156 if ok, msg := confirmed(r, name); !ok || name == "" {
157 if name == "" {
158 msg = "name the asset"
159 }
160 back(w, r, msg)
161 return
162 }
163 _, msg, code := s.runControlCode(u, []string{"release", "asset", "remove", repo, tag, name})
164 s.done(w, r, code, msg, back)
165 return
166 }
167 f, hdr, err := r.FormFile("file")
168 if err != nil {
169 back(w, r, "choose a file")
170 return
171 }
172 defer f.Close()
173 if hdr.Size == 0 {
174 back(w, r, "the file is empty")
175 return
176 }
177 name := strings.TrimSpace(r.FormValue("name"))
178 if name == "" {
179 name = filepath.Base(hdr.Filename)
180 }
181 msg, code := s.runControlReader(u, []string{"release", "asset", "add", repo, tag, name}, f)
182 s.done(w, r, code, msg, back)
183}
internal/httpd/orglabels.go +13 −9
@@ -12,17 +12,17 @@ import (
1212// see it; anyone else only when some repository under the org is
1313// readable. Everything else is not found, the same answer as for a
1414// user owner or an unknown name.
15func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool) {
15func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, store.User, []int64, bool, bool) {
1616 viewer := s.viewer(r)
1717 org, err := s.st.OrgByName(r.PathValue("owner"))
1818 if err != nil {
1919 s.notFound(w, r)
20 return org, viewer, nil, false
20 return org, viewer, nil, false, false
2121 }
2222 readable, err := control.ReadableOrgRepoIDs(s.st, viewer, org.ID)
2323 if err != nil {
2424 http.Error(w, "internal error", http.StatusInternalServerError)
25 return org, viewer, nil, false
25 return org, viewer, nil, false, false
2626 }
2727 role := ""
2828 if viewer.ID != 0 {
@@ -30,13 +30,13 @@ func (s *Server) orgScope(w http.ResponseWriter, r *http.Request) (store.Org, st
3030 }
3131 if role == "" && len(readable) == 0 {
3232 s.notFound(w, r)
33 return org, viewer, nil, false
33 return org, viewer, nil, false, false
3434 }
35 return org, viewer, readable, true
35 return org, viewer, readable, true, role == "admin"
3636}
3737
3838func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
39 org, viewer, readable, ok := s.orgScope(w, r)
39 org, viewer, readable, ok, admin := s.orgScope(w, r)
4040 if !ok {
4141 return
4242 }
@@ -54,11 +54,13 @@ func (s *Server) orgLabels(w http.ResponseWriter, r *http.Request) {
5454 Org string
5555 Labels []store.Label
5656 LabelColors map[string]template.CSS
57 }{s.baseFor(viewer), org.Name, labels, colorStyles(stored)})
57 CanAdmin bool
58 Notice string
59 }{s.baseFor(viewer), org.Name, labels, colorStyles(stored), admin, s.takeFlash(w, r)})
5860}
5961
6062func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
61 org, viewer, readable, ok := s.orgScope(w, r)
63 org, viewer, readable, ok, admin := s.orgScope(w, r)
6264 if !ok {
6365 return
6466 }
@@ -88,5 +90,7 @@ func (s *Server) orgMilestones(w http.ResponseWriter, r *http.Request) {
8890 Org string
8991 State string
9092 Milestones []msView
91 }{s.baseFor(viewer), org.Name, state, views})
93 CanAdmin bool
94 Notice string
95 }{s.baseFor(viewer), org.Name, state, views, admin, s.takeFlash(w, r)})
9296}
internal/httpd/parity296b_test.go added +396
@@ -0,0 +1,396 @@
1package httpd
2
3import (
4 "bytes"
5 "io"
6 "mime/multipart"
7 "net/http"
8 "net/http/httptest"
9 "net/url"
10 "strings"
11 "testing"
12
13 "gitbay.org/gitbay/internal/store"
14)
15
16type p296b struct {
17 s *Server
18 st *store.Store
19 h http.Handler
20 repo store.Repo
21 orgID int64
22 alice *http.Cookie
23 bob *http.Cookie
24 aliceU store.User
25}
26
27func newP296b(t *testing.T) *p296b {
28 t.Helper()
29 e := newSettingsEnv(t)
30 e.s.cfg.Limits.MaxAssetBytes = 1 << 10
31 orgID, err := e.st.CreateOrg("acme", e.alice.ID)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if err := e.st.SetOrgMember(orgID, e.bob.ID, "member"); err != nil {
36 t.Fatal(err)
37 }
38 if _, err := e.st.CreateRelease(e.repo.ID, "v1", "One", "", e.alice.ID, "md"); err != nil {
39 t.Fatal(err)
40 }
41 return &p296b{s: e.s, st: e.st, h: e.s.Handler(), repo: e.repo, orgID: orgID,
42 alice: sessionCookieFor(t, e.s, e.st, e.alice.ID),
43 bob: sessionCookieFor(t, e.s, e.st, e.bob.ID), aliceU: e.alice}
44}
45
46func (p *p296b) do(method, path string, ck *http.Cookie, body io.Reader, ctype string) *httptest.ResponseRecorder {
47 req := httptest.NewRequest(method, path, body)
48 if ctype != "" {
49 req.Header.Set("Content-Type", ctype)
50 }
51 req.AddCookie(ck)
52 rr := httptest.NewRecorder()
53 p.h.ServeHTTP(rr, req)
54 return rr
55}
56
57func (p *p296b) post(path string, ck *http.Cookie, f url.Values) *httptest.ResponseRecorder {
58 return p.do("POST", path, ck, strings.NewReader(f.Encode()), "application/x-www-form-urlencoded")
59}
60
61// follow returns the page a redirect points at, carrying the flash.
62func (p *p296b) follow(rr *httptest.ResponseRecorder, ck *http.Cookie) string {
63 req := httptest.NewRequest("GET", rr.Header().Get("Location"), nil)
64 req.AddCookie(ck)
65 for _, c := range rr.Result().Cookies() {
66 req.AddCookie(c)
67 }
68 out := httptest.NewRecorder()
69 p.h.ServeHTTP(out, req)
70 return out.Body.String()
71}
72
73func (p *p296b) get(path string, ck *http.Cookie) string {
74 return p.do("GET", path, ck, nil, "").Body.String()
75}
76
77func upload(t *testing.T, fields map[string]string, fname string, data []byte) (io.Reader, string) {
78 t.Helper()
79 var buf bytes.Buffer
80 mw := multipart.NewWriter(&buf)
81 for k, v := range fields {
82 mw.WriteField(k, v)
83 }
84 if fname != "" {
85 fw, _ := mw.CreateFormFile("file", fname)
86 fw.Write(data)
87 }
88 mw.Close()
89 return &buf, mw.FormDataContentType()
90}
91
92func TestReleaseAssetUploadAndRemove(t *testing.T) {
93 p := newP296b(t)
94 const path = "/alice/app/releases/assets"
95 body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
96 rr := p.do("POST", path, p.alice, body, ct)
97 if rr.Code != http.StatusSeeOther {
98 t.Fatalf("upload: %d %s", rr.Code, rr.Body.String())
99 }
100 rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1")
101 if len(rel.Assets) != 1 || rel.Assets[0].Name != "tool.bin" || rel.Assets[0].Size != 7 {
102 t.Fatalf("assets %+v", rel.Assets)
103 }
104 page := p.get("/alice/app/releases", p.alice)
105 for _, want := range []string{"Add asset", `enctype="multipart/form-data"`, "tool.bin", `value="remove"`} {
106 if !strings.Contains(page, want) {
107 t.Errorf("writer page lacks %q", want)
108 }
109 }
110
111 // Refusals: over the limit, empty, bad name, no file.
112 for name, tc := range map[string]struct {
113 fname string
114 data []byte
115 field map[string]string
116 want string
117 }{
118 "oversized": {"big.bin", bytes.Repeat([]byte("x"), 2<<10), map[string]string{"tag": "v1"}, "max_asset_bytes"},
119 "way over": {"huge.bin", bytes.Repeat([]byte("x"), 3<<20), map[string]string{"tag": "v1"}, "max_asset_bytes"},
120 "empty": {"e.bin", nil, map[string]string{"tag": "v1"}, "empty"},
121 "bad name": {"x.bin", []byte("x"), map[string]string{"tag": "v1", "name": ".hidden"}, "invalid asset name"},
122 "no file": {"", nil, map[string]string{"tag": "v1"}, "choose a file"},
123 "no release": {"a.bin", []byte("x"), map[string]string{"tag": "v9"}, ""},
124 } {
125 body, ct := upload(t, tc.field, tc.fname, tc.data)
126 rr := p.do("POST", path, p.alice, body, ct)
127 if tc.want == "" {
128 if rr.Code != http.StatusNotFound {
129 t.Errorf("%s: %d", name, rr.Code)
130 }
131 continue
132 }
133 if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), tc.want) {
134 t.Errorf("%s: %d, no %q on the page", name, rr.Code, tc.want)
135 }
136 }
137 if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 {
138 t.Fatalf("a refused upload stored something: %+v", rel.Assets)
139 }
140
141 // Remove needs the name typed.
142 rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}})
143 if !strings.Contains(p.follow(rr, p.alice), "type tool.bin to confirm") {
144 t.Fatal("no confirmation demanded")
145 }
146 if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 1 {
147 t.Fatal("removed without confirmation")
148 }
149 p.post(path, p.alice, url.Values{"action": {"remove"}, "tag": {"v1"}, "name": {"tool.bin"}, "confirm": {"tool.bin"}})
150 if rel, _ = p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 {
151 t.Fatalf("not removed: %+v", rel.Assets)
152 }
153}
154
155func TestReleaseAssetReaderSeesNoFormAndIsRefused(t *testing.T) {
156 p := newP296b(t)
157 body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
158 rr := p.do("POST", "/alice/app/releases/assets", p.bob, body, ct)
159 if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) {
160 t.Fatalf("no refusal shown: %d", rr.Code)
161 }
162 if rel, _ := p.st.ReleaseByTag(p.repo.ID, "v1"); len(rel.Assets) != 0 {
163 t.Fatal("a reader uploaded an asset")
164 }
165 page := p.get("/alice/app/releases", p.bob)
166 if strings.Contains(page, "Add asset") || strings.Contains(page, "releases/assets") {
167 t.Fatal("reader sees the asset form")
168 }
169}
170
171func TestRepoMilestoneCreateCloseReopen(t *testing.T) {
172 p := newP296b(t)
173 const path = "/alice/app/milestones"
174 rr := p.post(path, p.alice, url.Values{"title": {"v2"}, "description": {"next"}, "due": {"2026-12-01"}})
175 if rr.Code != http.StatusSeeOther {
176 t.Fatalf("create: %d", rr.Code)
177 }
178 m, err := p.st.MilestoneByTitle(p.repo, "v2")
179 if err != nil || m.Description != "next" || m.DueDate != "2026-12-01" {
180 t.Fatalf("%+v %v", m, err)
181 }
182 page := p.get(path, p.alice)
183 for _, want := range []string{"New milestone", `value="close"`} {
184 if !strings.Contains(page, want) {
185 t.Errorf("page lacks %q", want)
186 }
187 }
188 p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"v2"}})
189 if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "closed" {
190 t.Fatalf("state %q", m.State)
191 }
192 p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"v2"}})
193 if m, _ = p.st.MilestoneByTitle(p.repo, "v2"); m.State != "open" {
194 t.Fatalf("state %q", m.State)
195 }
196
197 // Refusals show on the page.
198 rr = p.post(path, p.alice, url.Values{"title": {"v3"}, "due": {"soon"}})
199 if !strings.Contains(p.follow(rr, p.alice), "--due must be YYYY-MM-DD") {
200 t.Fatal("bad date not reported")
201 }
202 rr = p.post(path, p.alice, url.Values{"title": {"v2"}})
203 if rr.Code != http.StatusSeeOther || !strings.Contains(p.follow(rr, p.alice), `role="alert"`) {
204 t.Fatal("duplicate not reported")
205 }
206}
207
208func TestRepoMilestoneReaderSeesNoForm(t *testing.T) {
209 p := newP296b(t)
210 page := p.get("/alice/app/milestones", p.bob)
211 if strings.Contains(page, "New milestone") || strings.Contains(page, `action="/alice/app/milestones"`) {
212 t.Fatal("reader sees the form")
213 }
214 rr := p.post("/alice/app/milestones", p.bob, url.Values{"title": {"sneaky"}})
215 if !strings.Contains(p.follow(rr, p.bob), `role="alert"`) {
216 t.Fatal("no refusal")
217 }
218 if _, err := p.st.MilestoneByTitle(p.repo, "sneaky"); err == nil {
219 t.Fatal("a reader created a milestone")
220 }
221}
222
223func TestOrgLabelSetAndRemove(t *testing.T) {
224 p := newP296b(t)
225 const path = "/acme/-/labels"
226 rr := p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"d73a4a"}})
227 if rr.Code != http.StatusSeeOther {
228 t.Fatalf("set: %d", rr.Code)
229 }
230 labels, _ := p.st.ListOrgLabels(p.orgID, nil)
231 if len(labels) != 1 || labels[0].Name != "bug" || labels[0].Color != "#d73a4a" {
232 t.Fatalf("%+v", labels)
233 }
234 page := p.get(path, p.alice)
235 for _, want := range []string{"New org label", `value="remove"`, `aria-label="Colour for bug"`} {
236 if !strings.Contains(page, want) {
237 t.Errorf("page lacks %q", want)
238 }
239 }
240 rr = p.post(path, p.alice, url.Values{"name": {"bug"}, "color": {"zzz"}})
241 if !strings.Contains(p.follow(rr, p.alice), "--color takes rrggbb") {
242 t.Fatal("bad colour not reported")
243 }
244 rr = p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}})
245 if !strings.Contains(p.follow(rr, p.alice), "type bug to confirm") {
246 t.Fatal("no confirmation demanded")
247 }
248 if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 1 {
249 t.Fatal("removed without confirmation")
250 }
251 p.post(path, p.alice, url.Values{"action": {"remove"}, "name": {"bug"}, "confirm": {"bug"}})
252 if labels, _ = p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 {
253 t.Fatalf("not removed: %+v", labels)
254 }
255}
256
257func TestOrgLabelMemberSeesNoFormAndIsRefused(t *testing.T) {
258 p := newP296b(t)
259 page := p.get("/acme/-/labels", p.bob)
260 if strings.Contains(page, "New org label") || strings.Contains(page, `action="/acme/-/labels"`) {
261 t.Fatal("member sees the form")
262 }
263 rr := p.post("/acme/-/labels", p.bob, url.Values{"name": {"bug"}})
264 if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") {
265 t.Fatalf("no refusal: %d", rr.Code)
266 }
267 if labels, _ := p.st.ListOrgLabels(p.orgID, nil); len(labels) != 0 {
268 t.Fatal("a member created an org label")
269 }
270}
271
272func TestOrgMilestoneCreateCloseReopen(t *testing.T) {
273 p := newP296b(t)
274 const path = "/acme/-/milestones"
275 if rr := p.post(path, p.alice, url.Values{"title": {"mobile"}, "due": {"2026-12-01"}}); rr.Code != http.StatusSeeOther {
276 t.Fatalf("create: %d", rr.Code)
277 }
278 state := func(s string) int {
279 ms, _ := p.st.ListOrgMilestones(p.orgID, s, nil)
280 return len(ms)
281 }
282 if state("open") != 1 {
283 t.Fatal("not created")
284 }
285 if page := p.get(path, p.alice); !strings.Contains(page, "New org milestone") || !strings.Contains(page, `value="close"`) {
286 t.Fatal("admin page lacks the controls")
287 }
288 p.post(path, p.alice, url.Values{"action": {"close"}, "title": {"mobile"}})
289 if state("closed") != 1 || state("open") != 0 {
290 t.Fatal("not closed")
291 }
292 p.post(path, p.alice, url.Values{"action": {"reopen"}, "title": {"mobile"}})
293 if state("open") != 1 {
294 t.Fatal("not reopened")
295 }
296 rr := p.post(path, p.alice, url.Values{"title": {"mobile"}})
297 if !strings.Contains(p.follow(rr, p.alice), `role="alert"`) {
298 t.Fatal("duplicate not reported")
299 }
300}
301
302func TestOrgMilestoneMemberSeesNoFormAndIsRefused(t *testing.T) {
303 p := newP296b(t)
304 page := p.get("/acme/-/milestones", p.bob)
305 if strings.Contains(page, "New org milestone") || strings.Contains(page, `action="/acme/-/milestones"`) {
306 t.Fatal("member sees the form")
307 }
308 rr := p.post("/acme/-/milestones", p.bob, url.Values{"title": {"sneaky"}})
309 if !strings.Contains(p.follow(rr, p.bob), "only admins of acme") {
310 t.Fatalf("no refusal: %d", rr.Code)
311 }
312 if ms, _ := p.st.ListOrgMilestones(p.orgID, "all", nil); len(ms) != 0 {
313 t.Fatal("a member created an org milestone")
314 }
315}
316
317// countingBody reports how many bytes a handler read from a request.
318type countingBody struct {
319 r io.Reader
320 n int
321}
322
323func (c *countingBody) Read(b []byte) (int, error) {
324 n, err := c.r.Read(b)
325 c.n += n
326 return n, err
327}
328
329func TestReleaseAssetAuthorisesBeforeReading(t *testing.T) {
330 p := newP296b(t)
331 if _, err := p.st.CreateRepo("user", p.aliceU.ID, "secret", "private"); err != nil {
332 t.Fatal(err)
333 }
334 payload, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", bytes.Repeat([]byte("x"), 512))
335 raw, _ := io.ReadAll(payload)
336 send := func(path string, ck *http.Cookie, length int64) (*httptest.ResponseRecorder, *countingBody) {
337 body := &countingBody{r: bytes.NewReader(raw)}
338 req := httptest.NewRequest("POST", path, body)
339 req.ContentLength = length
340 req.Header.Set("Content-Type", ct)
341 req.AddCookie(ck)
342 rr := httptest.NewRecorder()
343 p.h.ServeHTTP(rr, req)
344 return rr, body
345 }
346 for _, path := range []string{"/alice/secret/releases/assets", "/alice/nothing/releases/assets"} {
347 rr, body := send(path, p.bob, int64(len(raw)))
348 if rr.Code != http.StatusNotFound || body.n != 0 {
349 t.Errorf("%s: %d, read %d bytes", path, rr.Code, body.n)
350 }
351 }
352 // A reader of a public repo is refused without reading too.
353 rr, body := send("/alice/app/releases/assets", p.bob, int64(len(raw)))
354 if rr.Code != http.StatusSeeOther || body.n != 0 {
355 t.Errorf("reader: %d, read %d bytes", rr.Code, body.n)
356 }
357 // An announced length over the cap is refused before reading.
358 rr, body = send("/alice/app/releases/assets", p.alice, 3<<20)
359 if rr.Code != http.StatusSeeOther || body.n != 0 || !strings.Contains(p.follow(rr, p.alice), "max_asset_bytes") {
360 t.Errorf("oversized: %d, read %d bytes", rr.Code, body.n)
361 }
362}
363
364func TestReleaseAssetOneUploadAtATime(t *testing.T) {
365 p := newP296b(t)
366 p.s.uploads.Store(p.aliceU.ID, struct{}{})
367 body, ct := upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
368 rr := p.do("POST", "/alice/app/releases/assets", p.alice, body, ct)
369 if !strings.Contains(p.follow(rr, p.alice), "still running") {
370 t.Fatalf("second upload not refused: %d", rr.Code)
371 }
372 p.s.uploads.Delete(p.aliceU.ID)
373 body, ct = upload(t, map[string]string{"tag": "v1"}, "tool.bin", []byte("payload"))
374 if rr = p.do("POST", "/alice/app/releases/assets", p.alice, body, ct); rr.Code != http.StatusSeeOther {
375 t.Fatal(rr.Code)
376 }
377 if _, busy := p.s.uploads.Load(p.aliceU.ID); busy {
378 t.Fatal("slot not released")
379 }
380}
381
382func TestMilestoneTitleStartingWithDash(t *testing.T) {
383 p := newP296b(t)
384 p.post("/alice/app/milestones", p.alice, url.Values{"title": {"--due"}})
385 if _, err := p.st.MilestoneByTitle(p.repo, "--due"); err != nil {
386 t.Fatalf("repo milestone: %v", err)
387 }
388 p.post("/acme/-/milestones", p.alice, url.Values{"title": {"--description"}})
389 if ms, _ := p.st.ListOrgMilestones(p.orgID, "open", nil); len(ms) != 1 || ms[0].Title != "--description" {
390 t.Fatalf("org milestone: %+v", ms)
391 }
392 p.post("/acme/-/labels", p.alice, url.Values{"name": {"--color"}})
393 if ls, _ := p.st.ListOrgLabels(p.orgID, nil); len(ls) != 1 || ls[0].Name != "--color" {
394 t.Fatalf("org label: %+v", ls)
395 }
396}
internal/httpd/routes.go +8
@@ -175,6 +175,14 @@ func (s *Server) Routes() []Route {
175175 Handler: s.checkOrigin(s.requireUser(s.releaseSubmit))},
176176 Route{Method: "POST", Pattern: "/{owner}/{repo}/labels", Mutating: true,
177177 Handler: s.checkOrigin(s.requireUser(s.labelSubmit))},
178 Route{Method: "POST", Pattern: "/{owner}/{repo}/releases/assets", Mutating: true,
179 Handler: s.checkOrigin(s.requireUser(s.releaseAssetSubmit))},
180 Route{Method: "POST", Pattern: "/{owner}/{repo}/milestones", Mutating: true,
181 Handler: s.checkOrigin(s.requireUser(s.milestoneSubmit))},
182 Route{Method: "POST", Pattern: "/{owner}/-/labels", Mutating: true,
183 Handler: s.checkOrigin(s.requireUser(s.orgLabelSubmit))},
184 Route{Method: "POST", Pattern: "/{owner}/-/milestones", Mutating: true,
185 Handler: s.checkOrigin(s.requireUser(s.orgMilestoneSubmit))},
178186 Route{Method: "GET", Pattern: "/bookmarks", Handler: s.requireUser(s.bookmarksPage)},
179187 Route{Method: "GET", Pattern: "/{owner}/-/snippets/new", Handler: s.requireUser(s.snippetNewForm)},
180188 Route{Method: "POST", Pattern: "/{owner}/-/snippets/new", Mutating: true,
internal/httpd/smart.go +1
@@ -35,6 +35,7 @@ type Server struct {
3535 apiLimit *apiLimiter
3636 proxies []*net.IPNet // http.trusted_proxies, parsed once
3737 stopping chan struct{} // closed by Stop
38 uploads sync.Map // user id -> struct{}: release asset uploads in flight
3839 stopOnce sync.Once
3940}
4041
internal/httpd/web.go +3 −1
@@ -897,7 +897,9 @@ func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
897897 repoPage
898898 State string
899899 Milestones []msView
900 }{p, state, views})
900 CanWrite bool
901 Notice string
902 }{p, state, views, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
901903}
902904
903905// search runs a bounded literal git grep over the repo's default branch.
internal/web/templates/milestones.html +19
@@ -8,6 +8,21 @@
88 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
99 </nav>
1010</div>
11{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
12{{if .CanWrite}}
13<details class="editbox">
14 <summary>New milestone</summary>
15 <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones" class="setform stack">
16 <label for="mstitle">Title</label>
17 <input type="text" id="mstitle" name="title" required>
18 <label for="msdesc">Description</label>
19 <input type="text" id="msdesc" name="description">
20 <label for="msdue">Due</label>
21 <input type="text" id="msdue" name="due" placeholder="YYYY-MM-DD">
22 <button type="submit" class="btn">Create milestone</button>
23 </form>
24</details>
25{{end}}
1126<ul class="milestonelist">
1227{{range .Milestones}}<li>
1328 <div class="msmain">
@@ -15,6 +30,10 @@
1530 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
1631 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
1732 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
33 {{if and $.CanWrite (not .OrgID)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/milestones" class="inline">
34 <input type="hidden" name="title" value="{{.Title}}">
35 {{if eq .State "open"}}<input type="hidden" name="action" value="close"><button type="submit" class="btn">Close</button>{{else}}<input type="hidden" name="action" value="reopen"><button type="submit" class="btn">Reopen</button>{{end}}
36 </form>{{end}}
1837 </div>
1938</li>
2039{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}milestones</li>{{end}}
internal/web/templates/orglabels.html +27 −3
@@ -1,15 +1,39 @@
11{{define "title"}}labels · {{.Org}}{{end}}
22{{define "content"}}
33<h1><a href="/{{.Org}}">{{.Org}}</a> labels</h1>
4<p class="meta">Every repository under {{.Org}} sees these beside its own. Managed with <code>gitbay org label set {{.Org}} &lt;label&gt;</code>; counts span the repositories you can read.</p>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5<p class="meta">Every repository under {{.Org}} sees these beside its own. Counts span the repositories you can read.</p>
56{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col">merge requests</th></tr>
7<tr class="cols"><th scope="col">label</th><th scope="col">colour</th><th scope="col">issues</th><th scope="col">merge requests</th>{{if .CanAdmin}}<th scope="col"><span class="vh">actions</span></th>{{end}}</tr>
78{{range .Labels}}<tr>
89 <td><span class="chip label" style="{{index $.LabelColors .Name}}">{{.Name}}</span></td>
9 <td><span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span></td>
10 <td>{{if $.CanAdmin}}<form method="post" action="/{{$.Org}}/-/labels" class="inline">
11 <input type="hidden" name="name" value="{{.Name}}">
12 <input type="text" name="color" value="{{.Color}}" aria-label="Colour for {{.Name}}" placeholder="rrggbb" size="8">
13 <button type="submit" class="btn">Save</button>
14 </form>{{else}}<span class="mono">{{if .Color}}{{.Color}}{{else}}—{{end}}</span>{{end}}</td>
1015 <td>{{.Issues}}</td>
1116 <td>{{.MRs}}</td>
17 {{if $.CanAdmin}}<td class="act"><form method="post" action="/{{$.Org}}/-/labels" class="inline">
18 <input type="hidden" name="action" value="remove">
19 <input type="hidden" name="name" value="{{.Name}}">
20 {{template "confirmfield" .Name}}
21 <button type="submit" class="danger">Remove</button>
22 </form></td>{{end}}
1223</tr>
1324{{end}}</table></div>
1425{{else}}<p class="none">No org labels yet.</p>{{end}}
26{{if .CanAdmin}}
27<details class="editbox">
28 <summary>New org label</summary>
29 <form method="post" action="/{{.Org}}/-/labels" class="setform stack">
30 <label for="labelname">Name</label>
31 <input type="text" id="labelname" name="name" maxlength="50" required>
32 <label for="labelcolor">Colour</label>
33 <input type="text" id="labelcolor" name="color" placeholder="rrggbb, or blank for one picked from the name">
34 <button type="submit" class="btn">Create label</button>
35 </form>
36</details>
37<p class="meta">Removing a label takes it off every issue and merge request under {{.Org}}. Creating one folds in same-named repository labels.</p>
38{{end}}
1539{{end}}
internal/web/templates/orgmilestones.html +19
@@ -8,6 +8,21 @@
88 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
99 </nav>
1010</div>
11{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
12{{if .CanAdmin}}
13<details class="editbox">
14 <summary>New org milestone</summary>
15 <form method="post" action="/{{.Org}}/-/milestones" class="setform stack">
16 <label for="mstitle">Title</label>
17 <input type="text" id="mstitle" name="title" required>
18 <label for="msdesc">Description</label>
19 <input type="text" id="msdesc" name="description">
20 <label for="msdue">Due</label>
21 <input type="text" id="msdue" name="due" placeholder="YYYY-MM-DD">
22 <button type="submit" class="btn">Create milestone</button>
23 </form>
24</details>
25{{end}}
1126<p class="meta">Progress spans the repositories under {{.Org}} you can read.</p>
1227<ul class="milestonelist">
1328{{range .Milestones}}<li>
@@ -16,6 +31,10 @@
1631 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
1732 <p class="meta">{{if .DueDate}}due {{.DueDate}} · {{end}}{{.ClosedItems}} closed, {{.OpenItems}} open · {{.Percent}}%</p>
1833 <div class="progress"><div class="bar" style="width: {{.Percent}}%"></div></div>
34 {{if $.CanAdmin}}<form method="post" action="/{{$.Org}}/-/milestones" class="inline">
35 <input type="hidden" name="title" value="{{.Title}}">
36 {{if eq .State "open"}}<input type="hidden" name="action" value="close"><button type="submit" class="btn">Close</button>{{else}}<input type="hidden" name="action" value="reopen"><button type="submit" class="btn">Reopen</button>{{end}}
37 </form>{{end}}
1938 </div>
2039</li>
2140{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}org milestones</li>{{end}}
internal/web/templates/releases.html +16 −1
@@ -43,13 +43,28 @@
4343 <p>{{template "confirmfield" $rel.Tag}} <button type="submit" class="danger">Delete release</button></p>
4444 </form>{{end}}</details>{{end}}
4545 {{if $rel.Assets}}<table class="assets">
46 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th></tr></thead>
46 <thead><tr><th scope="col">File</th><th scope="col">Bytes</th><th scope="col">SHA-256</th>{{if $.CanWrite}}<th scope="col"><span class="vh">actions</span></th>{{end}}</tr></thead>
4747 {{range $rel.Assets}}<tr>
4848 <td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/download/{{$rel.Tag}}/{{.Name}}">{{.Name}}</a></td>
4949 <td class="size">{{.Size}}</td>
5050 <td class="sha"><code title="{{.SHA256}}">{{short .SHA256}}</code></td>
51 {{if $.CanWrite}}<td class="act"><form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/assets" class="inline">
52 <input type="hidden" name="action" value="remove">
53 <input type="hidden" name="tag" value="{{$rel.Tag}}">
54 <input type="hidden" name="name" value="{{.Name}}">
55 {{template "confirmfield" .Name}}
56 <button type="submit" class="danger">Remove</button>
57 </form></td>{{end}}
5158 </tr>{{end}}
5259 </table>{{end}}
60 {{if $.CanWrite}}<details class="editbox"><summary>Add asset</summary>
61 <form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/releases/assets" enctype="multipart/form-data" class="commentform">
62 <input type="hidden" name="tag" value="{{$rel.Tag}}">
63 <p><input type="file" name="file" aria-label="File" required></p>
64 <p><input type="text" name="name" aria-label="Name" placeholder="name (defaults to the file's name)"></p>
65 <p><button type="submit" class="btn">Upload</button></p>
66 </form>
67 </details>{{end}}
5368</article>
5469{{else}}<p class="empty-note">no releases yet</p>{{end}}
5570{{end}}