detached cce8cfa

feat(web): public issue form and moderation in the inbox

GET/POST /<repo>/issues/new for visitors of a public repository with
issues=public: a signed challenge, a proof of work solved by script, a
honeypot, one generic refusal, no-store everywhere. The POST is the only
anonymous write the gate lets through and still needs the Origin of the site.
The Inbox gets an Awaiting moderation section (plain text) with Approve,
Reject and Reject and ban as owner POSTs behind the gate.
commit
cce8cfaf3430fabde50b9cfc8be136ee91535d34
author
brice · · 2026-10-19 07:42 UTC
parent
093dc62
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +2067 -122
M internal/adapters/web/inbox.go +111 -6
@@ -7,6 +7,7 @@ import (
77 "net/http"
88 "net/url"
99 "strconv"
10+ "strings"
1011 "time"
1112
1213 "github.com/a-h/templ"
@@ -40,6 +41,25 @@ type InboxRow struct {
4041 Cursor bool
4142 }
4243
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
4363 // InboxRepoLink is an entry of the repository filter.
4464 type InboxRepoLink struct {
4565 Label string
@@ -51,10 +71,13 @@ type InboxRepoLink struct {
5171 type InboxPage struct {
5272 // MergeRequests and Issues count every open item, whatever the filter.
5373 MergeRequests, Issues int
54- Filter string // the repository filtered on, "" for all
55- Repos []InboxRepoLink
56- MRs []InboxRow
57- IssueRows []InboxRow
74+ // PendingCount counts every issue awaiting moderation.
75+ PendingCount int
76+ Pending []PendingRow
77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
5881 }
5982
6083 // inboxHref is the inbox page, filtered on repo when it is not "".
@@ -82,7 +105,10 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
82105 // inboxPage builds the page from the overview.
83106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
84107 now := s.cfg.Now()
85- p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, Filter: filter.String()}
108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
86112 row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
87113 ts := updated
88114 if ts.IsZero() {
@@ -114,6 +140,85 @@ func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) Inbox
114140 return p
115141 }
116142
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
117222 // inbox is GET /-/inbox.
118223 func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
119224 if !SignedIn(ctx) {
@@ -178,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
178283 }
179284 p := l.s.inboxPage(ov, filter)
180285 var out []fragment
181- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxMRs(p), InboxIssues(p)} {
286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
182287 f, err := renderFragment(ctx, inboxEvent, c)
183288 if err != nil {
184289 return nil, err
@@ -7,6 +7,7 @@ import (
7 "net/http"7 "net/http"
8 "net/url"8 "net/url"
9 "strconv"9 "strconv"
10+ "strings"
10 "time"11 "time"
11 12
12 "github.com/a-h/templ"13 "github.com/a-h/templ"
@@ -40,6 +41,25 @@ type InboxRow struct {
40 Cursor bool41 Cursor bool
41 }42 }
42 43
44+// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45+// it comes from an anonymous visitor and is never rendered as Markdown.
46+type PendingRow struct {
47+ Repo string
48+ RepoHref string
49+ Author string
50+ Title string
51+ // Preview is the start of the body, at most pendingPreviewRunes characters.
52+ Preview string
53+ When string
54+ WhenAt string
55+ // Approve, Reject and Ban are the URLs the buttons post to; empty without
56+ // the moderation use case.
57+ Approve, Reject, Ban string
58+}
59+
60+// pendingPreviewRunes bounds the preview of a pending body.
61+const pendingPreviewRunes = 300
62+
43 // InboxRepoLink is an entry of the repository filter.63 // InboxRepoLink is an entry of the repository filter.
44 type InboxRepoLink struct {64 type InboxRepoLink struct {
45 Label string65 Label string
@@ -51,10 +71,13 @@ type InboxRepoLink struct {
51 type InboxPage struct {71 type InboxPage struct {
52 // MergeRequests and Issues count every open item, whatever the filter.72 // MergeRequests and Issues count every open item, whatever the filter.
53 MergeRequests, Issues int73 MergeRequests, Issues int
54- Filter string // the repository filtered on, "" for all74+ // PendingCount counts every issue awaiting moderation.
55- Repos []InboxRepoLink75+ PendingCount int
56- MRs []InboxRow76+ Pending []PendingRow
57- IssueRows []InboxRow77+ Filter string // the repository filtered on, "" for all
78+ Repos []InboxRepoLink
79+ MRs []InboxRow
80+ IssueRows []InboxRow
58 }81 }
59 82
60 // inboxHref is the inbox page, filtered on repo when it is not "".83 // inboxHref is the inbox page, filtered on repo when it is not "".
@@ -82,7 +105,10 @@ func inboxFilter(r *http.Request) (domain.RepoName, error) {
82 // inboxPage builds the page from the overview.105 // inboxPage builds the page from the overview.
83 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {106 func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
84 now := s.cfg.Now()107 now := s.cfg.Now()
85- p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, Filter: filter.String()}108+ p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
109+ for _, pi := range ov.Pending {
110+ p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
111+ }
86 row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {112 row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
87 ts := updated113 ts := updated
88 if ts.IsZero() {114 if ts.IsZero() {
@@ -114,6 +140,85 @@ func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) Inbox
114 return p140 return p
115 }141 }
116 142
143+// pendingRow is the row of a pending issue: the texts as plain strings, the
144+// invisible characters marked, the body cut.
145+func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
146+ repo := pi.Repo.String()
147+ row := PendingRow{
148+ Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
149+ Title: visibleText(pi.Title.String()),
150+ }
151+ if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
152+ row.Author = a
153+ }
154+ body := strings.TrimSpace(visibleText(pi.Body.String()))
155+ row.Preview = cutRunes(body, pendingPreviewRunes)
156+ if len(row.Preview) < len(body) {
157+ row.Preview += "…"
158+ }
159+ row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
160+ if s.cfg.Moderation != nil {
161+ base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
162+ var q string
163+ if !filter.IsZero() {
164+ q = "?repo=" + url.QueryEscape(filter.String())
165+ }
166+ row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
167+ }
168+ return row
169+}
170+
171+// pendingBack is where a moderation action returns: the inbox, on the same
172+// repository filter.
173+func pendingBack(r *http.Request) string {
174+ if f, err := inboxFilter(r); err == nil {
175+ return inboxHref(f.String())
176+ }
177+ return inboxPath
178+}
179+
180+// pendingID reads the repository and the id of the path.
181+func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
182+ name, err := domain.ParseRepoName(r.PathValue("repo"))
183+ if err != nil {
184+ return domain.RepoName{}, "", err
185+ }
186+ id, err := domain.ParsePendingIssueID(r.PathValue("id"))
187+ if err != nil {
188+ return domain.RepoName{}, "", domain.ErrNotFound
189+ }
190+ return name, id, nil
191+}
192+
193+// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
194+func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
195+ back := pendingBack(r)
196+ name, id, err := pendingID(r)
197+ if err != nil {
198+ return "", back, err
199+ }
200+ if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
201+ return "", back, err
202+ }
203+ return back, back, nil
204+}
205+
206+// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
207+// also bans the submitter.
208+func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
209+ return func(ctx context.Context, r *http.Request) (string, string, error) {
210+ back := pendingBack(r)
211+ name, id, err := pendingID(r)
212+ if err != nil {
213+ return "", back, err
214+ }
215+ if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
216+ return "", back, err
217+ }
218+ return back, back, nil
219+ }
220+}
221+
117 // inbox is GET /-/inbox.222 // inbox is GET /-/inbox.
118 func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {223 func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
119 if !SignedIn(ctx) {224 if !SignedIn(ctx) {
@@ -178,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment
178 }283 }
179 p := l.s.inboxPage(ov, filter)284 p := l.s.inboxPage(ov, filter)
180 var out []fragment285 var out []fragment
181- for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxMRs(p), InboxIssues(p)} {286+ for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxMRs(p), InboxIssues(p)} {
182 f, err := renderFragment(ctx, inboxEvent, c)287 f, err := renderFragment(ctx, inboxEvent, c)
183 if err != nil {288 if err != nil {
184 return nil, err289 return nil, err
M internal/adapters/web/inbox.templ +39 -1
@@ -11,6 +11,7 @@ templ Inbox(l Layout, p InboxPage) {
1111 <div class="inbox">
1212 @InboxCounters(p)
1313 @InboxRepos(p)
14+ @InboxPending(p)
1415 @InboxMRs(p)
1516 @InboxIssues(p)
1617 </div>
@@ -19,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
1920
2021 templ InboxCounters(p InboxPage) {
2122 <p id="inbox-counts" class="inbox-counts">
22- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues
23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
2324 </p>
2425 }
2526
@@ -36,6 +37,43 @@ templ InboxRepos(p InboxPage) {
3637 </nav>
3738 }
3839
40+// InboxPending is the issues awaiting moderation, first on the page. Their
41+// texts come from anonymous visitors: they are shown as plain text, escaped,
42+// never as Markdown.
43+templ InboxPending(p InboxPage) {
44+ <section id="inbox-pending" aria-labelledby="inbox-pending-h">
45+ <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2>
46+ if len(p.Pending) > 0 {
47+ <ul class="pending-list">
48+ for _, m := range p.Pending {
49+ <li class="pending">
50+ <div class="p-head">
51+ <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a>
52+ <span>{ m.Author }</span>
53+ if m.WhenAt != "" {
54+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
55+ }
56+ </div>
57+ <p class="p-title">{ m.Title }</p>
58+ if m.Preview != "" {
59+ <p class="p-body">{ m.Preview }</p>
60+ }
61+ if m.Approve != "" {
62+ <div class="p-btns">
63+ <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form>
64+ <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form>
65+ <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form>
66+ </div>
67+ }
68+ </li>
69+ }
70+ </ul>
71+ } else {
72+ <p class="lede dim">nothing awaits moderation.</p>
73+ }
74+ </section>
75+}
76+
3977 templ InboxMRs(p InboxPage) {
4078 <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">
4179 <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>
@@ -11,6 +11,7 @@ templ Inbox(l Layout, p InboxPage) {
11 <div class="inbox">11 <div class="inbox">
12 @InboxCounters(p)12 @InboxCounters(p)
13 @InboxRepos(p)13 @InboxRepos(p)
14+ @InboxPending(p)
14 @InboxMRs(p)15 @InboxMRs(p)
15 @InboxIssues(p)16 @InboxIssues(p)
16 </div>17 </div>
@@ -19,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) {
19 20
20 templ InboxCounters(p InboxPage) {21 templ InboxCounters(p InboxPage) {
21 <p id="inbox-counts" class="inbox-counts">22 <p id="inbox-counts" class="inbox-counts">
22- <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues23+ <strong>{ itoa(p.MergeRequests) }</strong> merge requests · <strong>{ itoa(p.Issues) }</strong> issues · <strong>{ itoa(p.PendingCount) }</strong> awaiting moderation
23 </p>24 </p>
24 }25 }
25 26
@@ -36,6 +37,43 @@ templ InboxRepos(p InboxPage) {
36 </nav>37 </nav>
37 }38 }
38 39
40+// InboxPending is the issues awaiting moderation, first on the page. Their
41+// texts come from anonymous visitors: they are shown as plain text, escaped,
42+// never as Markdown.
43+templ InboxPending(p InboxPage) {
44+ <section id="inbox-pending" aria-labelledby="inbox-pending-h">
45+ <h2 id="inbox-pending-h" class="rule-h"><span>Awaiting moderation</span><span class="count">{ itoa(p.PendingCount) }</span></h2>
46+ if len(p.Pending) > 0 {
47+ <ul class="pending-list">
48+ for _, m := range p.Pending {
49+ <li class="pending">
50+ <div class="p-head">
51+ <a href={ templ.SafeURL(m.RepoHref) }>{ m.Repo }</a>
52+ <span>{ m.Author }</span>
53+ if m.WhenAt != "" {
54+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
55+ }
56+ </div>
57+ <p class="p-title">{ m.Title }</p>
58+ if m.Preview != "" {
59+ <p class="p-body">{ m.Preview }</p>
60+ }
61+ if m.Approve != "" {
62+ <div class="p-btns">
63+ <form method="post" action={ templ.SafeURL(m.Approve) }><button type="submit" class="btn primary">Approve</button></form>
64+ <form method="post" action={ templ.SafeURL(m.Reject) }><button type="submit" class="btn">Reject</button></form>
65+ <form method="post" action={ templ.SafeURL(m.Ban) }><button type="submit" class="btn danger">Reject and ban</button></form>
66+ </div>
67+ }
68+ </li>
69+ }
70+ </ul>
71+ } else {
72+ <p class="lede dim">nothing awaits moderation.</p>
73+ }
74+ </section>
75+}
76+
39 templ InboxMRs(p InboxPage) {77 templ InboxMRs(p InboxPage) {
40 <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">78 <section id="inbox-mrs" aria-labelledby="inbox-mrs-h">
41 <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>79 <h2 id="inbox-mrs-h" class="rule-h"><span>Open merge requests</span><span class="count">{ itoa(len(p.MRs)) }</span></h2>
A internal/adapters/web/inbox_moderation_test.go +148 -0
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa"
22+
23+func testPendingIssue(t testing.TB) domain.PendingIssue {
24+ t.Helper()
25+ id, err := domain.NewPendingIssueID()
26+ require.NoError(t, err)
27+ title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash")
28+ require.NoError(t, err)
29+ body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60))
30+ require.NoError(t, err)
31+ return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)}
32+}
33+
34+func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server {
35+ t.Helper()
36+ return newTestServer(t, Config{
37+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod,
38+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
39+ })
40+}
41+
42+func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) {
43+ t.Parallel()
44+ p := testPendingIssue(t)
45+ ov := inboxOverview(t)
46+ ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}}
47+ ov.Counts.Pending = 2
48+ svc := mocks.NewInboxService(t)
49+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil)
50+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
51+
52+ rec := inboxGet(srv, "/-/inbox", true)
53+ require.Equal(t, 200, rec.Code)
54+ body := rec.Body.String()
55+ assert.Contains(t, body, "<strong>2</strong> awaiting moderation")
56+ assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section")
57+ assert.Contains(t, body, "&lt;script&gt;alert(1)&lt;/script&gt; crash")
58+ assert.NotContains(t, body, "<script>alert")
59+ assert.Contains(t, body, "**not bold**", "no Markdown")
60+ assert.NotContains(t, body, "<strong>not bold")
61+ assert.NotContains(t, body, `href="javascript`)
62+ assert.NotContains(t, body, "<img")
63+ assert.Contains(t, body, "Eve &lt;i&gt;")
64+ assert.Contains(t, body, ">anonymous<", "no label")
65+ assert.Contains(t, body, "…</p>", "a preview of about 300 characters")
66+ assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`)
67+ assert.Contains(t, body, `/reject"`)
68+ assert.Contains(t, body, `/ban"`)
69+ assert.Contains(t, body, "Reject and ban")
70+}
71+
72+func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) {
73+ t.Parallel()
74+ p := testPendingIssue(t)
75+ ov := inboxOverview(t)
76+ ov.Pending = []domain.PendingIssue{p}
77+ ov.Counts.Pending = 1
78+ svc := mocks.NewInboxService(t)
79+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil)
80+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
81+ rec := inboxGet(srv, "/-/inbox?repo=alpha", true)
82+ assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`)
83+}
84+
85+func TestInboxModerationActions(t *testing.T) {
86+ t.Parallel()
87+ post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder {
88+ req := httptest.NewRequest(http.MethodPost, path, nil)
89+ for k, v := range hdr {
90+ req.Header.Set(k, v)
91+ }
92+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
93+ rec := httptest.NewRecorder()
94+ srv.ServeHTTP(rec, req)
95+ return rec
96+ }
97+ id, err := domain.ParsePendingIssueID(testPendingID)
98+ require.NoError(t, err)
99+ alpha := repoName(t, "alpha")
100+ site := map[string]string{"Origin": testPublicURL}
101+
102+ t.Run("approve", func(t *testing.T) {
103+ t.Parallel()
104+ mod := mocks.NewIssueModerationService(t)
105+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once()
106+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site)
107+ assert.Equal(t, 303, rec.Code)
108+ assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location"))
109+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
110+ })
111+ t.Run("reject", func(t *testing.T) {
112+ t.Parallel()
113+ mod := mocks.NewIssueModerationService(t)
114+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once()
115+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site)
116+ assert.Equal(t, 303, rec.Code)
117+ assert.Equal(t, "/-/inbox", rec.Header().Get("Location"))
118+ })
119+ t.Run("ban", func(t *testing.T) {
120+ t.Parallel()
121+ mod := mocks.NewIssueModerationService(t)
122+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once()
123+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site)
124+ assert.Equal(t, 303, rec.Code)
125+ })
126+ t.Run("gone", func(t *testing.T) {
127+ t.Parallel()
128+ mod := mocks.NewIssueModerationService(t)
129+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once()
130+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site)
131+ assert.Equal(t, 404, rec.Code)
132+ })
133+ t.Run("a malformed id is not found and calls nothing", func(t *testing.T) {
134+ t.Parallel()
135+ rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site)
136+ assert.Equal(t, 404, rec.Code)
137+ })
138+ t.Run("the gate: no origin, no session", func(t *testing.T) {
139+ t.Parallel()
140+ srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t))
141+ assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code)
142+ req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil)
143+ req.Header.Set("Origin", testPublicURL)
144+ rec := httptest.NewRecorder()
145+ srv.ServeHTTP(rec, req)
146+ assert.Equal(t, 403, rec.Code, "anonymous")
147+ })
148+}
@@ -0,0 +1,148 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "strings"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+const testPendingID = "aaaaaaaaaaaaaaaaaaaaaaaaaa"
22+
23+func testPendingIssue(t testing.TB) domain.PendingIssue {
24+ t.Helper()
25+ id, err := domain.NewPendingIssueID()
26+ require.NoError(t, err)
27+ title, err := domain.ParseIssueTitle("<script>alert(1)</script> crash")
28+ require.NoError(t, err)
29+ body, err := domain.ParseIssueBody("**not bold** [x](javascript:alert(1)) <img src=x onerror=y>\n" + strings.Repeat("long line ", 60))
30+ require.NoError(t, err)
31+ return domain.PendingIssue{ID: id, Repo: repoName(t, "alpha"), Title: title, Body: body, Author: "Eve <i>", CreatedAt: time.Now().Add(-3 * time.Hour)}
32+}
33+
34+func moderationServer(t *testing.T, inbox ports.InboxService, mod ports.IssueModerationService) *Server {
35+ t.Helper()
36+ return newTestServer(t, Config{
37+ Repos: reposServing(t, publicRepo(t, "alpha")), Browse: homeBrowse(t), Inbox: inbox, Moderation: mod,
38+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
39+ })
40+}
41+
42+func TestInboxShowsPendingIssuesAsPlainTextFirst(t *testing.T) {
43+ t.Parallel()
44+ p := testPendingIssue(t)
45+ ov := inboxOverview(t)
46+ ov.Pending = []domain.PendingIssue{p, {ID: p.ID, Repo: repoName(t, "beta"), Title: p.Title, CreatedAt: time.Now()}}
47+ ov.Counts.Pending = 2
48+ svc := mocks.NewInboxService(t)
49+ svc.EXPECT().Overview(mock.Anything, mock.Anything, domain.RepoName{}).Return(ov, nil)
50+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
51+
52+ rec := inboxGet(srv, "/-/inbox", true)
53+ require.Equal(t, 200, rec.Code)
54+ body := rec.Body.String()
55+ assert.Contains(t, body, "<strong>2</strong> awaiting moderation")
56+ assert.Less(t, strings.Index(body, `id="inbox-pending"`), strings.Index(body, `id="inbox-mrs"`), "first section")
57+ assert.Contains(t, body, "&lt;script&gt;alert(1)&lt;/script&gt; crash")
58+ assert.NotContains(t, body, "<script>alert")
59+ assert.Contains(t, body, "**not bold**", "no Markdown")
60+ assert.NotContains(t, body, "<strong>not bold")
61+ assert.NotContains(t, body, `href="javascript`)
62+ assert.NotContains(t, body, "<img")
63+ assert.Contains(t, body, "Eve &lt;i&gt;")
64+ assert.Contains(t, body, ">anonymous<", "no label")
65+ assert.Contains(t, body, "…</p>", "a preview of about 300 characters")
66+ assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`)
67+ assert.Contains(t, body, `/reject"`)
68+ assert.Contains(t, body, `/ban"`)
69+ assert.Contains(t, body, "Reject and ban")
70+}
71+
72+func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) {
73+ t.Parallel()
74+ p := testPendingIssue(t)
75+ ov := inboxOverview(t)
76+ ov.Pending = []domain.PendingIssue{p}
77+ ov.Counts.Pending = 1
78+ svc := mocks.NewInboxService(t)
79+ svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil)
80+ srv := moderationServer(t, svc, mocks.NewIssueModerationService(t))
81+ rec := inboxGet(srv, "/-/inbox?repo=alpha", true)
82+ assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`)
83+}
84+
85+func TestInboxModerationActions(t *testing.T) {
86+ t.Parallel()
87+ post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder {
88+ req := httptest.NewRequest(http.MethodPost, path, nil)
89+ for k, v := range hdr {
90+ req.Header.Set(k, v)
91+ }
92+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
93+ rec := httptest.NewRecorder()
94+ srv.ServeHTTP(rec, req)
95+ return rec
96+ }
97+ id, err := domain.ParsePendingIssueID(testPendingID)
98+ require.NoError(t, err)
99+ alpha := repoName(t, "alpha")
100+ site := map[string]string{"Origin": testPublicURL}
101+
102+ t.Run("approve", func(t *testing.T) {
103+ t.Parallel()
104+ mod := mocks.NewIssueModerationService(t)
105+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once()
106+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site)
107+ assert.Equal(t, 303, rec.Code)
108+ assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location"))
109+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
110+ })
111+ t.Run("reject", func(t *testing.T) {
112+ t.Parallel()
113+ mod := mocks.NewIssueModerationService(t)
114+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once()
115+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site)
116+ assert.Equal(t, 303, rec.Code)
117+ assert.Equal(t, "/-/inbox", rec.Header().Get("Location"))
118+ })
119+ t.Run("ban", func(t *testing.T) {
120+ t.Parallel()
121+ mod := mocks.NewIssueModerationService(t)
122+ mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once()
123+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site)
124+ assert.Equal(t, 303, rec.Code)
125+ })
126+ t.Run("gone", func(t *testing.T) {
127+ t.Parallel()
128+ mod := mocks.NewIssueModerationService(t)
129+ mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once()
130+ rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site)
131+ assert.Equal(t, 404, rec.Code)
132+ })
133+ t.Run("a malformed id is not found and calls nothing", func(t *testing.T) {
134+ t.Parallel()
135+ rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site)
136+ assert.Equal(t, 404, rec.Code)
137+ })
138+ t.Run("the gate: no origin, no session", func(t *testing.T) {
139+ t.Parallel()
140+ srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t))
141+ assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code)
142+ req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil)
143+ req.Header.Set("Origin", testPublicURL)
144+ rec := httptest.NewRecorder()
145+ srv.ServeHTTP(rec, req)
146+ assert.Equal(t, 403, rec.Code, "anonymous")
147+ })
148+}
M internal/adapters/web/inbox_templ.go +354 -98
@@ -58,6 +58,10 @@ func Inbox(l Layout, p InboxPage) templ.Component {
5858 if templ_7745c5c3_Err != nil {
5959 return templ_7745c5c3_Err
6060 }
61+ templ_7745c5c3_Err = InboxPending(p).Render(ctx, templ_7745c5c3_Buffer)
62+ if templ_7745c5c3_Err != nil {
63+ return templ_7745c5c3_Err
64+ }
6165 templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer)
6266 if templ_7745c5c3_Err != nil {
6367 return templ_7745c5c3_Err
@@ -108,7 +112,7 @@ func InboxCounters(p InboxPage) templ.Component {
108112 var templ_7745c5c3_Var4 string
109113 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests))
110114 if templ_7745c5c3_Err != nil {
111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33}
115+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 33}
112116 }
113117 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
114118 if templ_7745c5c3_Err != nil {
@@ -121,13 +125,26 @@ func InboxCounters(p InboxPage) templ.Component {
121125 var templ_7745c5c3_Var5 string
122126 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues))
123127 if templ_7745c5c3_Err != nil {
124- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87}
128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 87}
125129 }
126130 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
127131 if templ_7745c5c3_Err != nil {
128132 return templ_7745c5c3_Err
129133 }
130- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>")
134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues · <strong>")
135+ if templ_7745c5c3_Err != nil {
136+ return templ_7745c5c3_Err
137+ }
138+ var templ_7745c5c3_Var6 string
139+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount))
140+ if templ_7745c5c3_Err != nil {
141+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 139}
142+ }
143+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
144+ if templ_7745c5c3_Err != nil {
145+ return templ_7745c5c3_Err
146+ }
147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong> awaiting moderation</p>")
131148 if templ_7745c5c3_Err != nil {
132149 return templ_7745c5c3_Err
133150 }
@@ -151,58 +168,297 @@ func InboxRepos(p InboxPage) templ.Component {
151168 }()
152169 }
153170 ctx = templ.InitializeContext(ctx)
154- templ_7745c5c3_Var6 := templ.GetChildren(ctx)
155- if templ_7745c5c3_Var6 == nil {
156- templ_7745c5c3_Var6 = templ.NopComponent
171+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
172+ if templ_7745c5c3_Var7 == nil {
173+ templ_7745c5c3_Var7 = templ.NopComponent
157174 }
158175 ctx = templ.ClearChildren(ctx)
159- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">")
176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">")
160177 if templ_7745c5c3_Err != nil {
161178 return templ_7745c5c3_Err
162179 }
163180 for _, r := range p.Repos {
164- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"")
181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
165182 if templ_7745c5c3_Err != nil {
166183 return templ_7745c5c3_Err
167184 }
168- var templ_7745c5c3_Var7 templ.SafeURL
169- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
185+ var templ_7745c5c3_Var8 templ.SafeURL
186+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
170187 if templ_7745c5c3_Err != nil {
171- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32}
188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 31, Col: 32}
172189 }
173- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
190+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
174191 if templ_7745c5c3_Err != nil {
175192 return templ_7745c5c3_Err
176193 }
177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"")
194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
178195 if templ_7745c5c3_Err != nil {
179196 return templ_7745c5c3_Err
180197 }
181198 if r.Current {
182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"")
199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"page\"")
183200 if templ_7745c5c3_Err != nil {
184201 return templ_7745c5c3_Err
185202 }
186203 }
187- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">")
204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">")
188205 if templ_7745c5c3_Err != nil {
189206 return templ_7745c5c3_Err
190207 }
191- var templ_7745c5c3_Var8 string
192- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label)
208+ var templ_7745c5c3_Var9 string
209+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label)
193210 if templ_7745c5c3_Err != nil {
194- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13}
211+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 35, Col: 13}
195212 }
196- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
213+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
197214 if templ_7745c5c3_Err != nil {
198215 return templ_7745c5c3_Err
199216 }
200- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>")
217+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
201218 if templ_7745c5c3_Err != nil {
202219 return templ_7745c5c3_Err
203220 }
204221 }
205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>")
222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</nav>")
223+ if templ_7745c5c3_Err != nil {
224+ return templ_7745c5c3_Err
225+ }
226+ return nil
227+ })
228+}
229+
230+// InboxPending is the issues awaiting moderation, first on the page. Their
231+// texts come from anonymous visitors: they are shown as plain text, escaped,
232+// never as Markdown.
233+func InboxPending(p InboxPage) templ.Component {
234+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
235+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
236+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
237+ return templ_7745c5c3_CtxErr
238+ }
239+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
240+ if !templ_7745c5c3_IsBuffer {
241+ defer func() {
242+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
243+ if templ_7745c5c3_Err == nil {
244+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
245+ }
246+ }()
247+ }
248+ ctx = templ.InitializeContext(ctx)
249+ templ_7745c5c3_Var10 := templ.GetChildren(ctx)
250+ if templ_7745c5c3_Var10 == nil {
251+ templ_7745c5c3_Var10 = templ.NopComponent
252+ }
253+ ctx = templ.ClearChildren(ctx)
254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section id=\"inbox-pending\" aria-labelledby=\"inbox-pending-h\"><h2 id=\"inbox-pending-h\" class=\"rule-h\"><span>Awaiting moderation</span><span class=\"count\">")
255+ if templ_7745c5c3_Err != nil {
256+ return templ_7745c5c3_Err
257+ }
258+ var templ_7745c5c3_Var11 string
259+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount))
260+ if templ_7745c5c3_Err != nil {
261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 45, Col: 116}
262+ }
263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
264+ if templ_7745c5c3_Err != nil {
265+ return templ_7745c5c3_Err
266+ }
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></h2>")
268+ if templ_7745c5c3_Err != nil {
269+ return templ_7745c5c3_Err
270+ }
271+ if len(p.Pending) > 0 {
272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<ul class=\"pending-list\">")
273+ if templ_7745c5c3_Err != nil {
274+ return templ_7745c5c3_Err
275+ }
276+ for _, m := range p.Pending {
277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<li class=\"pending\"><div class=\"p-head\"><a href=\"")
278+ if templ_7745c5c3_Err != nil {
279+ return templ_7745c5c3_Err
280+ }
281+ var templ_7745c5c3_Var12 templ.SafeURL
282+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))
283+ if templ_7745c5c3_Err != nil {
284+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 42}
285+ }
286+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
287+ if templ_7745c5c3_Err != nil {
288+ return templ_7745c5c3_Err
289+ }
290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
291+ if templ_7745c5c3_Err != nil {
292+ return templ_7745c5c3_Err
293+ }
294+ var templ_7745c5c3_Var13 string
295+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)
296+ if templ_7745c5c3_Err != nil {
297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 53}
298+ }
299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> <span>")
304+ if templ_7745c5c3_Err != nil {
305+ return templ_7745c5c3_Err
306+ }
307+ var templ_7745c5c3_Var14 string
308+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author)
309+ if templ_7745c5c3_Err != nil {
310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 52, Col: 23}
311+ }
312+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ if m.WhenAt != "" {
321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<time datetime=\"")
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ var templ_7745c5c3_Var15 string
326+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
327+ if templ_7745c5c3_Err != nil {
328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 33}
329+ }
330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
331+ if templ_7745c5c3_Err != nil {
332+ return templ_7745c5c3_Err
333+ }
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
335+ if templ_7745c5c3_Err != nil {
336+ return templ_7745c5c3_Err
337+ }
338+ var templ_7745c5c3_Var16 string
339+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
340+ if templ_7745c5c3_Err != nil {
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 52}
342+ }
343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
344+ if templ_7745c5c3_Err != nil {
345+ return templ_7745c5c3_Err
346+ }
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var17 string
352+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 63}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</time>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ }
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><p class=\"p-title\">")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var18 string
370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 57, Col: 34}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p>")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ if m.Preview != "" {
383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"p-body\">")
384+ if templ_7745c5c3_Err != nil {
385+ return templ_7745c5c3_Err
386+ }
387+ var templ_7745c5c3_Var19 string
388+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Preview)
389+ if templ_7745c5c3_Err != nil {
390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 59, Col: 36}
391+ }
392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
393+ if templ_7745c5c3_Err != nil {
394+ return templ_7745c5c3_Err
395+ }
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
397+ if templ_7745c5c3_Err != nil {
398+ return templ_7745c5c3_Err
399+ }
400+ }
401+ if m.Approve != "" {
402+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"p-btns\"><form method=\"post\" action=\"")
403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ var templ_7745c5c3_Var20 templ.SafeURL
407+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Approve))
408+ if templ_7745c5c3_Err != nil {
409+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 63, Col: 61}
410+ }
411+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
412+ if templ_7745c5c3_Err != nil {
413+ return templ_7745c5c3_Err
414+ }
415+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><button type=\"submit\" class=\"btn primary\">Approve</button></form><form method=\"post\" action=\"")
416+ if templ_7745c5c3_Err != nil {
417+ return templ_7745c5c3_Err
418+ }
419+ var templ_7745c5c3_Var21 templ.SafeURL
420+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Reject))
421+ if templ_7745c5c3_Err != nil {
422+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 64, Col: 60}
423+ }
424+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
425+ if templ_7745c5c3_Err != nil {
426+ return templ_7745c5c3_Err
427+ }
428+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><button type=\"submit\" class=\"btn\">Reject</button></form><form method=\"post\" action=\"")
429+ if templ_7745c5c3_Err != nil {
430+ return templ_7745c5c3_Err
431+ }
432+ var templ_7745c5c3_Var22 templ.SafeURL
433+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Ban))
434+ if templ_7745c5c3_Err != nil {
435+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 57}
436+ }
437+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
438+ if templ_7745c5c3_Err != nil {
439+ return templ_7745c5c3_Err
440+ }
441+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><button type=\"submit\" class=\"btn danger\">Reject and ban</button></form></div>")
442+ if templ_7745c5c3_Err != nil {
443+ return templ_7745c5c3_Err
444+ }
445+ }
446+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</li>")
447+ if templ_7745c5c3_Err != nil {
448+ return templ_7745c5c3_Err
449+ }
450+ }
451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</ul>")
452+ if templ_7745c5c3_Err != nil {
453+ return templ_7745c5c3_Err
454+ }
455+ } else {
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"lede dim\">nothing awaits moderation.</p>")
457+ if templ_7745c5c3_Err != nil {
458+ return templ_7745c5c3_Err
459+ }
460+ }
461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>")
206462 if templ_7745c5c3_Err != nil {
207463 return templ_7745c5c3_Err
208464 }
@@ -226,25 +482,25 @@ func InboxMRs(p InboxPage) templ.Component {
226482 }()
227483 }
228484 ctx = templ.InitializeContext(ctx)
229- templ_7745c5c3_Var9 := templ.GetChildren(ctx)
230- if templ_7745c5c3_Var9 == nil {
231- templ_7745c5c3_Var9 = templ.NopComponent
485+ templ_7745c5c3_Var23 := templ.GetChildren(ctx)
486+ if templ_7745c5c3_Var23 == nil {
487+ templ_7745c5c3_Var23 = templ.NopComponent
232488 }
233489 ctx = templ.ClearChildren(ctx)
234- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">")
490+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">")
235491 if templ_7745c5c3_Err != nil {
236492 return templ_7745c5c3_Err
237493 }
238- var templ_7745c5c3_Var10 string
239- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs)))
494+ var templ_7745c5c3_Var24 string
495+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs)))
240496 if templ_7745c5c3_Err != nil {
241- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108}
497+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 79, Col: 108}
242498 }
243- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
499+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
244500 if templ_7745c5c3_Err != nil {
245501 return templ_7745c5c3_Err
246502 }
247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>")
503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></h2>")
248504 if templ_7745c5c3_Err != nil {
249505 return templ_7745c5c3_Err
250506 }
@@ -253,12 +509,12 @@ func InboxMRs(p InboxPage) templ.Component {
253509 return templ_7745c5c3_Err
254510 }
255511 if len(p.MRs) == 0 {
256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>")
512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"lede dim\">no open merge requests.</p>")
257513 if templ_7745c5c3_Err != nil {
258514 return templ_7745c5c3_Err
259515 }
260516 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>")
517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>")
262518 if templ_7745c5c3_Err != nil {
263519 return templ_7745c5c3_Err
264520 }
@@ -282,25 +538,25 @@ func InboxIssues(p InboxPage) templ.Component {
282538 }()
283539 }
284540 ctx = templ.InitializeContext(ctx)
285- templ_7745c5c3_Var11 := templ.GetChildren(ctx)
286- if templ_7745c5c3_Var11 == nil {
287- templ_7745c5c3_Var11 = templ.NopComponent
541+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
542+ if templ_7745c5c3_Var25 == nil {
543+ templ_7745c5c3_Var25 = templ.NopComponent
288544 }
289545 ctx = templ.ClearChildren(ctx)
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">")
546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">")
291547 if templ_7745c5c3_Err != nil {
292548 return templ_7745c5c3_Err
293549 }
294- var templ_7745c5c3_Var12 string
295- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows)))
550+ var templ_7745c5c3_Var26 string
551+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows)))
296552 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109}
553+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 89, Col: 109}
298554 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
555+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
300556 if templ_7745c5c3_Err != nil {
301557 return templ_7745c5c3_Err
302558 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>")
559+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></h2>")
304560 if templ_7745c5c3_Err != nil {
305561 return templ_7745c5c3_Err
306562 }
@@ -309,12 +565,12 @@ func InboxIssues(p InboxPage) templ.Component {
309565 return templ_7745c5c3_Err
310566 }
311567 if len(p.IssueRows) == 0 {
312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>")
568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"lede dim\">no open issues.</p>")
313569 if templ_7745c5c3_Err != nil {
314570 return templ_7745c5c3_Err
315571 }
316572 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>")
573+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>")
318574 if templ_7745c5c3_Err != nil {
319575 return templ_7745c5c3_Err
320576 }
@@ -338,160 +594,160 @@ func inboxRows(rows []InboxRow, label string) templ.Component {
338594 }()
339595 }
340596 ctx = templ.InitializeContext(ctx)
341- templ_7745c5c3_Var13 := templ.GetChildren(ctx)
342- if templ_7745c5c3_Var13 == nil {
343- templ_7745c5c3_Var13 = templ.NopComponent
597+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
598+ if templ_7745c5c3_Var27 == nil {
599+ templ_7745c5c3_Var27 = templ.NopComponent
344600 }
345601 ctx = templ.ClearChildren(ctx)
346602 if len(rows) > 0 {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"")
603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<table class=\"list mrs\" aria-label=\"")
348604 if templ_7745c5c3_Err != nil {
349605 return templ_7745c5c3_Err
350606 }
351- var templ_7745c5c3_Var14 string
352- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)
607+ var templ_7745c5c3_Var28 string
608+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)
353609 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44}
610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 99, Col: 44}
355611 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
357613 if templ_7745c5c3_Err != nil {
358614 return templ_7745c5c3_Err
359615 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>")
616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><tbody data-rows>")
361617 if templ_7745c5c3_Err != nil {
362618 return templ_7745c5c3_Err
363619 }
364620 for _, m := range rows {
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr")
621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr")
366622 if templ_7745c5c3_Err != nil {
367623 return templ_7745c5c3_Err
368624 }
369625 if m.Cursor {
370- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor")
626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " data-cursor")
371627 if templ_7745c5c3_Err != nil {
372628 return templ_7745c5c3_Err
373629 }
374630 }
375- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">")
631+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " data-state=\"open\"><td class=\"c-n mute\">")
376632 if templ_7745c5c3_Err != nil {
377633 return templ_7745c5c3_Err
378634 }
379- var templ_7745c5c3_Var15 string
380- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)
635+ var templ_7745c5c3_Var29 string
636+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)
381637 if templ_7745c5c3_Err != nil {
382- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37}
638+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 103, Col: 37}
383639 }
384- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
640+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
385641 if templ_7745c5c3_Err != nil {
386642 return templ_7745c5c3_Err
387643 }
388- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"")
644+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-subject\"><a href=\"")
389645 if templ_7745c5c3_Err != nil {
390646 return templ_7745c5c3_Err
391647 }
392- var templ_7745c5c3_Var16 templ.SafeURL
393- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))
648+ var templ_7745c5c3_Var30 templ.SafeURL
649+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))
394650 if templ_7745c5c3_Err != nil {
395- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59}
651+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 59}
396652 }
397- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
653+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
398654 if templ_7745c5c3_Err != nil {
399655 return templ_7745c5c3_Err
400656 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")
657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
402658 if templ_7745c5c3_Err != nil {
403659 return templ_7745c5c3_Err
404660 }
405- var templ_7745c5c3_Var17 string
406- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
661+ var templ_7745c5c3_Var31 string
662+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
407663 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71}
664+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 71}
409665 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
666+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
411667 if templ_7745c5c3_Err != nil {
412668 return templ_7745c5c3_Err
413669 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"")
670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</a> <a class=\"mute\" href=\"")
415671 if templ_7745c5c3_Err != nil {
416672 return templ_7745c5c3_Err
417673 }
418- var templ_7745c5c3_Var18 templ.SafeURL
419- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))
674+ var templ_7745c5c3_Var32 templ.SafeURL
675+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))
420676 if templ_7745c5c3_Err != nil {
421- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126}
677+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 126}
422678 }
423- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
679+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
424680 if templ_7745c5c3_Err != nil {
425681 return templ_7745c5c3_Err
426682 }
427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")
683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
428684 if templ_7745c5c3_Err != nil {
429685 return templ_7745c5c3_Err
430686 }
431- var templ_7745c5c3_Var19 string
432- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)
687+ var templ_7745c5c3_Var33 string
688+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)
433689 if templ_7745c5c3_Err != nil {
434- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137}
690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 137}
435691 }
436- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
692+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
437693 if templ_7745c5c3_Err != nil {
438694 return templ_7745c5c3_Err
439695 }
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">")
696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a></td><td class=\"c-when nowrap dim\">")
441697 if templ_7745c5c3_Err != nil {
442698 return templ_7745c5c3_Err
443699 }
444700 if m.WhenAt != "" {
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"")
701+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<time datetime=\"")
446702 if templ_7745c5c3_Err != nil {
447703 return templ_7745c5c3_Err
448704 }
449- var templ_7745c5c3_Var20 string
450- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
705+ var templ_7745c5c3_Var34 string
706+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
451707 if templ_7745c5c3_Err != nil {
452- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33}
708+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 33}
453709 }
454- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
710+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
455711 if templ_7745c5c3_Err != nil {
456712 return templ_7745c5c3_Err
457713 }
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"")
714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"")
459715 if templ_7745c5c3_Err != nil {
460716 return templ_7745c5c3_Err
461717 }
462- var templ_7745c5c3_Var21 string
463- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
718+ var templ_7745c5c3_Var35 string
719+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
464720 if templ_7745c5c3_Err != nil {
465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52}
721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 52}
466722 }
467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
468724 if templ_7745c5c3_Err != nil {
469725 return templ_7745c5c3_Err
470726 }
471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
472728 if templ_7745c5c3_Err != nil {
473729 return templ_7745c5c3_Err
474730 }
475- var templ_7745c5c3_Var22 string
476- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
731+ var templ_7745c5c3_Var36 string
732+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
477733 if templ_7745c5c3_Err != nil {
478- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63}
734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 63}
479735 }
480- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
481737 if templ_7745c5c3_Err != nil {
482738 return templ_7745c5c3_Err
483739 }
484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>")
740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time>")
485741 if templ_7745c5c3_Err != nil {
486742 return templ_7745c5c3_Err
487743 }
488744 }
489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>")
745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td></tr>")
490746 if templ_7745c5c3_Err != nil {
491747 return templ_7745c5c3_Err
492748 }
493749 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>")
750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>")
495751 if templ_7745c5c3_Err != nil {
496752 return templ_7745c5c3_Err
497753 }
@@ -58,6 +58,10 @@ func Inbox(l Layout, p InboxPage) templ.Component {
58 if templ_7745c5c3_Err != nil {58 if templ_7745c5c3_Err != nil {
59 return templ_7745c5c3_Err59 return templ_7745c5c3_Err
60 }60 }
61+ templ_7745c5c3_Err = InboxPending(p).Render(ctx, templ_7745c5c3_Buffer)
62+ if templ_7745c5c3_Err != nil {
63+ return templ_7745c5c3_Err
64+ }
61 templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer)65 templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer)
62 if templ_7745c5c3_Err != nil {66 if templ_7745c5c3_Err != nil {
63 return templ_7745c5c3_Err67 return templ_7745c5c3_Err
@@ -108,7 +112,7 @@ func InboxCounters(p InboxPage) templ.Component {
108 var templ_7745c5c3_Var4 string112 var templ_7745c5c3_Var4 string
109 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests))113 templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests))
110 if templ_7745c5c3_Err != nil {114 if templ_7745c5c3_Err != nil {
111- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33}115+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 33}
112 }116 }
113 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))117 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
114 if templ_7745c5c3_Err != nil {118 if templ_7745c5c3_Err != nil {
@@ -121,13 +125,26 @@ func InboxCounters(p InboxPage) templ.Component {
121 var templ_7745c5c3_Var5 string125 var templ_7745c5c3_Var5 string
122 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues))126 templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues))
123 if templ_7745c5c3_Err != nil {127 if templ_7745c5c3_Err != nil {
124- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87}128+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 87}
125 }129 }
126 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))130 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
127 if templ_7745c5c3_Err != nil {131 if templ_7745c5c3_Err != nil {
128 return templ_7745c5c3_Err132 return templ_7745c5c3_Err
129 }133 }
130- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>")134+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues · <strong>")
135+ if templ_7745c5c3_Err != nil {
136+ return templ_7745c5c3_Err
137+ }
138+ var templ_7745c5c3_Var6 string
139+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount))
140+ if templ_7745c5c3_Err != nil {
141+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 139}
142+ }
143+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
144+ if templ_7745c5c3_Err != nil {
145+ return templ_7745c5c3_Err
146+ }
147+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong> awaiting moderation</p>")
131 if templ_7745c5c3_Err != nil {148 if templ_7745c5c3_Err != nil {
132 return templ_7745c5c3_Err149 return templ_7745c5c3_Err
133 }150 }
@@ -151,58 +168,297 @@ func InboxRepos(p InboxPage) templ.Component {
151 }()168 }()
152 }169 }
153 ctx = templ.InitializeContext(ctx)170 ctx = templ.InitializeContext(ctx)
154- templ_7745c5c3_Var6 := templ.GetChildren(ctx)171+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
155- if templ_7745c5c3_Var6 == nil {172+ if templ_7745c5c3_Var7 == nil {
156- templ_7745c5c3_Var6 = templ.NopComponent173+ templ_7745c5c3_Var7 = templ.NopComponent
157 }174 }
158 ctx = templ.ClearChildren(ctx)175 ctx = templ.ClearChildren(ctx)
159- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">")176+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">")
160 if templ_7745c5c3_Err != nil {177 if templ_7745c5c3_Err != nil {
161 return templ_7745c5c3_Err178 return templ_7745c5c3_Err
162 }179 }
163 for _, r := range p.Repos {180 for _, r := range p.Repos {
164- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"")181+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"")
165 if templ_7745c5c3_Err != nil {182 if templ_7745c5c3_Err != nil {
166 return templ_7745c5c3_Err183 return templ_7745c5c3_Err
167 }184 }
168- var templ_7745c5c3_Var7 templ.SafeURL185+ var templ_7745c5c3_Var8 templ.SafeURL
169- templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))186+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
170 if templ_7745c5c3_Err != nil {187 if templ_7745c5c3_Err != nil {
171- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32}188+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 31, Col: 32}
172 }189 }
173- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))190+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
174 if templ_7745c5c3_Err != nil {191 if templ_7745c5c3_Err != nil {
175 return templ_7745c5c3_Err192 return templ_7745c5c3_Err
176 }193 }
177- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"")194+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"")
178 if templ_7745c5c3_Err != nil {195 if templ_7745c5c3_Err != nil {
179 return templ_7745c5c3_Err196 return templ_7745c5c3_Err
180 }197 }
181 if r.Current {198 if r.Current {
182- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"")199+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"page\"")
183 if templ_7745c5c3_Err != nil {200 if templ_7745c5c3_Err != nil {
184 return templ_7745c5c3_Err201 return templ_7745c5c3_Err
185 }202 }
186 }203 }
187- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">")204+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">")
188 if templ_7745c5c3_Err != nil {205 if templ_7745c5c3_Err != nil {
189 return templ_7745c5c3_Err206 return templ_7745c5c3_Err
190 }207 }
191- var templ_7745c5c3_Var8 string208+ var templ_7745c5c3_Var9 string
192- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label)209+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label)
193 if templ_7745c5c3_Err != nil {210 if templ_7745c5c3_Err != nil {
194- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13}211+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 35, Col: 13}
195 }212 }
196- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))213+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
197 if templ_7745c5c3_Err != nil {214 if templ_7745c5c3_Err != nil {
198 return templ_7745c5c3_Err215 return templ_7745c5c3_Err
199 }216 }
200- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>")217+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>")
201 if templ_7745c5c3_Err != nil {218 if templ_7745c5c3_Err != nil {
202 return templ_7745c5c3_Err219 return templ_7745c5c3_Err
203 }220 }
204 }221 }
205- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>")222+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</nav>")
223+ if templ_7745c5c3_Err != nil {
224+ return templ_7745c5c3_Err
225+ }
226+ return nil
227+ })
228+}
229+
230+// InboxPending is the issues awaiting moderation, first on the page. Their
231+// texts come from anonymous visitors: they are shown as plain text, escaped,
232+// never as Markdown.
233+func InboxPending(p InboxPage) templ.Component {
234+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
235+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
236+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
237+ return templ_7745c5c3_CtxErr
238+ }
239+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
240+ if !templ_7745c5c3_IsBuffer {
241+ defer func() {
242+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
243+ if templ_7745c5c3_Err == nil {
244+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
245+ }
246+ }()
247+ }
248+ ctx = templ.InitializeContext(ctx)
249+ templ_7745c5c3_Var10 := templ.GetChildren(ctx)
250+ if templ_7745c5c3_Var10 == nil {
251+ templ_7745c5c3_Var10 = templ.NopComponent
252+ }
253+ ctx = templ.ClearChildren(ctx)
254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section id=\"inbox-pending\" aria-labelledby=\"inbox-pending-h\"><h2 id=\"inbox-pending-h\" class=\"rule-h\"><span>Awaiting moderation</span><span class=\"count\">")
255+ if templ_7745c5c3_Err != nil {
256+ return templ_7745c5c3_Err
257+ }
258+ var templ_7745c5c3_Var11 string
259+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount))
260+ if templ_7745c5c3_Err != nil {
261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 45, Col: 116}
262+ }
263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
264+ if templ_7745c5c3_Err != nil {
265+ return templ_7745c5c3_Err
266+ }
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></h2>")
268+ if templ_7745c5c3_Err != nil {
269+ return templ_7745c5c3_Err
270+ }
271+ if len(p.Pending) > 0 {
272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<ul class=\"pending-list\">")
273+ if templ_7745c5c3_Err != nil {
274+ return templ_7745c5c3_Err
275+ }
276+ for _, m := range p.Pending {
277+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<li class=\"pending\"><div class=\"p-head\"><a href=\"")
278+ if templ_7745c5c3_Err != nil {
279+ return templ_7745c5c3_Err
280+ }
281+ var templ_7745c5c3_Var12 templ.SafeURL
282+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))
283+ if templ_7745c5c3_Err != nil {
284+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 42}
285+ }
286+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
287+ if templ_7745c5c3_Err != nil {
288+ return templ_7745c5c3_Err
289+ }
290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">")
291+ if templ_7745c5c3_Err != nil {
292+ return templ_7745c5c3_Err
293+ }
294+ var templ_7745c5c3_Var13 string
295+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)
296+ if templ_7745c5c3_Err != nil {
297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 53}
298+ }
299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> <span>")
304+ if templ_7745c5c3_Err != nil {
305+ return templ_7745c5c3_Err
306+ }
307+ var templ_7745c5c3_Var14 string
308+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author)
309+ if templ_7745c5c3_Err != nil {
310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 52, Col: 23}
311+ }
312+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ if m.WhenAt != "" {
321+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<time datetime=\"")
322+ if templ_7745c5c3_Err != nil {
323+ return templ_7745c5c3_Err
324+ }
325+ var templ_7745c5c3_Var15 string
326+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
327+ if templ_7745c5c3_Err != nil {
328+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 33}
329+ }
330+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
331+ if templ_7745c5c3_Err != nil {
332+ return templ_7745c5c3_Err
333+ }
334+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"")
335+ if templ_7745c5c3_Err != nil {
336+ return templ_7745c5c3_Err
337+ }
338+ var templ_7745c5c3_Var16 string
339+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
340+ if templ_7745c5c3_Err != nil {
341+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 52}
342+ }
343+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
344+ if templ_7745c5c3_Err != nil {
345+ return templ_7745c5c3_Err
346+ }
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var17 string
352+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 63}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</time>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ }
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><p class=\"p-title\">")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var18 string
370+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 57, Col: 34}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p>")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ if m.Preview != "" {
383+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"p-body\">")
384+ if templ_7745c5c3_Err != nil {
385+ return templ_7745c5c3_Err
386+ }
387+ var templ_7745c5c3_Var19 string
388+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Preview)
389+ if templ_7745c5c3_Err != nil {
390+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 59, Col: 36}
391+ }
392+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
393+ if templ_7745c5c3_Err != nil {
394+ return templ_7745c5c3_Err
395+ }
396+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
397+ if templ_7745c5c3_Err != nil {
398+ return templ_7745c5c3_Err
399+ }
400+ }
401+ if m.Approve != "" {
402+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"p-btns\"><form method=\"post\" action=\"")
403+ if templ_7745c5c3_Err != nil {
404+ return templ_7745c5c3_Err
405+ }
406+ var templ_7745c5c3_Var20 templ.SafeURL
407+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Approve))
408+ if templ_7745c5c3_Err != nil {
409+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 63, Col: 61}
410+ }
411+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
412+ if templ_7745c5c3_Err != nil {
413+ return templ_7745c5c3_Err
414+ }
415+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><button type=\"submit\" class=\"btn primary\">Approve</button></form><form method=\"post\" action=\"")
416+ if templ_7745c5c3_Err != nil {
417+ return templ_7745c5c3_Err
418+ }
419+ var templ_7745c5c3_Var21 templ.SafeURL
420+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Reject))
421+ if templ_7745c5c3_Err != nil {
422+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 64, Col: 60}
423+ }
424+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
425+ if templ_7745c5c3_Err != nil {
426+ return templ_7745c5c3_Err
427+ }
428+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><button type=\"submit\" class=\"btn\">Reject</button></form><form method=\"post\" action=\"")
429+ if templ_7745c5c3_Err != nil {
430+ return templ_7745c5c3_Err
431+ }
432+ var templ_7745c5c3_Var22 templ.SafeURL
433+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Ban))
434+ if templ_7745c5c3_Err != nil {
435+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 57}
436+ }
437+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
438+ if templ_7745c5c3_Err != nil {
439+ return templ_7745c5c3_Err
440+ }
441+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><button type=\"submit\" class=\"btn danger\">Reject and ban</button></form></div>")
442+ if templ_7745c5c3_Err != nil {
443+ return templ_7745c5c3_Err
444+ }
445+ }
446+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</li>")
447+ if templ_7745c5c3_Err != nil {
448+ return templ_7745c5c3_Err
449+ }
450+ }
451+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</ul>")
452+ if templ_7745c5c3_Err != nil {
453+ return templ_7745c5c3_Err
454+ }
455+ } else {
456+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"lede dim\">nothing awaits moderation.</p>")
457+ if templ_7745c5c3_Err != nil {
458+ return templ_7745c5c3_Err
459+ }
460+ }
461+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>")
206 if templ_7745c5c3_Err != nil {462 if templ_7745c5c3_Err != nil {
207 return templ_7745c5c3_Err463 return templ_7745c5c3_Err
208 }464 }
@@ -226,25 +482,25 @@ func InboxMRs(p InboxPage) templ.Component {
226 }()482 }()
227 }483 }
228 ctx = templ.InitializeContext(ctx)484 ctx = templ.InitializeContext(ctx)
229- templ_7745c5c3_Var9 := templ.GetChildren(ctx)485+ templ_7745c5c3_Var23 := templ.GetChildren(ctx)
230- if templ_7745c5c3_Var9 == nil {486+ if templ_7745c5c3_Var23 == nil {
231- templ_7745c5c3_Var9 = templ.NopComponent487+ templ_7745c5c3_Var23 = templ.NopComponent
232 }488 }
233 ctx = templ.ClearChildren(ctx)489 ctx = templ.ClearChildren(ctx)
234- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">")490+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">")
235 if templ_7745c5c3_Err != nil {491 if templ_7745c5c3_Err != nil {
236 return templ_7745c5c3_Err492 return templ_7745c5c3_Err
237 }493 }
238- var templ_7745c5c3_Var10 string494+ var templ_7745c5c3_Var24 string
239- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs)))495+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs)))
240 if templ_7745c5c3_Err != nil {496 if templ_7745c5c3_Err != nil {
241- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108}497+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 79, Col: 108}
242 }498 }
243- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))499+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
244 if templ_7745c5c3_Err != nil {500 if templ_7745c5c3_Err != nil {
245 return templ_7745c5c3_Err501 return templ_7745c5c3_Err
246 }502 }
247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>")503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></h2>")
248 if templ_7745c5c3_Err != nil {504 if templ_7745c5c3_Err != nil {
249 return templ_7745c5c3_Err505 return templ_7745c5c3_Err
250 }506 }
@@ -253,12 +509,12 @@ func InboxMRs(p InboxPage) templ.Component {
253 return templ_7745c5c3_Err509 return templ_7745c5c3_Err
254 }510 }
255 if len(p.MRs) == 0 {511 if len(p.MRs) == 0 {
256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>")512+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"lede dim\">no open merge requests.</p>")
257 if templ_7745c5c3_Err != nil {513 if templ_7745c5c3_Err != nil {
258 return templ_7745c5c3_Err514 return templ_7745c5c3_Err
259 }515 }
260 }516 }
261- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>")517+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>")
262 if templ_7745c5c3_Err != nil {518 if templ_7745c5c3_Err != nil {
263 return templ_7745c5c3_Err519 return templ_7745c5c3_Err
264 }520 }
@@ -282,25 +538,25 @@ func InboxIssues(p InboxPage) templ.Component {
282 }()538 }()
283 }539 }
284 ctx = templ.InitializeContext(ctx)540 ctx = templ.InitializeContext(ctx)
285- templ_7745c5c3_Var11 := templ.GetChildren(ctx)541+ templ_7745c5c3_Var25 := templ.GetChildren(ctx)
286- if templ_7745c5c3_Var11 == nil {542+ if templ_7745c5c3_Var25 == nil {
287- templ_7745c5c3_Var11 = templ.NopComponent543+ templ_7745c5c3_Var25 = templ.NopComponent
288 }544 }
289 ctx = templ.ClearChildren(ctx)545 ctx = templ.ClearChildren(ctx)
290- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">")546+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">")
291 if templ_7745c5c3_Err != nil {547 if templ_7745c5c3_Err != nil {
292 return templ_7745c5c3_Err548 return templ_7745c5c3_Err
293 }549 }
294- var templ_7745c5c3_Var12 string550+ var templ_7745c5c3_Var26 string
295- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows)))551+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows)))
296 if templ_7745c5c3_Err != nil {552 if templ_7745c5c3_Err != nil {
297- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109}553+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 89, Col: 109}
298 }554 }
299- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))555+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
300 if templ_7745c5c3_Err != nil {556 if templ_7745c5c3_Err != nil {
301 return templ_7745c5c3_Err557 return templ_7745c5c3_Err
302 }558 }
303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>")559+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></h2>")
304 if templ_7745c5c3_Err != nil {560 if templ_7745c5c3_Err != nil {
305 return templ_7745c5c3_Err561 return templ_7745c5c3_Err
306 }562 }
@@ -309,12 +565,12 @@ func InboxIssues(p InboxPage) templ.Component {
309 return templ_7745c5c3_Err565 return templ_7745c5c3_Err
310 }566 }
311 if len(p.IssueRows) == 0 {567 if len(p.IssueRows) == 0 {
312- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>")568+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"lede dim\">no open issues.</p>")
313 if templ_7745c5c3_Err != nil {569 if templ_7745c5c3_Err != nil {
314 return templ_7745c5c3_Err570 return templ_7745c5c3_Err
315 }571 }
316 }572 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>")573+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>")
318 if templ_7745c5c3_Err != nil {574 if templ_7745c5c3_Err != nil {
319 return templ_7745c5c3_Err575 return templ_7745c5c3_Err
320 }576 }
@@ -338,160 +594,160 @@ func inboxRows(rows []InboxRow, label string) templ.Component {
338 }()594 }()
339 }595 }
340 ctx = templ.InitializeContext(ctx)596 ctx = templ.InitializeContext(ctx)
341- templ_7745c5c3_Var13 := templ.GetChildren(ctx)597+ templ_7745c5c3_Var27 := templ.GetChildren(ctx)
342- if templ_7745c5c3_Var13 == nil {598+ if templ_7745c5c3_Var27 == nil {
343- templ_7745c5c3_Var13 = templ.NopComponent599+ templ_7745c5c3_Var27 = templ.NopComponent
344 }600 }
345 ctx = templ.ClearChildren(ctx)601 ctx = templ.ClearChildren(ctx)
346 if len(rows) > 0 {602 if len(rows) > 0 {
347- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"")603+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<table class=\"list mrs\" aria-label=\"")
348 if templ_7745c5c3_Err != nil {604 if templ_7745c5c3_Err != nil {
349 return templ_7745c5c3_Err605 return templ_7745c5c3_Err
350 }606 }
351- var templ_7745c5c3_Var14 string607+ var templ_7745c5c3_Var28 string
352- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)608+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(label)
353 if templ_7745c5c3_Err != nil {609 if templ_7745c5c3_Err != nil {
354- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44}610+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 99, Col: 44}
355 }611 }
356- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)612+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
357 if templ_7745c5c3_Err != nil {613 if templ_7745c5c3_Err != nil {
358 return templ_7745c5c3_Err614 return templ_7745c5c3_Err
359 }615 }
360- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>")616+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><tbody data-rows>")
361 if templ_7745c5c3_Err != nil {617 if templ_7745c5c3_Err != nil {
362 return templ_7745c5c3_Err618 return templ_7745c5c3_Err
363 }619 }
364 for _, m := range rows {620 for _, m := range rows {
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr")621+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr")
366 if templ_7745c5c3_Err != nil {622 if templ_7745c5c3_Err != nil {
367 return templ_7745c5c3_Err623 return templ_7745c5c3_Err
368 }624 }
369 if m.Cursor {625 if m.Cursor {
370- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor")626+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " data-cursor")
371 if templ_7745c5c3_Err != nil {627 if templ_7745c5c3_Err != nil {
372 return templ_7745c5c3_Err628 return templ_7745c5c3_Err
373 }629 }
374 }630 }
375- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">")631+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " data-state=\"open\"><td class=\"c-n mute\">")
376 if templ_7745c5c3_Err != nil {632 if templ_7745c5c3_Err != nil {
377 return templ_7745c5c3_Err633 return templ_7745c5c3_Err
378 }634 }
379- var templ_7745c5c3_Var15 string635+ var templ_7745c5c3_Var29 string
380- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)636+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number)
381 if templ_7745c5c3_Err != nil {637 if templ_7745c5c3_Err != nil {
382- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37}638+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 103, Col: 37}
383 }639 }
384- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))640+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
385 if templ_7745c5c3_Err != nil {641 if templ_7745c5c3_Err != nil {
386 return templ_7745c5c3_Err642 return templ_7745c5c3_Err
387 }643 }
388- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"")644+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-subject\"><a href=\"")
389 if templ_7745c5c3_Err != nil {645 if templ_7745c5c3_Err != nil {
390 return templ_7745c5c3_Err646 return templ_7745c5c3_Err
391 }647 }
392- var templ_7745c5c3_Var16 templ.SafeURL648+ var templ_7745c5c3_Var30 templ.SafeURL
393- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))649+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href))
394 if templ_7745c5c3_Err != nil {650 if templ_7745c5c3_Err != nil {
395- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59}651+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 59}
396 }652 }
397- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))653+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
398 if templ_7745c5c3_Err != nil {654 if templ_7745c5c3_Err != nil {
399 return templ_7745c5c3_Err655 return templ_7745c5c3_Err
400 }656 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">")657+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">")
402 if templ_7745c5c3_Err != nil {658 if templ_7745c5c3_Err != nil {
403 return templ_7745c5c3_Err659 return templ_7745c5c3_Err
404 }660 }
405- var templ_7745c5c3_Var17 string661+ var templ_7745c5c3_Var31 string
406- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)662+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title)
407 if templ_7745c5c3_Err != nil {663 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71}664+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 71}
409 }665 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))666+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
411 if templ_7745c5c3_Err != nil {667 if templ_7745c5c3_Err != nil {
412 return templ_7745c5c3_Err668 return templ_7745c5c3_Err
413 }669 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"")670+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</a> <a class=\"mute\" href=\"")
415 if templ_7745c5c3_Err != nil {671 if templ_7745c5c3_Err != nil {
416 return templ_7745c5c3_Err672 return templ_7745c5c3_Err
417 }673 }
418- var templ_7745c5c3_Var18 templ.SafeURL674+ var templ_7745c5c3_Var32 templ.SafeURL
419- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))675+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref))
420 if templ_7745c5c3_Err != nil {676 if templ_7745c5c3_Err != nil {
421- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126}677+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 126}
422 }678 }
423- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))679+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
424 if templ_7745c5c3_Err != nil {680 if templ_7745c5c3_Err != nil {
425 return templ_7745c5c3_Err681 return templ_7745c5c3_Err
426 }682 }
427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">")683+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
428 if templ_7745c5c3_Err != nil {684 if templ_7745c5c3_Err != nil {
429 return templ_7745c5c3_Err685 return templ_7745c5c3_Err
430 }686 }
431- var templ_7745c5c3_Var19 string687+ var templ_7745c5c3_Var33 string
432- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)688+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo)
433 if templ_7745c5c3_Err != nil {689 if templ_7745c5c3_Err != nil {
434- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137}690+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 137}
435 }691 }
436- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))692+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
437 if templ_7745c5c3_Err != nil {693 if templ_7745c5c3_Err != nil {
438 return templ_7745c5c3_Err694 return templ_7745c5c3_Err
439 }695 }
440- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">")696+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a></td><td class=\"c-when nowrap dim\">")
441 if templ_7745c5c3_Err != nil {697 if templ_7745c5c3_Err != nil {
442 return templ_7745c5c3_Err698 return templ_7745c5c3_Err
443 }699 }
444 if m.WhenAt != "" {700 if m.WhenAt != "" {
445- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"")701+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<time datetime=\"")
446 if templ_7745c5c3_Err != nil {702 if templ_7745c5c3_Err != nil {
447 return templ_7745c5c3_Err703 return templ_7745c5c3_Err
448 }704 }
449- var templ_7745c5c3_Var20 string705+ var templ_7745c5c3_Var34 string
450- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)706+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
451 if templ_7745c5c3_Err != nil {707 if templ_7745c5c3_Err != nil {
452- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33}708+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 33}
453 }709 }
454- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)710+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
455 if templ_7745c5c3_Err != nil {711 if templ_7745c5c3_Err != nil {
456 return templ_7745c5c3_Err712 return templ_7745c5c3_Err
457 }713 }
458- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"")714+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"")
459 if templ_7745c5c3_Err != nil {715 if templ_7745c5c3_Err != nil {
460 return templ_7745c5c3_Err716 return templ_7745c5c3_Err
461 }717 }
462- var templ_7745c5c3_Var21 string718+ var templ_7745c5c3_Var35 string
463- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)719+ templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt)
464 if templ_7745c5c3_Err != nil {720 if templ_7745c5c3_Err != nil {
465- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52}721+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 52}
466 }722 }
467- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)723+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
468 if templ_7745c5c3_Err != nil {724 if templ_7745c5c3_Err != nil {
469 return templ_7745c5c3_Err725 return templ_7745c5c3_Err
470 }726 }
471- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")727+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">")
472 if templ_7745c5c3_Err != nil {728 if templ_7745c5c3_Err != nil {
473 return templ_7745c5c3_Err729 return templ_7745c5c3_Err
474 }730 }
475- var templ_7745c5c3_Var22 string731+ var templ_7745c5c3_Var36 string
476- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)732+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(m.When)
477 if templ_7745c5c3_Err != nil {733 if templ_7745c5c3_Err != nil {
478- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63}734+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 63}
479 }735 }
480- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))736+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
481 if templ_7745c5c3_Err != nil {737 if templ_7745c5c3_Err != nil {
482 return templ_7745c5c3_Err738 return templ_7745c5c3_Err
483 }739 }
484- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>")740+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time>")
485 if templ_7745c5c3_Err != nil {741 if templ_7745c5c3_Err != nil {
486 return templ_7745c5c3_Err742 return templ_7745c5c3_Err
487 }743 }
488 }744 }
489- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>")745+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td></tr>")
490 if templ_7745c5c3_Err != nil {746 if templ_7745c5c3_Err != nil {
491 return templ_7745c5c3_Err747 return templ_7745c5c3_Err
492 }748 }
493 }749 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>")750+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>")
495 if templ_7745c5c3_Err != nil {751 if templ_7745c5c3_Err != nil {
496 return templ_7745c5c3_Err752 return templ_7745c5c3_Err
497 }753 }
M internal/adapters/web/inbox_test.go +5 -4
@@ -180,11 +180,12 @@ func TestInboxLiveFragments(t *testing.T) {
180180 ls := &liveStream{s: srv, view: "inbox"}
181181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182182 require.NoError(t, err)
183- require.Len(t, frags, 4)
183+ require.Len(t, frags, 5)
184184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186- assert.Contains(t, frags[2].html, `id="inbox-mrs"`)
187- assert.Contains(t, frags[3].html, `id="inbox-issues"`)
186+ assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
188189
189190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
190191 require.NoError(t, err)
@@ -296,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
296297 ls := &liveStream{s: srv, view: "inbox"}
297298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
298299 require.NoError(t, err)
299- require.Len(t, frags, 4)
300+ require.Len(t, frags, 5)
300301 all := ""
301302 for _, f := range frags {
302303 all += f.html
@@ -180,11 +180,12 @@ func TestInboxLiveFragments(t *testing.T) {
180 ls := &liveStream{s: srv, view: "inbox"}180 ls := &liveStream{s: srv, view: "inbox"}
181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})181 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: domain.IssueOpened{Issue: domain.Issue{Repo: repoName(t, "beta"), Number: 7}}})
182 require.NoError(t, err)182 require.NoError(t, err)
183- require.Len(t, frags, 4)183+ require.Len(t, frags, 5)
184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)184 assert.Contains(t, frags[0].html, `id="inbox-counts"`)
185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)185 assert.Contains(t, frags[1].html, `id="inbox-repos"`)
186- assert.Contains(t, frags[2].html, `id="inbox-mrs"`)186+ assert.Contains(t, frags[2].html, `id="inbox-pending"`)
187- assert.Contains(t, frags[3].html, `id="inbox-issues"`)187+ assert.Contains(t, frags[3].html, `id="inbox-mrs"`)
188+ assert.Contains(t, frags[4].html, `id="inbox-issues"`)
188 189
189 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})190 none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}})
190 require.NoError(t, err)191 require.NoError(t, err)
@@ -296,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) {
296 ls := &liveStream{s: srv, view: "inbox"}297 ls := &liveStream{s: srv, view: "inbox"}
297 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})298 frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev})
298 require.NoError(t, err)299 require.NoError(t, err)
299- require.Len(t, frags, 4)300+ require.Len(t, frags, 5)
300 all := ""301 all := ""
301 for _, f := range frags {302 for _, f := range frags {
302 all += f.html303 all += f.html
M internal/adapters/web/issues.go +1 -1
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179179 if token != "" {
180180 p.Newest = link("")
181181 }
182- if SignedIn(ctx) {
182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
183183 p.NewHref = issuesBase(d.name()) + "/new"
184184 }
185185
@@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen
179 if token != "" {179 if token != "" {
180 p.Newest = link("")180 p.Newest = link("")
181 }181 }
182- if SignedIn(ctx) {182+ if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) {
183 p.NewHref = issuesBase(d.name()) + "/new"183 p.NewHref = issuesBase(d.name()) + "/new"
184 }184 }
185 185
A internal/adapters/web/issues_form.go +276 -0
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// The public issue form (ADR 0031). A visitor of a public repository whose
22+// issues mode is public writes an issue that awaits the owner's moderation.
23+//
24+// GET /<repo>/issues/new the form, with a proof of work solved by script
25+// POST /<repo>/issues/new the submission (the one write a visitor may make)
26+// GET /<repo>/issues/submitted the thanks page
27+//
28+// Every answer is "Cache-Control: no-store". The challenge token is never
29+// echoed back, and neither the address of the client nor the text of the
30+// submission is logged. A refusal for a bot-related reason is one generic page.
31+
32+// maxIssueFormBytes bounds the body of the submission: 10,000 characters are
33+// at most 40,000 bytes, plus the title, the name and the tokens.
34+const maxIssueFormBytes = 64 << 10
35+
36+// The fields of the form, and so the only ones read.
37+const (
38+ fieldTitle = "title"
39+ fieldBody = "body"
40+ fieldAuthor = "author"
41+ fieldToken = "token"
42+ fieldFormToken = "form_token"
43+ fieldSolution = "solution"
44+ fieldHoneypot = "website"
45+)
46+
47+var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48+
49+// IssueFormPage is the public form.
50+type IssueFormPage struct {
51+ Action, Cancel string
52+ Token string
53+ FormToken string
54+ Nonce string
55+ Difficulty int
56+ MinSubmitAt int64 // unix milliseconds
57+ Worker string
58+ // Title, Body and Author are what the visitor already wrote.
59+ Title, Body, Author string
60+ // ErrorField is "title", "body" or "author" and Error the message to show
61+ // next to it; both empty without an error.
62+ ErrorField, Error string
63+ // Max lengths, in characters.
64+ MaxTitle, MaxBody, MaxAuthor int
65+}
66+
67+// issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in,
68+// else the public form, else the page that is not there.
69+func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) {
70+ if SignedIn(r.Context()) || s.cfg.IssueForm == nil {
71+ s.page(s.issueNew)(w, r)
72+ return
73+ }
74+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
75+ defer cancel()
76+ name, err := domain.ParseRepoName(r.PathValue("repo"))
77+ if err != nil {
78+ s.fail(ctx, w, r, err)
79+ return
80+ }
81+ page := IssueFormPage{}
82+ status, err := s.fillIssueForm(ctx, r, name, &page)
83+ if err != nil {
84+ s.formFailed(ctx, w, r, err, page.Cancel)
85+ return
86+ }
87+ s.writeIssueForm(ctx, w, r, status, name, page)
88+}
89+
90+// fillIssueForm asks a challenge for the client and fills what the form needs
91+// from it, leaving the text of the visitor as it is.
92+func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) {
93+ base := issuesBase(name.String())
94+ p.Cancel = base
95+ ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r))
96+ if err != nil {
97+ return 0, err
98+ }
99+ p.Action = base + "/new"
100+ p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty
101+ p.MinSubmitAt = ch.MinSubmitAt.UnixMilli()
102+ p.Worker = s.assets.urls.PowWorker
103+ p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen
104+ return http.StatusOK, nil
105+}
106+
107+// formClient is the key of the client for the limits of the submissions.
108+func (s *Server) formClient(r *http.Request) string {
109+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
110+}
111+
112+// writeIssueForm renders the form in the layout of the issues of the repository.
113+func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) {
114+ d, err := s.issueRepo(ctx, name.String())
115+ if err != nil {
116+ s.fail(ctx, w, r, err)
117+ return
118+ }
119+ l := s.repoLayout(ctx, d, "issues")
120+ l.Title = "new issue · " + d.name() + " · " + s.instance
121+ l.Crumbs[0].Current = false
122+ l.Crumbs = append(l.Crumbs,
123+ Crumb{Label: "issues", Href: issuesBase(d.name())},
124+ Crumb{Label: "new", Current: true},
125+ )
126+ l.HasRail = true
127+ l.IssueForm = true
128+ w.Header().Set("Cache-Control", noStore)
129+ s.write(ctx, w, r, status, IssueForm(l, p))
130+}
131+
132+// formFailed answers an error of the challenge or of the submission.
133+func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
134+ var retry *domain.SubmissionRetryError
135+ switch {
136+ case errors.Is(err, context.Canceled):
137+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText):
138+ // The same page, with the same headers, as every other page that is
139+ // not there (a POST has set no-store already).
140+ s.fail(ctx, w, r, domain.ErrNotFound)
141+ case errors.As(err, &retry):
142+ secs := int(retry.After.Round(time.Second).Seconds())
143+ if retry.After > time.Duration(secs)*time.Second {
144+ secs++
145+ }
146+ secs = max(secs, 1)
147+ w.Header().Set("Retry-After", strconv.Itoa(secs))
148+ mins := (secs + 59) / 60
149+ unit := " minutes."
150+ if mins == 1 {
151+ unit = " minute."
152+ }
153+ s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions",
154+ "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back)
155+ case errors.Is(err, domain.ErrSubmissionsPaused):
156+ s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back)
157+ case errors.Is(err, domain.ErrSubmissionRefused):
158+ s.refusal(ctx, w, r, http.StatusBadRequest, "not verified",
159+ "The submission could not be verified. Reload the page and try again.", back)
160+ default:
161+ s.internalFailure(ctx, w, r, err)
162+ }
163+}
164+
165+// refusal writes a state page for a visitor, never cached.
166+func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
167+ if back == "" {
168+ back = "/"
169+ }
170+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
171+ w.Header().Set("Cache-Control", noStore)
172+ s.write(ctx, w, r, status, State(l, StatePage{
173+ Code: itoa(status), Title: title, Lines: []string{line},
174+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
175+ }))
176+}
177+
178+// issueFormPost is POST /<repo>/issues/new.
179+func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) {
180+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181+ defer cancel()
182+ w.Header().Set("Cache-Control", noStore)
183+ name, err := domain.ParseRepoName(r.PathValue("repo"))
184+ if err != nil {
185+ s.formFailed(ctx, w, r, err, "")
186+ return
187+ }
188+ base := issuesBase(name.String())
189+ r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes)
190+ if err := r.ParseForm(); err != nil {
191+ var tooBig *http.MaxBytesError
192+ if errors.As(err, &tooBig) {
193+ s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new")
194+ return
195+ }
196+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
197+ return
198+ }
199+ for _, f := range issueFormFields {
200+ if len(r.PostForm[f]) > 1 {
201+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
202+ return
203+ }
204+ }
205+ in := ports.IssueSubmission{
206+ Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution),
207+ FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot),
208+ Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)),
209+ Author: r.PostForm.Get(fieldAuthor),
210+ }
211+ err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in)
212+ if err == nil {
213+ seeOther(w, r, base+"/submitted")
214+ return
215+ }
216+ var te *domain.TextError
217+ if errors.As(err, &te) {
218+ s.formAgain(ctx, w, r, name, in, err, te)
219+ return
220+ }
221+ s.formFailed(ctx, w, r, err, base+"/new")
222+}
223+
224+// formAgain shows the form again after a text the visitor can fix: a new
225+// challenge (the first one is spent), the text kept, the message at its field.
226+func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) {
227+ p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author}
228+ field, _, _ := strings.Cut(err.Error(), ": ")
229+ switch field {
230+ case fieldTitle, fieldBody, fieldAuthor:
231+ default:
232+ field = fieldBody
233+ }
234+ label := field
235+ if field == fieldAuthor {
236+ label = "name"
237+ }
238+ p.ErrorField = field
239+ p.Error = "The " + label + " " + visibleText(te.Reason) + "."
240+ if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil {
241+ s.formFailed(ctx, w, r, cerr, p.Cancel)
242+ return
243+ }
244+ s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p)
245+}
246+
247+// issueSubmittedPage is GET /<repo>/issues/submitted.
248+func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250+ defer cancel()
251+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
252+ if err != nil {
253+ s.fail(ctx, w, r, err)
254+ return
255+ }
256+ if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
257+ s.fail(ctx, w, r, errNotFound)
258+ return
259+ }
260+ w.Header().Set("Cache-Control", noStore)
261+ s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
262+}
263+
264+func (s *Server) submittedPage(repo string) templ.Component {
265+ l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
266+ return State(l, StatePage{
267+ Code: "ok", Title: "thanks",
268+ Lines: []string{"Thanks, your issue is waiting for moderation."},
269+ Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
270+ })
271+}
272+
273+func i64(n int64) string { return strconv.FormatInt(n, 10) }
274+
275+// runeCount is the length of s in characters, like the limits of the form.
276+func runeCount(s string) int { return utf8.RuneCountInString(s) }
@@ -0,0 +1,276 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/a-h/templ"
15+
16+ "bnema.dev/ermite/internal/clientlimit"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+// The public issue form (ADR 0031). A visitor of a public repository whose
22+// issues mode is public writes an issue that awaits the owner's moderation.
23+//
24+// GET /<repo>/issues/new the form, with a proof of work solved by script
25+// POST /<repo>/issues/new the submission (the one write a visitor may make)
26+// GET /<repo>/issues/submitted the thanks page
27+//
28+// Every answer is "Cache-Control: no-store". The challenge token is never
29+// echoed back, and neither the address of the client nor the text of the
30+// submission is logged. A refusal for a bot-related reason is one generic page.
31+
32+// maxIssueFormBytes bounds the body of the submission: 10,000 characters are
33+// at most 40,000 bytes, plus the title, the name and the tokens.
34+const maxIssueFormBytes = 64 << 10
35+
36+// The fields of the form, and so the only ones read.
37+const (
38+ fieldTitle = "title"
39+ fieldBody = "body"
40+ fieldAuthor = "author"
41+ fieldToken = "token"
42+ fieldFormToken = "form_token"
43+ fieldSolution = "solution"
44+ fieldHoneypot = "website"
45+)
46+
47+var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48+
49+// IssueFormPage is the public form.
50+type IssueFormPage struct {
51+ Action, Cancel string
52+ Token string
53+ FormToken string
54+ Nonce string
55+ Difficulty int
56+ MinSubmitAt int64 // unix milliseconds
57+ Worker string
58+ // Title, Body and Author are what the visitor already wrote.
59+ Title, Body, Author string
60+ // ErrorField is "title", "body" or "author" and Error the message to show
61+ // next to it; both empty without an error.
62+ ErrorField, Error string
63+ // Max lengths, in characters.
64+ MaxTitle, MaxBody, MaxAuthor int
65+}
66+
67+// issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in,
68+// else the public form, else the page that is not there.
69+func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) {
70+ if SignedIn(r.Context()) || s.cfg.IssueForm == nil {
71+ s.page(s.issueNew)(w, r)
72+ return
73+ }
74+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
75+ defer cancel()
76+ name, err := domain.ParseRepoName(r.PathValue("repo"))
77+ if err != nil {
78+ s.fail(ctx, w, r, err)
79+ return
80+ }
81+ page := IssueFormPage{}
82+ status, err := s.fillIssueForm(ctx, r, name, &page)
83+ if err != nil {
84+ s.formFailed(ctx, w, r, err, page.Cancel)
85+ return
86+ }
87+ s.writeIssueForm(ctx, w, r, status, name, page)
88+}
89+
90+// fillIssueForm asks a challenge for the client and fills what the form needs
91+// from it, leaving the text of the visitor as it is.
92+func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) {
93+ base := issuesBase(name.String())
94+ p.Cancel = base
95+ ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r))
96+ if err != nil {
97+ return 0, err
98+ }
99+ p.Action = base + "/new"
100+ p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty
101+ p.MinSubmitAt = ch.MinSubmitAt.UnixMilli()
102+ p.Worker = s.assets.urls.PowWorker
103+ p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen
104+ return http.StatusOK, nil
105+}
106+
107+// formClient is the key of the client for the limits of the submissions.
108+func (s *Server) formClient(r *http.Request) string {
109+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
110+}
111+
112+// writeIssueForm renders the form in the layout of the issues of the repository.
113+func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) {
114+ d, err := s.issueRepo(ctx, name.String())
115+ if err != nil {
116+ s.fail(ctx, w, r, err)
117+ return
118+ }
119+ l := s.repoLayout(ctx, d, "issues")
120+ l.Title = "new issue · " + d.name() + " · " + s.instance
121+ l.Crumbs[0].Current = false
122+ l.Crumbs = append(l.Crumbs,
123+ Crumb{Label: "issues", Href: issuesBase(d.name())},
124+ Crumb{Label: "new", Current: true},
125+ )
126+ l.HasRail = true
127+ l.IssueForm = true
128+ w.Header().Set("Cache-Control", noStore)
129+ s.write(ctx, w, r, status, IssueForm(l, p))
130+}
131+
132+// formFailed answers an error of the challenge or of the submission.
133+func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
134+ var retry *domain.SubmissionRetryError
135+ switch {
136+ case errors.Is(err, context.Canceled):
137+ case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText):
138+ // The same page, with the same headers, as every other page that is
139+ // not there (a POST has set no-store already).
140+ s.fail(ctx, w, r, domain.ErrNotFound)
141+ case errors.As(err, &retry):
142+ secs := int(retry.After.Round(time.Second).Seconds())
143+ if retry.After > time.Duration(secs)*time.Second {
144+ secs++
145+ }
146+ secs = max(secs, 1)
147+ w.Header().Set("Retry-After", strconv.Itoa(secs))
148+ mins := (secs + 59) / 60
149+ unit := " minutes."
150+ if mins == 1 {
151+ unit = " minute."
152+ }
153+ s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions",
154+ "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back)
155+ case errors.Is(err, domain.ErrSubmissionsPaused):
156+ s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back)
157+ case errors.Is(err, domain.ErrSubmissionRefused):
158+ s.refusal(ctx, w, r, http.StatusBadRequest, "not verified",
159+ "The submission could not be verified. Reload the page and try again.", back)
160+ default:
161+ s.internalFailure(ctx, w, r, err)
162+ }
163+}
164+
165+// refusal writes a state page for a visitor, never cached.
166+func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
167+ if back == "" {
168+ back = "/"
169+ }
170+ l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
171+ w.Header().Set("Cache-Control", noStore)
172+ s.write(ctx, w, r, status, State(l, StatePage{
173+ Code: itoa(status), Title: title, Lines: []string{line},
174+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
175+ }))
176+}
177+
178+// issueFormPost is POST /<repo>/issues/new.
179+func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) {
180+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
181+ defer cancel()
182+ w.Header().Set("Cache-Control", noStore)
183+ name, err := domain.ParseRepoName(r.PathValue("repo"))
184+ if err != nil {
185+ s.formFailed(ctx, w, r, err, "")
186+ return
187+ }
188+ base := issuesBase(name.String())
189+ r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes)
190+ if err := r.ParseForm(); err != nil {
191+ var tooBig *http.MaxBytesError
192+ if errors.As(err, &tooBig) {
193+ s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new")
194+ return
195+ }
196+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
197+ return
198+ }
199+ for _, f := range issueFormFields {
200+ if len(r.PostForm[f]) > 1 {
201+ s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new")
202+ return
203+ }
204+ }
205+ in := ports.IssueSubmission{
206+ Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution),
207+ FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot),
208+ Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)),
209+ Author: r.PostForm.Get(fieldAuthor),
210+ }
211+ err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in)
212+ if err == nil {
213+ seeOther(w, r, base+"/submitted")
214+ return
215+ }
216+ var te *domain.TextError
217+ if errors.As(err, &te) {
218+ s.formAgain(ctx, w, r, name, in, err, te)
219+ return
220+ }
221+ s.formFailed(ctx, w, r, err, base+"/new")
222+}
223+
224+// formAgain shows the form again after a text the visitor can fix: a new
225+// challenge (the first one is spent), the text kept, the message at its field.
226+func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) {
227+ p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author}
228+ field, _, _ := strings.Cut(err.Error(), ": ")
229+ switch field {
230+ case fieldTitle, fieldBody, fieldAuthor:
231+ default:
232+ field = fieldBody
233+ }
234+ label := field
235+ if field == fieldAuthor {
236+ label = "name"
237+ }
238+ p.ErrorField = field
239+ p.Error = "The " + label + " " + visibleText(te.Reason) + "."
240+ if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil {
241+ s.formFailed(ctx, w, r, cerr, p.Cancel)
242+ return
243+ }
244+ s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p)
245+}
246+
247+// issueSubmittedPage is GET /<repo>/issues/submitted.
248+func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
249+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
250+ defer cancel()
251+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
252+ if err != nil {
253+ s.fail(ctx, w, r, err)
254+ return
255+ }
256+ if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
257+ s.fail(ctx, w, r, errNotFound)
258+ return
259+ }
260+ w.Header().Set("Cache-Control", noStore)
261+ s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
262+}
263+
264+func (s *Server) submittedPage(repo string) templ.Component {
265+ l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
266+ return State(l, StatePage{
267+ Code: "ok", Title: "thanks",
268+ Lines: []string{"Thanks, your issue is waiting for moderation."},
269+ Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
270+ })
271+}
272+
273+func i64(n int64) string { return strconv.FormatInt(n, 10) }
274+
275+// runeCount is the length of s in characters, like the limits of the form.
276+func runeCount(s string) int { return utf8.RuneCountInString(s) }
A internal/adapters/web/issues_form.templ +67 -0
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueForm is the public form that proposes an issue to the owner. It needs
6+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
7+// so the form stays hidden until it runs, and the page says why without it.
8+templ IssueForm(l Layout, p IssueFormPage) {
9+ @Shell(l, issueFormRail()) {
10+ <header class="show mr-h">
11+ <h1>New issue</h1>
12+ </header>
13+ <noscript>
14+ <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p>
15+ </noscript>
16+ <form
17+ class="cform create"
18+ method="post"
19+ action={ templ.SafeURL(p.Action) }
20+ hidden
21+ data-issue-form
22+ data-nonce={ p.Nonce }
23+ data-difficulty={ itoa(p.Difficulty) }
24+ data-min-submit-at={ i64(p.MinSubmitAt) }
25+ data-worker={ p.Worker }
26+ autocomplete="off"
27+ >
28+ <input type="hidden" name="token" value={ p.Token }/>
29+ <input type="hidden" name="form_token" value={ p.FormToken }/>
30+ <input type="hidden" name="solution" value=""/>
31+ <label for="issue-title">title</label>
32+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/>
33+ <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34+ if p.ErrorField == "title" {
35+ <p class="f-error" role="alert">{ p.Error }</p>
36+ }
37+ <label for="issue-body">description</label>
38+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea>
39+ <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40+ if p.ErrorField == "body" {
41+ <p class="f-error" role="alert">{ p.Error }</p>
42+ }
43+ <label for="issue-author">name (optional, not verified)</label>
44+ <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/>
45+ <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46+ if p.ErrorField == "author" {
47+ <p class="f-error" role="alert">{ p.Error }</p>
48+ }
49+ <div class="hp" aria-hidden="true">
50+ <label for="issue-website">Leave this field empty</label>
51+ <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/>
52+ </div>
53+ <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p>
54+ <div class="f-btns">
55+ <button type="submit" class="btn primary" disabled>Submit for moderation</button>
56+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
57+ </div>
58+ </form>
59+ }
60+}
61+
62+templ issueFormRail() {
63+ <section aria-labelledby="r-about">
64+ <h2 id="r-about">Before you write</h2>
65+ <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66+ </section>
67+}
@@ -0,0 +1,67 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueForm is the public form that proposes an issue to the owner. It needs
6+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
7+// so the form stays hidden until it runs, and the page says why without it.
8+templ IssueForm(l Layout, p IssueFormPage) {
9+ @Shell(l, issueFormRail()) {
10+ <header class="show mr-h">
11+ <h1>New issue</h1>
12+ </header>
13+ <noscript>
14+ <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p>
15+ </noscript>
16+ <form
17+ class="cform create"
18+ method="post"
19+ action={ templ.SafeURL(p.Action) }
20+ hidden
21+ data-issue-form
22+ data-nonce={ p.Nonce }
23+ data-difficulty={ itoa(p.Difficulty) }
24+ data-min-submit-at={ i64(p.MinSubmitAt) }
25+ data-worker={ p.Worker }
26+ autocomplete="off"
27+ >
28+ <input type="hidden" name="token" value={ p.Token }/>
29+ <input type="hidden" name="form_token" value={ p.FormToken }/>
30+ <input type="hidden" name="solution" value=""/>
31+ <label for="issue-title">title</label>
32+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/>
33+ <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output>
34+ if p.ErrorField == "title" {
35+ <p class="f-error" role="alert">{ p.Error }</p>
36+ }
37+ <label for="issue-body">description</label>
38+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea>
39+ <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output>
40+ if p.ErrorField == "body" {
41+ <p class="f-error" role="alert">{ p.Error }</p>
42+ }
43+ <label for="issue-author">name (optional, not verified)</label>
44+ <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/>
45+ <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output>
46+ if p.ErrorField == "author" {
47+ <p class="f-error" role="alert">{ p.Error }</p>
48+ }
49+ <div class="hp" aria-hidden="true">
50+ <label for="issue-website">Leave this field empty</label>
51+ <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/>
52+ </div>
53+ <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p>
54+ <div class="f-btns">
55+ <button type="submit" class="btn primary" disabled>Submit for moderation</button>
56+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
57+ </div>
58+ </form>
59+ }
60+}
61+
62+templ issueFormRail() {
63+ <section aria-labelledby="r-about">
64+ <h2 id="r-about">Before you write</h2>
65+ <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p>
66+ </section>
67+}
A internal/adapters/web/issues_form_templ.go +421 -0 generated file: collapsed
A internal/adapters/web/issues_form_test.go +322 -0
@@ -0,0 +1,322 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+type formRig struct {
24+ *issueRig
25+ form *mocks.IssueSubmissionService
26+}
27+
28+func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29+ t.Helper()
30+ r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31+ browse := mocks.NewBrowseService(t)
32+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34+ r.srv = newTestServer(t, Config{
35+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36+ IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37+ })
38+ return r
39+}
40+
41+func challenge(n int) ports.IssueChallenge {
42+ return ports.IssueChallenge{
43+ Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44+ FormToken: fmt.Sprintf("form-%d", n),
45+ IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46+ ExpiresAt: time.UnixMilli(1_700_000_600_000),
47+ }
48+}
49+
50+// post sends a submission as a browser of this site would.
51+func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ rec := httptest.NewRecorder()
58+ r.srv.ServeHTTP(rec, req)
59+ return rec
60+}
61+
62+var fromSite = map[string]string{"Origin": testPublicURL}
63+
64+func goodForm() url.Values {
65+ return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66+}
67+
68+func TestIssueFormGet(t *testing.T) {
69+ t.Parallel()
70+ r := newFormRig(t, domain.IssuesPublic)
71+ r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72+
73+ rec := r.do("GET", "/proj/issues/new", false, nil)
74+ require.Equal(t, 200, rec.Code, rec.Body.String())
75+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76+ body := rec.Body.String()
77+ for _, want := range []string{
78+ `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79+ `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80+ `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81+ `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82+ `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`,
83+ `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84+ `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85+ `/-/static/js/issue-form.`,
86+ } {
87+ assert.Contains(t, body, want)
88+ }
89+ assert.NotContains(t, body, `name="website" type="hidden"`)
90+ assert.NotContains(t, body, `type="hidden" name="website"`)
91+ noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
92+ assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
93+ assert.NotContains(t, body, "style=", "no inline style")
94+ for _, tag := range strings.Split(body, "<script")[1:] {
95+ head, _, _ := strings.Cut(tag, ">")
96+ assert.Contains(t, head, `src="`, "no inline script")
97+ }
98+}
99+
100+func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
101+ t.Parallel()
102+ want := ""
103+ for i, tc := range []struct {
104+ name string
105+ mode domain.IssuesMode
106+ err error
107+ }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
108+ r := newFormRig(t, tc.mode)
109+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
110+ path := "/proj/issues/new"
111+ if tc.name == "unknown" {
112+ path = "/nope/issues/new"
113+ }
114+ rec := r.do("GET", path, false, nil)
115+ assert.Equal(t, 404, rec.Code, tc.name)
116+ if i == 0 {
117+ want = rec.Body.String()
118+ continue
119+ }
120+ assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
121+ }
122+}
123+
124+func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
125+ t.Parallel()
126+ r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
127+ rec := r.do("GET", "/proj/issues/new", true, nil)
128+ require.Equal(t, 200, rec.Code)
129+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
130+ assert.NotContains(t, rec.Body.String(), "data-issue-form")
131+}
132+
133+func TestIssueFormGetPausedAndLimited(t *testing.T) {
134+ t.Parallel()
135+ r := newFormRig(t, domain.IssuesPublic)
136+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
137+ rec := r.do("GET", "/proj/issues/new", false, nil)
138+ assert.Equal(t, 503, rec.Code)
139+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
140+ assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
141+}
142+
143+func TestIssueFormPostOutcomes(t *testing.T) {
144+ t.Parallel()
145+ generic := "The submission could not be verified. Reload the page and try again."
146+ tests := []struct {
147+ name string
148+ err error
149+ status int
150+ body string
151+ hdr map[string]string
152+ }{
153+ {"not found", domain.ErrNotFound, 404, "no such page", nil},
154+ {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
155+ {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
156+ {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
157+ {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
158+ {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
159+ }
160+ for _, tc := range tests {
161+ t.Run(tc.name, func(t *testing.T) {
162+ t.Parallel()
163+ r := newFormRig(t, domain.IssuesPublic)
164+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
165+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
166+ assert.Equal(t, tc.status, rec.Code, rec.Body.String())
167+ assert.Contains(t, rec.Body.String(), tc.body)
168+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
169+ for k, v := range tc.hdr {
170+ assert.Equal(t, v, rec.Header().Get(k))
171+ }
172+ assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
173+ for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
174+ assert.NotContains(t, rec.Body.String(), leak, "no detail")
175+ }
176+ })
177+ }
178+}
179+
180+func TestIssueFormPostSuccess(t *testing.T) {
181+ t.Parallel()
182+ r := newFormRig(t, domain.IssuesPublic)
183+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
184+ Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
185+ }).Return(nil).Once()
186+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
187+ assert.Equal(t, 303, rec.Code)
188+ assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
189+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
190+
191+ thanks := r.do("GET", "/proj/issues/submitted", false, nil)
192+ assert.Equal(t, 200, thanks.Code)
193+ assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
194+ assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
195+}
196+
197+func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
198+ t.Parallel()
199+ r := newFormRig(t, domain.IssuesOwner)
200+ assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
201+}
202+
203+func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
204+ t.Parallel()
205+ r := newFormRig(t, domain.IssuesPublic)
206+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
207+ Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
208+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
209+ f := goodForm()
210+ f.Set("title", "<b>mine</b>")
211+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
212+ require.Equal(t, 422, rec.Code, rec.Body.String())
213+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
214+ body := rec.Body.String()
215+ assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
216+ assert.Contains(t, body, "line1\nline2", "the body is kept")
217+ assert.Contains(t, body, `value="Alex"`)
218+ assert.Contains(t, body, "The title is empty.")
219+ assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
220+ assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
221+}
222+
223+func TestIssueFormPostLimits(t *testing.T) {
224+ t.Parallel()
225+ t.Run("oversize", func(t *testing.T) {
226+ t.Parallel()
227+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
228+ big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
229+ rec := r.post("/proj/issues/new", fromSite, big.Encode())
230+ assert.Equal(t, 413, rec.Code)
231+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
232+ })
233+ t.Run("duplicated fields", func(t *testing.T) {
234+ t.Parallel()
235+ for _, f := range issueFormFields {
236+ r := newFormRig(t, domain.IssuesPublic)
237+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
238+ assert.Equal(t, 400, rec.Code, f)
239+ assert.Contains(t, rec.Body.String(), "could not be verified", f)
240+ }
241+ })
242+}
243+
244+// The gate still demands the Origin of this site for the anonymous POST.
245+func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
246+ t.Parallel()
247+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
248+ for name, hdr := range map[string]map[string]string{
249+ "missing": nil,
250+ "cross origin": {"Origin": "https://evil.example"},
251+ "null": {"Origin": "null"},
252+ "same host, other scheme": {"Origin": "http://forge.example"},
253+ } {
254+ rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
255+ assert.Equal(t, 403, rec.Code, name)
256+ }
257+}
258+
259+func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
260+ t.Parallel()
261+ r := newFormRig(t, domain.IssuesOwner)
262+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
263+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
264+ assert.Equal(t, 404, rec.Code)
265+}
266+
267+// anonymousPost opens exactly /<repo>/issues/new and nothing else.
268+func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
269+ t.Parallel()
270+ r := newFormRig(t, domain.IssuesPublic)
271+ for _, p := range []string{
272+ "/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
273+ "/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
274+ "/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
275+ "/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
276+ } {
277+ u := &url.URL{Path: p}
278+ if dec, err := url.PathUnescape(p); err == nil {
279+ u = &url.URL{Path: dec, RawPath: p}
280+ }
281+ req := &http.Request{Method: http.MethodPost, URL: u}
282+ got := r.srv.anonymousPost(req)
283+ want := false
284+ assert.Equal(t, want, got, p)
285+ }
286+ for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
287+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
288+ assert.True(t, r.srv.anonymousPost(req), p)
289+ req.Method = http.MethodPut
290+ assert.False(t, r.srv.anonymousPost(req), p+" PUT")
291+ }
292+ // Without the use case the path is not opened at all.
293+ plain := newIssueRig(t, domain.IssuesPublic, false)
294+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
295+ assert.False(t, plain.srv.anonymousPost(req))
296+}
297+
298+func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
299+ t.Parallel()
300+ for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
301+ r := newFormRig(t, mode)
302+ r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
303+ rec := r.do("GET", "/proj/issues", false, nil)
304+ require.Equal(t, 200, rec.Code)
305+ assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
306+ }
307+}
308+
309+func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
310+ t.Parallel()
311+ r := newFormRig(t, domain.IssuesPublic)
312+ i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
313+ i.External, i.Author = true, "Eve <b>"
314+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
315+ rec := r.do("GET", "/proj/issues/5", false, nil)
316+ require.Equal(t, 200, rec.Code)
317+ body := rec.Body.String()
318+ assert.NotContains(t, body, "javascript:")
319+ assert.NotContains(t, body, "<script>x")
320+ assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
321+ assert.Contains(t, body, "Eve &lt;b&gt;")
322+}
@@ -0,0 +1,322 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "fmt"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/url"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/mock"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+type formRig struct {
24+ *issueRig
25+ form *mocks.IssueSubmissionService
26+}
27+
28+func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig {
29+ t.Helper()
30+ r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)}
31+ browse := mocks.NewBrowseService(t)
32+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
33+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
34+ r.srv = newTestServer(t, Config{
35+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
36+ IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
37+ })
38+ return r
39+}
40+
41+func challenge(n int) ports.IssueChallenge {
42+ return ports.IssueChallenge{
43+ Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18,
44+ FormToken: fmt.Sprintf("form-%d", n),
45+ IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000),
46+ ExpiresAt: time.UnixMilli(1_700_000_600_000),
47+ }
48+}
49+
50+// post sends a submission as a browser of this site would.
51+func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder {
52+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
53+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
54+ for k, v := range hdr {
55+ req.Header.Set(k, v)
56+ }
57+ rec := httptest.NewRecorder()
58+ r.srv.ServeHTTP(rec, req)
59+ return rec
60+}
61+
62+var fromSite = map[string]string{"Origin": testPublicURL}
63+
64+func goodForm() url.Values {
65+ return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}}
66+}
67+
68+func TestIssueFormGet(t *testing.T) {
69+ t.Parallel()
70+ r := newFormRig(t, domain.IssuesPublic)
71+ r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once()
72+
73+ rec := r.do("GET", "/proj/issues/new", false, nil)
74+ require.Equal(t, 200, rec.Code, rec.Body.String())
75+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
76+ body := rec.Body.String()
77+ for _, want := range []string{
78+ `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.",
79+ `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`,
80+ `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`,
81+ `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`,
82+ `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`,
83+ `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`,
84+ `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`,
85+ `/-/static/js/issue-form.`,
86+ } {
87+ assert.Contains(t, body, want)
88+ }
89+ assert.NotContains(t, body, `name="website" type="hidden"`)
90+ assert.NotContains(t, body, `type="hidden" name="website"`)
91+ noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")]
92+ assert.NotContains(t, noscript, "<form", "no usable form without JavaScript")
93+ assert.NotContains(t, body, "style=", "no inline style")
94+ for _, tag := range strings.Split(body, "<script")[1:] {
95+ head, _, _ := strings.Cut(tag, ">")
96+ assert.Contains(t, head, `src="`, "no inline script")
97+ }
98+}
99+
100+func TestIssueFormIsNotThereUnlessPublic(t *testing.T) {
101+ t.Parallel()
102+ want := ""
103+ for i, tc := range []struct {
104+ name string
105+ mode domain.IssuesMode
106+ err error
107+ }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} {
108+ r := newFormRig(t, tc.mode)
109+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe()
110+ path := "/proj/issues/new"
111+ if tc.name == "unknown" {
112+ path = "/nope/issues/new"
113+ }
114+ rec := r.do("GET", path, false, nil)
115+ assert.Equal(t, 404, rec.Code, tc.name)
116+ if i == 0 {
117+ want = rec.Body.String()
118+ continue
119+ }
120+ assert.Equal(t, want, rec.Body.String(), tc.name+": the same page")
121+ }
122+}
123+
124+func TestIssueFormOwnerKeepsHisForm(t *testing.T) {
125+ t.Parallel()
126+ r := newFormRig(t, domain.IssuesPublic) // no Challenge expected
127+ rec := r.do("GET", "/proj/issues/new", true, nil)
128+ require.Equal(t, 200, rec.Code)
129+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
130+ assert.NotContains(t, rec.Body.String(), "data-issue-form")
131+}
132+
133+func TestIssueFormGetPausedAndLimited(t *testing.T) {
134+ t.Parallel()
135+ r := newFormRig(t, domain.IssuesPublic)
136+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once()
137+ rec := r.do("GET", "/proj/issues/new", false, nil)
138+ assert.Equal(t, 503, rec.Code)
139+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
140+ assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.")
141+}
142+
143+func TestIssueFormPostOutcomes(t *testing.T) {
144+ t.Parallel()
145+ generic := "The submission could not be verified. Reload the page and try again."
146+ tests := []struct {
147+ name string
148+ err error
149+ status int
150+ body string
151+ hdr map[string]string
152+ }{
153+ {"not found", domain.ErrNotFound, 404, "no such page", nil},
154+ {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}},
155+ {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}},
156+ {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil},
157+ {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil},
158+ {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil},
159+ }
160+ for _, tc := range tests {
161+ t.Run(tc.name, func(t *testing.T) {
162+ t.Parallel()
163+ r := newFormRig(t, domain.IssuesPublic)
164+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once()
165+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
166+ assert.Equal(t, tc.status, rec.Code, rec.Body.String())
167+ assert.Contains(t, rec.Body.String(), tc.body)
168+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
169+ for k, v := range tc.hdr {
170+ assert.Equal(t, v, rec.Header().Get(k))
171+ }
172+ assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed")
173+ for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} {
174+ assert.NotContains(t, rec.Body.String(), leak, "no detail")
175+ }
176+ })
177+ }
178+}
179+
180+func TestIssueFormPostSuccess(t *testing.T) {
181+ t.Parallel()
182+ r := newFormRig(t, domain.IssuesPublic)
183+ r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{
184+ Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex",
185+ }).Return(nil).Once()
186+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
187+ assert.Equal(t, 303, rec.Code)
188+ assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location"))
189+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
190+
191+ thanks := r.do("GET", "/proj/issues/submitted", false, nil)
192+ assert.Equal(t, 200, thanks.Code)
193+ assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control"))
194+ assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.")
195+}
196+
197+func TestIssueSubmittedPageOnlyForPublic(t *testing.T) {
198+ t.Parallel()
199+ r := newFormRig(t, domain.IssuesOwner)
200+ assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code)
201+}
202+
203+func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) {
204+ t.Parallel()
205+ r := newFormRig(t, domain.IssuesPublic)
206+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).
207+ Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once()
208+ r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once()
209+ f := goodForm()
210+ f.Set("title", "<b>mine</b>")
211+ rec := r.post("/proj/issues/new", fromSite, f.Encode())
212+ require.Equal(t, 422, rec.Code, rec.Body.String())
213+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
214+ body := rec.Body.String()
215+ assert.Contains(t, body, "&lt;b&gt;mine&lt;/b&gt;", "the title is kept")
216+ assert.Contains(t, body, "line1\nline2", "the body is kept")
217+ assert.Contains(t, body, `value="Alex"`)
218+ assert.Contains(t, body, "The title is empty.")
219+ assert.Contains(t, body, `name="token" value="tok-2.sig"`, "a new challenge")
220+ assert.NotContains(t, body, "tok-1.sig", "the spent token is not echoed")
221+}
222+
223+func TestIssueFormPostLimits(t *testing.T) {
224+ t.Parallel()
225+ t.Run("oversize", func(t *testing.T) {
226+ t.Parallel()
227+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
228+ big := url.Values{"title": {"x"}, "body": {strings.Repeat("a", maxIssueFormBytes+10)}}
229+ rec := r.post("/proj/issues/new", fromSite, big.Encode())
230+ assert.Equal(t, 413, rec.Code)
231+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
232+ })
233+ t.Run("duplicated fields", func(t *testing.T) {
234+ t.Parallel()
235+ for _, f := range issueFormFields {
236+ r := newFormRig(t, domain.IssuesPublic)
237+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()+"&"+f+"=again")
238+ assert.Equal(t, 400, rec.Code, f)
239+ assert.Contains(t, rec.Body.String(), "could not be verified", f)
240+ }
241+ })
242+}
243+
244+// The gate still demands the Origin of this site for the anonymous POST.
245+func TestIssueFormPostNeedsTheOrigin(t *testing.T) {
246+ t.Parallel()
247+ r := newFormRig(t, domain.IssuesPublic) // Submit is never called
248+ for name, hdr := range map[string]map[string]string{
249+ "missing": nil,
250+ "cross origin": {"Origin": "https://evil.example"},
251+ "null": {"Origin": "null"},
252+ "same host, other scheme": {"Origin": "http://forge.example"},
253+ } {
254+ rec := r.post("/proj/issues/new", hdr, goodForm().Encode())
255+ assert.Equal(t, 403, rec.Code, name)
256+ }
257+}
258+
259+func TestIssueFormPostOnOwnerModeIsNotFound(t *testing.T) {
260+ t.Parallel()
261+ r := newFormRig(t, domain.IssuesOwner)
262+ r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrNotFound).Once()
263+ rec := r.post("/proj/issues/new", fromSite, goodForm().Encode())
264+ assert.Equal(t, 404, rec.Code)
265+}
266+
267+// anonymousPost opens exactly /<repo>/issues/new and nothing else.
268+func TestAnonymousPostOpensOnlyTheIssueForm(t *testing.T) {
269+ t.Parallel()
270+ r := newFormRig(t, domain.IssuesPublic)
271+ for _, p := range []string{
272+ "/x/issues/new/extra", "/x/issues/123", "/x/issues", "/x/issues/new/", "/x/issues/123/close", "/x/issues/new/close",
273+ "/x%2Fissues/new", "/x%2Fy/issues/new", "/%2Fissues/new", "/issues/new", "//issues/new", "/X/issues/new",
274+ "/a/b/issues/new", "/x.git/issues/new", "/../issues/new", "/-/inbox/pending/x/y/approve", "/x/merge-requests",
275+ "/x/issues%2Fnew", "/x/issues/new%2Fextra", "/x/issues/%6Eew",
276+ } {
277+ u := &url.URL{Path: p}
278+ if dec, err := url.PathUnescape(p); err == nil {
279+ u = &url.URL{Path: dec, RawPath: p}
280+ }
281+ req := &http.Request{Method: http.MethodPost, URL: u}
282+ got := r.srv.anonymousPost(req)
283+ want := false
284+ assert.Equal(t, want, got, p)
285+ }
286+ for _, p := range []string{"/x/issues/new", "/proj/issues/new", "/a.b_c-d/issues/new"} {
287+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: p}}
288+ assert.True(t, r.srv.anonymousPost(req), p)
289+ req.Method = http.MethodPut
290+ assert.False(t, r.srv.anonymousPost(req), p+" PUT")
291+ }
292+ // Without the use case the path is not opened at all.
293+ plain := newIssueRig(t, domain.IssuesPublic, false)
294+ req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/proj/issues/new"}}
295+ assert.False(t, plain.srv.anonymousPost(req))
296+}
297+
298+func TestIssueListShowsNewIssueToVisitorsOfPublicRepos(t *testing.T) {
299+ t.Parallel()
300+ for mode, want := range map[domain.IssuesMode]bool{domain.IssuesPublic: true, domain.IssuesOwner: false} {
301+ r := newFormRig(t, mode)
302+ r.issues.EXPECT().List(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.IssuePage{}, nil)
303+ rec := r.do("GET", "/proj/issues", false, nil)
304+ require.Equal(t, 200, rec.Code)
305+ assert.Equal(t, want, strings.Contains(rec.Body.String(), `href="/proj/issues/new"`), string(mode))
306+ }
307+}
308+
309+func TestExternalIssueIsRenderedUntrusted(t *testing.T) {
310+ t.Parallel()
311+ r := newFormRig(t, domain.IssuesPublic)
312+ i := testIssue(t, 5, domain.IssueStateOpen, "bug", "[a](javascript:alert(1)) [b](https://example.org/) <script>x</script>")
313+ i.External, i.Author = true, "Eve <b>"
314+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{Issue: i}, nil)
315+ rec := r.do("GET", "/proj/issues/5", false, nil)
316+ require.Equal(t, 200, rec.Code)
317+ body := rec.Body.String()
318+ assert.NotContains(t, body, "javascript:")
319+ assert.NotContains(t, body, "<script>x")
320+ assert.Contains(t, body, `rel="nofollow noopener noreferrer ugc"`)
321+ assert.Contains(t, body, "Eve &lt;b&gt;")
322+}
M internal/adapters/web/mr_post.go +3 -0
@@ -32,6 +32,9 @@ func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
3434 back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
3538 if strings.Contains(r.URL.Path, "/issues") {
3639 back, what = issuesBase(r.PathValue("repo")), "an issue"
3740 }
@@ -32,6 +32,9 @@ func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 if err := r.ParseForm(); err != nil {
34 back, what := mrBase(r.PathValue("repo")), "a merge request"34 back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.HasPrefix(r.URL.Path, inboxPath+"/") {
36+ back, what = inboxPath, "a moderation action"
37+ }
35 if strings.Contains(r.URL.Path, "/issues") {38 if strings.Contains(r.URL.Path, "/issues") {
36 back, what = issuesBase(r.PathValue("repo")), "an issue"39 back, what = issuesBase(r.PathValue("repo")), "an issue"
37 }40 }
M internal/adapters/web/session.go +16 -1
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196196 if r.Method != http.MethodPost {
197197 return false
198198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))
199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200215 }
201216
202217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
@@ -196,7 +196,22 @@ func (s *Server) anonymousPost(r *http.Request) bool {
196 if r.Method != http.MethodPost {196 if r.Method != http.MethodPost {
197 return false197 return false
198 }198 }
199- return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath()))199+ return isLoginPath(r.URL) || (s.passkeysOn() && isPasskeyLoginPath(r.URL.EscapedPath())) ||
200+ (s.cfg.IssueForm != nil && isIssueFormPath(r.URL))
201+}
202+
203+// isIssueFormPath tells whether u is exactly /<repo>/issues/new for a valid
204+// repository name: the one write a visitor may make besides signing in. It
205+// looks at the escaped path, which is what the router matches, so that an
206+// encoded slash or a trailing segment never passes for it. The Origin check of
207+// the gate still applies.
208+func isIssueFormPath(u *url.URL) bool {
209+ repo, ok := strings.CutSuffix(strings.TrimPrefix(u.EscapedPath(), "/"), "/issues/new")
210+ if !ok || strings.Contains(repo, "/") || strings.HasSuffix(repo, ".git") {
211+ return false
212+ }
213+ _, err := domain.ParseRepoName(repo)
214+ return err == nil
200 }215 }
201 216
202 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of217 // isLoginPath tells whether u is exactly a login link: loginPrefix, a nonce of
M internal/adapters/web/static/css/60-mr.css +16 -0
@@ -173,6 +173,22 @@
173173 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
174174 }
175175
176+ /* the public issue form: counters, field errors, the honeypot out of sight */
177+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
178+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
179+ .cform output.over { color: var(--red); font-weight: 700; }
180+ .cform .f-error { color: var(--red); margin: 0; }
181+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
182+
183+ /* the inbox: items awaiting moderation, shown as text */
184+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
185+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
186+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
187+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
188+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
189+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
190+ .pending .p-btns form { margin: 0; }
191+
176192 .list.mrs {
177193 & .c-n { width: 64px; }
178194 & .c-state { width: 110px; }
@@ -173,6 +173,22 @@
173 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }173 & .f-btns { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s1); }
174 }174 }
175 175
176+ /* the public issue form: counters, field errors, the honeypot out of sight */
177+ .cform output { justify-self: end; font: var(--fs-s) var(--mono); color: var(--ink-mute); }
178+ .cform output.warn { color: var(--ink-hi); font-weight: 700; }
179+ .cform output.over { color: var(--red); font-weight: 700; }
180+ .cform .f-error { color: var(--red); margin: 0; }
181+ .cform .hp { position: absolute; left: -10000px; top: auto; width: 1px; height: 1px; overflow: hidden; }
182+
183+ /* the inbox: items awaiting moderation, shown as text */
184+ .pending-list { display: grid; gap: var(--s3); list-style: none; padding: 0; margin: 0; }
185+ .pending { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); display: grid; gap: var(--s2); }
186+ .pending .p-head { display: flex; gap: var(--s3); flex-wrap: wrap; align-items: baseline; color: var(--ink-dim); font-size: var(--fs-s); }
187+ .pending .p-title { margin: 0; font-weight: 700; color: var(--ink-hi); overflow-wrap: anywhere; }
188+ .pending .p-body { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
189+ .pending .p-btns { display: flex; gap: var(--s2); flex-wrap: wrap; }
190+ .pending .p-btns form { margin: 0; }
191+
176 .list.mrs {192 .list.mrs {
177 & .c-n { width: 64px; }193 & .c-n { width: 64px; }
178 & .c-state { width: 110px; }194 & .c-state { width: 110px; }
M internal/adapters/web/web.go +19 -1
@@ -63,6 +63,15 @@ type Config struct {
6363 // it there is no such redirect. Leave it unset rather than assigning a
6464 // typed nil pointer.
6565 IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
6675 // Inbox serves the owner's inbox page (/-/inbox) and the button that
6776 // leads to it. Without it there is neither. Leave it unset rather than
6877 // assigning a typed nil pointer.
@@ -181,6 +190,11 @@ func (s *Server) routes() *http.ServeMux {
181190 }
182191 if s.cfg.Inbox != nil {
183192 mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
184198 }
185199 mux.HandleFunc("GET /{$}", s.page(s.home))
186200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
@@ -214,7 +228,7 @@ func (s *Server) routes() *http.ServeMux {
214228 if s.cfg.Issues != nil {
215229 mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
216230 mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
217- mux.HandleFunc("GET /{repo}/issues/new", s.page(s.issueNew))
231+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
218232 mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
219233 // The writes: POST only, behind the gate (ServeHTTP).
220234 mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
@@ -222,6 +236,10 @@ func (s *Server) routes() *http.ServeMux {
222236 mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
223237 mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
224238 }
239+ if s.cfg.IssueForm != nil {
240+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
241+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
242+ }
225243 if s.cfg.Runs != nil {
226244 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
227245 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -63,6 +63,15 @@ type Config struct {
63 // it there is no such redirect. Leave it unset rather than assigning a63 // it there is no such redirect. Leave it unset rather than assigning a
64 // typed nil pointer.64 // typed nil pointer.
65 IssueNumbers ports.IssueNumberResolver65 IssueNumbers ports.IssueNumberResolver
66+ // IssueForm serves the public issue form of repositories whose issues
67+ // mode is public (GET and POST /<repo>/issues/new for a visitor). Without
68+ // it there is no such form. Leave it unset rather than assigning a typed
69+ // nil pointer.
70+ IssueForm ports.IssueSubmissionService
71+ // Moderation serves the Approve, Reject and Reject-and-ban buttons of the
72+ // pending issues in the inbox. It needs Inbox. Without it there are no
73+ // such routes. Leave it unset rather than assigning a typed nil pointer.
74+ Moderation ports.IssueModerationService
66 // Inbox serves the owner's inbox page (/-/inbox) and the button that75 // Inbox serves the owner's inbox page (/-/inbox) and the button that
67 // leads to it. Without it there is neither. Leave it unset rather than76 // leads to it. Without it there is neither. Leave it unset rather than
68 // assigning a typed nil pointer.77 // assigning a typed nil pointer.
@@ -181,6 +190,11 @@ func (s *Server) routes() *http.ServeMux {
181 }190 }
182 if s.cfg.Inbox != nil {191 if s.cfg.Inbox != nil {
183 mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))192 mux.HandleFunc("GET "+inboxPath, s.page(s.inbox))
193+ if s.cfg.Moderation != nil {
194+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/approve", s.mrAction(s.pendingApprove))
195+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/reject", s.mrAction(s.pendingReject(false)))
196+ mux.HandleFunc("POST "+inboxPath+"/pending/{repo}/{id}/ban", s.mrAction(s.pendingReject(true)))
197+ }
184 }198 }
185 mux.HandleFunc("GET /{$}", s.page(s.home))199 mux.HandleFunc("GET /{$}", s.page(s.home))
186 mux.HandleFunc("GET /{repo}", s.page(s.summary))200 mux.HandleFunc("GET /{repo}", s.page(s.summary))
@@ -214,7 +228,7 @@ func (s *Server) routes() *http.ServeMux {
214 if s.cfg.Issues != nil {228 if s.cfg.Issues != nil {
215 mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))229 mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
216 mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))230 mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
217- mux.HandleFunc("GET /{repo}/issues/new", s.page(s.issueNew))231+ mux.HandleFunc("GET /{repo}/issues/new", s.issueNewRoute)
218 mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))232 mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
219 // The writes: POST only, behind the gate (ServeHTTP).233 // The writes: POST only, behind the gate (ServeHTTP).
220 mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))234 mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
@@ -222,6 +236,10 @@ func (s *Server) routes() *http.ServeMux {
222 mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))236 mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
223 mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))237 mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
224 }238 }
239+ if s.cfg.IssueForm != nil {
240+ mux.HandleFunc("GET /{repo}/issues/submitted", s.issueSubmittedPage)
241+ mux.HandleFunc("POST /{repo}/issues/new", s.issueFormPost)
242+ }
225 if s.cfg.Runs != nil {243 if s.cfg.Runs != nil {
226 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))244 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
227 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))245 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/cli_issue_e2e_test.go +5 -2
@@ -59,8 +59,11 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
5959 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
6060 assert.Regexp(t, `issues\s+owner`, res.stdout)
6161 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err)
62- res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public")
63- require.Error(t, res.err, "public is refused by the server for now")
62+ res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public", "--json").ok(t)
63+ assert.Equal(t, "public", decode[struct {
64+ Issues string `json:"issues"`
65+ }](t, res.stdout).Issues)
66+ c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
6467
6568 // A merge request first: the issue takes the next number.
6669 wt := c.workTree("main", "README.md")
@@ -59,8 +59,11 @@ func TestIssuesCLIEndToEnd(t *testing.T) {
59 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)59 res = c.cli(t, adminToken, "repo", "show", "proj").ok(t)
60 assert.Regexp(t, `issues\s+owner`, res.stdout)60 assert.Regexp(t, `issues\s+owner`, res.stdout)
61 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err)61 require.Error(t, c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "everyone").err)
62- res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public")62+ res = c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "public", "--json").ok(t)
63- require.Error(t, res.err, "public is refused by the server for now")63+ assert.Equal(t, "public", decode[struct {
64+ Issues string `json:"issues"`
65+ }](t, res.stdout).Issues)
66+ c.cli(t, adminToken, "repo", "edit", "proj", "--issues", "owner").ok(t)
64 67
65 // A merge request first: the issue takes the next number.68 // A merge request first: the issue takes the next number.
66 wt := c.workTree("main", "README.md")69 wt := c.workTree("main", "README.md")
M internal/app/cli_repo.go +1 -1
@@ -318,7 +318,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
318318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
319319 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320320 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321- "public (not available yet; the server refuses it). Needs a server with the issues feature.\n" +
321+ "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
322322 "Needs the repo:admin scope." + jsonRepoHelp,
323323 Args: cobra.ExactArgs(1),
324324 ValidArgsFunction: completeRepoNames(env),
@@ -318,7 +318,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +318 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
319 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +319 "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
320 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +320 "--issues sets who may use issues: off (default), owner (the owner and tokens with issue:write) or\n" +
321- "public (not available yet; the server refuses it). Needs a server with the issues feature.\n" +321+ "public (visitors of a public repository may also propose issues through a moderated form).\nNeeds a server with the issues feature.\n" +
322 "Needs the repo:admin scope." + jsonRepoHelp,322 "Needs the repo:admin scope." + jsonRepoHelp,
323 Args: cobra.ExactArgs(1),323 Args: cobra.ExactArgs(1),
324 ValidArgsFunction: completeRepoNames(env),324 ValidArgsFunction: completeRepoNames(env),
M internal/app/forge.go +14 -0
@@ -75,6 +75,10 @@ type options struct {
7575 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
7676 // keys (tests give prepared ones: a 3072-bit key takes a while).
7777 oidcKey func() (*rsa.PrivateKey, error)
78+ // issuePoW lowers the proof of work of the public issue form (the base
79+ // and the highest difficulty, in bits) when positive, and shortens the
80+ // minimum fill time to a nanosecond; tests only.
81+ issuePoW int
7882 }
7983
8084 // remote returns the lister of github.com refs: the test's, else def.
@@ -345,6 +349,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
345349 f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
346350 Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
347351 Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
352+ BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
348353 })
349354 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
350355 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
@@ -571,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
571576 f.Browse.WithVerifier(sigs)
572577 return nil
573578 }
579+
580+// fastFill is the minimum fill time of the public issue form for a test that
581+// lowers the proof of work: next to none. Zero keeps the production value.
582+func fastFill(issuePoW int) time.Duration {
583+ if issuePoW > 0 {
584+ return time.Nanosecond
585+ }
586+ return 0
587+}
@@ -75,6 +75,10 @@ type options struct {
75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA75 // oidcKey generates the signing keys of the ID tokens; nil generates RSA
76 // keys (tests give prepared ones: a 3072-bit key takes a while).76 // keys (tests give prepared ones: a 3072-bit key takes a while).
77 oidcKey func() (*rsa.PrivateKey, error)77 oidcKey func() (*rsa.PrivateKey, error)
78+ // issuePoW lowers the proof of work of the public issue form (the base
79+ // and the highest difficulty, in bits) when positive, and shortens the
80+ // minimum fill time to a nanosecond; tests only.
81+ issuePoW int
78 }82 }
79 83
80 // remote returns the lister of github.com refs: the test's, else def.84 // remote returns the lister of github.com refs: the test's, else def.
@@ -345,6 +349,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
345 f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{349 f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{
346 Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,350 Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter,
347 Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,351 Keys: f.IssueKeys, Bus: f.Bus, Clock: clock,
352+ BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW),
348 })353 })
349 f.Inbox = usecase.NewInbox(usecase.InboxConfig{354 f.Inbox = usecase.NewInbox(usecase.InboxConfig{
350 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),355 Meta: store, MergeRequests: store.MergeRequests(), Issues: store.Issues(), Pending: store.PendingIssues(),
@@ -571,3 +576,12 @@ func (f *forge) signatures(store *kvstore.Store, reader ports.RepoReader, key do
571 f.Browse.WithVerifier(sigs)576 f.Browse.WithVerifier(sigs)
572 return nil577 return nil
573 }578 }
579+
580+// fastFill is the minimum fill time of the public issue form for a test that
581+// lowers the proof of work: next to none. Zero keeps the production value.
582+func fastFill(issuePoW int) time.Duration {
583+ if issuePoW > 0 {
584+ return time.Nanosecond
585+ }
586+ return 0
587+}
M internal/app/issue_api_test.go +5 -2
@@ -225,8 +225,11 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
225225 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226226 require.NoError(t, err)
227227
228- _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229- assert.Equal(t, "invalid_argument", codeName(err), "public issues are refused")
228+ updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229+ require.NoError(t, err, "public issues are accepted")
230+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
231+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
232+ require.NoError(t, err)
230233 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
231234 assert.Equal(t, "invalid_argument", codeName(err))
232235 repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
@@ -225,8 +225,11 @@ func TestIssuesFeatureAndSettingOverTheAPI(t *testing.T) {
225 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))225 _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"}))
226 require.NoError(t, err)226 require.NoError(t, err)
227 227
228- _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)228+ updated, err = update(r.root, pb.IssuesMode_ISSUES_MODE_PUBLIC)
229- assert.Equal(t, "invalid_argument", codeName(err), "public issues are refused")229+ require.NoError(t, err, "public issues are accepted")
230+ assert.Equal(t, pb.IssuesMode_ISSUES_MODE_PUBLIC, updated.GetSettings().GetIssues())
231+ _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_OWNER)
232+ require.NoError(t, err)
230 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)233 _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
231 assert.Equal(t, "invalid_argument", codeName(err))234 assert.Equal(t, "invalid_argument", codeName(err))
232 repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))235 repo, err = r.root.repo.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "priv"}))
M internal/app/web.go +2 -0
@@ -48,6 +48,8 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
4848 Issues: f.Issues,
4949 IssueNumbers: f.Issues,
5050 Inbox: f.Inbox,
51+ IssueForm: f.IssueSubmissions,
52+ Moderation: f.IssueModeration,
5153 Runs: f.CI,
5254 Artifacts: artifacts,
5355 Events: f.EventLog,
@@ -48,6 +48,8 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
48 Issues: f.Issues,48 Issues: f.Issues,
49 IssueNumbers: f.Issues,49 IssueNumbers: f.Issues,
50 Inbox: f.Inbox,50 Inbox: f.Inbox,
51+ IssueForm: f.IssueSubmissions,
52+ Moderation: f.IssueModeration,
51 Runs: f.CI,53 Runs: f.CI,
52 Artifacts: artifacts,54 Artifacts: artifacts,
53 Events: f.EventLog,55 Events: f.EventLog,
M internal/app/web_inbox_test.go +2 -2
@@ -51,13 +51,13 @@ func TestWebInboxEndToEnd(t *testing.T) {
5151 ev := stream.next(t, watchWait)
5252 assert.Equal(t, "inbox", ev.name)
5353 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 3 { // the other fragments of this event
54+ for range 4 { // the other fragments of this event
5555 stream.next(t, watchWait)
5656 }
5757 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
5858 require.NoError(t, err)
5959 var seen strings.Builder
60- for range 4 { // the four fragments of this event
60+ for range 5 { // the five fragments of this event
6161 seen.WriteString(stream.next(t, watchWait).data)
6262 }
6363 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
@@ -51,13 +51,13 @@ func TestWebInboxEndToEnd(t *testing.T) {
51 ev := stream.next(t, watchWait)51 ev := stream.next(t, watchWait)
52 assert.Equal(t, "inbox", ev.name)52 assert.Equal(t, "inbox", ev.name)
53 assert.Contains(t, ev.data, `id="inbox-counts"`)53 assert.Contains(t, ev.data, `id="inbox-counts"`)
54- for range 3 { // the other fragments of this event54+ for range 4 { // the other fragments of this event
55 stream.next(t, watchWait)55 stream.next(t, watchWait)
56 }56 }
57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})57 _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"})
58 require.NoError(t, err)58 require.NoError(t, err)
59 var seen strings.Builder59 var seen strings.Builder
60- for range 4 { // the four fragments of this event60+ for range 5 { // the five fragments of this event
61 seen.WriteString(stream.next(t, watchWait).data)61 seen.WriteString(stream.next(t, watchWait).data)
62 }62 }
63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")63 assert.Contains(t, seen.String(), "search is slow", "an event of the second repository")
A internal/app/web_issue_form_test.go +231 -0
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "html"
9+ "math/bits"
10+ "net/url"
11+ "regexp"
12+ "strconv"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+var (
23+ reFormAttr = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24+ reFormField = func(name string) *regexp.Regexp {
25+ return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26+ }
27+ rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28+)
29+
30+// testPoW is the difficulty the tests lower the proof of work to.
31+const testPoW = 8
32+
33+// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34+// bits leading zero bits, as the browser does.
35+func solvePoW(t *testing.T, nonce string, difficulty int) string {
36+ t.Helper()
37+ raw, err := url.PathUnescape(nonce)
38+ require.NoError(t, err)
39+ b, err := decodeB64URL(raw)
40+ require.NoError(t, err)
41+ for n := 0; n < 1<<26; n++ {
42+ sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43+ z := 0
44+ for _, c := range sum {
45+ if c == 0 {
46+ z += 8
47+ continue
48+ }
49+ z += bits.LeadingZeros8(c)
50+ break
51+ }
52+ if z >= difficulty {
53+ return strconv.Itoa(n)
54+ }
55+ }
56+ t.Fatal("no solution")
57+ return ""
58+}
59+
60+// formValues reads the challenge out of the HTML of the public form and
61+// solves it.
62+func formValues(t *testing.T, body string) url.Values {
63+ t.Helper()
64+ get := func(re *regexp.Regexp) string {
65+ m := re.FindStringSubmatch(body)
66+ require.Len(t, m, 2, "the form carries %s", re)
67+ return html.UnescapeString(m[1])
68+ }
69+ diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70+ require.NoError(t, err)
71+ require.Equal(t, testPoW, diff, "the test difficulty")
72+ return url.Values{
73+ "token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74+ "solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75+ }
76+}
77+
78+// The public issue form end to end: a visitor solves the proof of work and
79+// submits, the owner sees the issue awaiting moderation in the inbox and
80+// approves it, and a rejection with a ban shuts the same address out.
81+func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83+ cli := &cliHarness{harness: h, url: h.srv.URL}
84+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85+ require.NoError(t, err)
86+ b := browser{h}
87+ token := cli.signIn(t)
88+
89+ pub := domain.IssuesPublic
90+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91+ require.NoError(t, err)
92+ site := map[string]string{"Origin": publicOrigin}
93+
94+ // The visitor reads the list and finds the button.
95+ list := b.get("/proj/issues", nil)
96+ require.Equal(t, 200, list.status, list.body)
97+ assert.Contains(t, list.body, `href="/proj/issues/new"`)
98+
99+ // The form, with a challenge, never stored.
100+ form := b.get("/proj/issues/new", nil)
101+ require.Equal(t, 200, form.status, form.body)
102+ assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103+ assert.Contains(t, form.body, "<noscript>")
104+ assert.Contains(t, form.body, `name="website"`)
105+ v := formValues(t, form.body)
106+ v.Set("title", "Search is <b>slow</b>")
107+ v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108+ v.Set("author", "Alex")
109+
110+ // A bad cross-origin POST is refused before anything is checked.
111+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113+
114+ // The submission passes.
115+ sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116+ require.Equal(t, 303, sent.status, sent.body)
117+ assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118+ assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119+ thanks := b.get("/proj/issues/submitted", nil)
120+ assert.Equal(t, 200, thanks.status)
121+ assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122+
123+ // The same challenge cannot be used twice.
124+ replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125+ assert.Equal(t, 400, replay.status)
126+ assert.Contains(t, replay.body, "could not be verified")
127+
128+ // Nothing is public yet.
129+ assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130+
131+ // A text error shows the form again with a new challenge and the text.
132+ form2 := b.get("/proj/issues/new", nil)
133+ v2 := formValues(t, form2.body)
134+ v2.Set("title", "")
135+ v2.Set("body", "kept text")
136+ again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137+ require.Equal(t, 422, again.status, again.body)
138+ assert.Contains(t, again.body, "kept text")
139+ assert.Contains(t, again.body, "The title is empty.")
140+ v3 := formValues(t, again.body)
141+ assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142+
143+ // The owner finds it awaiting moderation, as plain text.
144+ inbox := b.get("/-/inbox", as(token))
145+ require.Equal(t, 200, inbox.status, inbox.body)
146+ assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147+ assert.Contains(t, inbox.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
148+ assert.Contains(t, inbox.body, "**bold**")
149+ assert.NotContains(t, inbox.body, "<strong>bold")
150+ acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151+ require.Len(t, acts, 3)
152+ actions := map[string]string{}
153+ for _, a := range acts {
154+ actions[a[2]] = a[1]
155+ }
156+
157+ // An anonymous POST to a moderation path is refused.
158+ assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159+
160+ // The owner shares the numbers with merge requests and issues: approve.
161+ ok := b.form(actions["approve"], token, nil)
162+ require.Equal(t, 303, ok.status, ok.body)
163+ assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164+ assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165+ shown := b.get("/proj/issues/1", nil)
166+ require.Equal(t, 200, shown.status, shown.body)
167+ assert.Contains(t, shown.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
168+ assert.Contains(t, shown.body, `href="https://example.org/d"`)
169+ assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170+ assert.NotContains(t, shown.body, "javascript:")
171+ assert.Contains(t, shown.body, "Alex")
172+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173+ empty := b.get("/-/inbox", as(token))
174+ assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175+
176+ // Reject and ban: the same address is then refused with the generic page.
177+ form4 := b.get("/proj/issues/new", nil)
178+ v4 := formValues(t, form4.body)
179+ v4.Set("title", "spam")
180+ require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181+ inbox2 := b.get("/-/inbox", as(token))
182+ var ban string
183+ for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184+ if a[2] == "ban" {
185+ ban = a[1]
186+ }
187+ }
188+ require.NotEmpty(t, ban)
189+ require.Equal(t, 303, b.form(ban, token, nil).status)
190+
191+ form5 := b.get("/proj/issues/new", nil)
192+ require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193+ v5 := formValues(t, form5.body)
194+ v5.Set("title", "more spam")
195+ banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196+ assert.Equal(t, 400, banned.status)
197+ assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198+ assert.NotContains(t, banned.body, "ban")
199+ assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200+}
201+
202+// A repository that is not public with public issues has no form.
203+func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206+ require.NoError(t, err)
207+ b := browser{h}
208+ site := map[string]string{"Origin": publicOrigin}
209+ body := url.Values{"title": {"x"}}.Encode()
210+
211+ missing := b.get("/nope/issues/new", nil)
212+ for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214+ require.NoError(t, err)
215+ got := b.get("/proj/issues/new", nil)
216+ assert.Equal(t, 404, got.status, name)
217+ assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219+ }
220+
221+ pub := domain.IssuesPublic
222+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223+ require.NoError(t, err)
224+ priv := domain.VisibilityPrivate
225+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226+ require.NoError(t, err)
227+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229+}
230+
231+func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
@@ -0,0 +1,231 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "crypto/sha256"
7+ "encoding/base64"
8+ "html"
9+ "math/bits"
10+ "net/url"
11+ "regexp"
12+ "strconv"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+var (
23+ reFormAttr = func(name string) *regexp.Regexp { return regexp.MustCompile(`data-` + name + `="([^"]*)"`) }
24+ reFormField = func(name string) *regexp.Regexp {
25+ return regexp.MustCompile(`<input type="hidden" name="` + name + `" value="([^"]*)"`)
26+ }
27+ rePendingActions = regexp.MustCompile(`action="(/-/inbox/pending/[^"]+/(approve|reject|ban))"`)
28+)
29+
30+// testPoW is the difficulty the tests lower the proof of work to.
31+const testPoW = 8
32+
33+// solvePoW finds the smallest counter n with SHA-256(nonce || ":" || n) having
34+// bits leading zero bits, as the browser does.
35+func solvePoW(t *testing.T, nonce string, difficulty int) string {
36+ t.Helper()
37+ raw, err := url.PathUnescape(nonce)
38+ require.NoError(t, err)
39+ b, err := decodeB64URL(raw)
40+ require.NoError(t, err)
41+ for n := 0; n < 1<<26; n++ {
42+ sum := sha256.Sum256(append(append(append([]byte{}, b...), ':'), strconv.Itoa(n)...))
43+ z := 0
44+ for _, c := range sum {
45+ if c == 0 {
46+ z += 8
47+ continue
48+ }
49+ z += bits.LeadingZeros8(c)
50+ break
51+ }
52+ if z >= difficulty {
53+ return strconv.Itoa(n)
54+ }
55+ }
56+ t.Fatal("no solution")
57+ return ""
58+}
59+
60+// formValues reads the challenge out of the HTML of the public form and
61+// solves it.
62+func formValues(t *testing.T, body string) url.Values {
63+ t.Helper()
64+ get := func(re *regexp.Regexp) string {
65+ m := re.FindStringSubmatch(body)
66+ require.Len(t, m, 2, "the form carries %s", re)
67+ return html.UnescapeString(m[1])
68+ }
69+ diff, err := strconv.Atoi(get(reFormAttr("difficulty")))
70+ require.NoError(t, err)
71+ require.Equal(t, testPoW, diff, "the test difficulty")
72+ return url.Values{
73+ "token": {get(reFormField("token"))}, "form_token": {get(reFormField("form_token"))},
74+ "solution": {solvePoW(t, get(reFormAttr("nonce")), diff)},
75+ }
76+}
77+
78+// The public issue form end to end: a visitor solves the proof of work and
79+// submits, the owner sees the issue awaiting moderation in the inbox and
80+// approves it, and a rejection with a ban shuts the same address out.
81+func TestWebPublicIssueFormEndToEnd(t *testing.T) {
82+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
83+ cli := &cliHarness{harness: h, url: h.srv.URL}
84+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
85+ require.NoError(t, err)
86+ b := browser{h}
87+ token := cli.signIn(t)
88+
89+ pub := domain.IssuesPublic
90+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
91+ require.NoError(t, err)
92+ site := map[string]string{"Origin": publicOrigin}
93+
94+ // The visitor reads the list and finds the button.
95+ list := b.get("/proj/issues", nil)
96+ require.Equal(t, 200, list.status, list.body)
97+ assert.Contains(t, list.body, `href="/proj/issues/new"`)
98+
99+ // The form, with a challenge, never stored.
100+ form := b.get("/proj/issues/new", nil)
101+ require.Equal(t, 200, form.status, form.body)
102+ assert.Equal(t, "no-store", form.hdr.Get("Cache-Control"))
103+ assert.Contains(t, form.body, "<noscript>")
104+ assert.Contains(t, form.body, `name="website"`)
105+ v := formValues(t, form.body)
106+ v.Set("title", "Search is <b>slow</b>")
107+ v.Set("body", "see [docs](https://example.org/d) and [x](javascript:alert(1))\r\n**bold**")
108+ v.Set("author", "Alex")
109+
110+ // A bad cross-origin POST is refused before anything is checked.
111+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", nil, v.Encode()).status)
112+ assert.Equal(t, 403, b.do("POST", "/proj/issues/new", map[string]string{"Origin": "https://evil.example"}, v.Encode()).status)
113+
114+ // The submission passes.
115+ sent := b.do("POST", "/proj/issues/new", site, v.Encode())
116+ require.Equal(t, 303, sent.status, sent.body)
117+ assert.Equal(t, "/proj/issues/submitted", sent.hdr.Get("Location"))
118+ assert.Equal(t, "no-store", sent.hdr.Get("Cache-Control"))
119+ thanks := b.get("/proj/issues/submitted", nil)
120+ assert.Equal(t, 200, thanks.status)
121+ assert.Contains(t, thanks.body, "Thanks, your issue is waiting for moderation.")
122+
123+ // The same challenge cannot be used twice.
124+ replay := b.do("POST", "/proj/issues/new", site, v.Encode())
125+ assert.Equal(t, 400, replay.status)
126+ assert.Contains(t, replay.body, "could not be verified")
127+
128+ // Nothing is public yet.
129+ assert.NotContains(t, b.get("/proj/issues", nil).body, "slow")
130+
131+ // A text error shows the form again with a new challenge and the text.
132+ form2 := b.get("/proj/issues/new", nil)
133+ v2 := formValues(t, form2.body)
134+ v2.Set("title", "")
135+ v2.Set("body", "kept text")
136+ again := b.do("POST", "/proj/issues/new", site, v2.Encode())
137+ require.Equal(t, 422, again.status, again.body)
138+ assert.Contains(t, again.body, "kept text")
139+ assert.Contains(t, again.body, "The title is empty.")
140+ v3 := formValues(t, again.body)
141+ assert.NotEqual(t, v2.Get("token"), v3.Get("token"))
142+
143+ // The owner finds it awaiting moderation, as plain text.
144+ inbox := b.get("/-/inbox", as(token))
145+ require.Equal(t, 200, inbox.status, inbox.body)
146+ assert.Contains(t, inbox.body, "<strong>1</strong> awaiting moderation")
147+ assert.Contains(t, inbox.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
148+ assert.Contains(t, inbox.body, "**bold**")
149+ assert.NotContains(t, inbox.body, "<strong>bold")
150+ acts := rePendingActions.FindAllStringSubmatch(inbox.body, -1)
151+ require.Len(t, acts, 3)
152+ actions := map[string]string{}
153+ for _, a := range acts {
154+ actions[a[2]] = a[1]
155+ }
156+
157+ // An anonymous POST to a moderation path is refused.
158+ assert.Equal(t, 403, b.do("POST", actions["approve"], site, "").status)
159+
160+ // The owner shares the numbers with merge requests and issues: approve.
161+ ok := b.form(actions["approve"], token, nil)
162+ require.Equal(t, 303, ok.status, ok.body)
163+ assert.Equal(t, "/-/inbox", ok.hdr.Get("Location"))
164+ assert.Equal(t, 404, b.form(actions["approve"], token, nil).status, "approved once")
165+ shown := b.get("/proj/issues/1", nil)
166+ require.Equal(t, 200, shown.status, shown.body)
167+ assert.Contains(t, shown.body, "Search is &lt;b&gt;slow&lt;/b&gt;")
168+ assert.Contains(t, shown.body, `href="https://example.org/d"`)
169+ assert.Contains(t, shown.body, `rel="nofollow noopener noreferrer ugc"`)
170+ assert.NotContains(t, shown.body, "javascript:")
171+ assert.Contains(t, shown.body, "Alex")
172+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="/proj/issues/1"`)
173+ empty := b.get("/-/inbox", as(token))
174+ assert.Contains(t, empty.body, "<strong>0</strong> awaiting moderation")
175+
176+ // Reject and ban: the same address is then refused with the generic page.
177+ form4 := b.get("/proj/issues/new", nil)
178+ v4 := formValues(t, form4.body)
179+ v4.Set("title", "spam")
180+ require.Equal(t, 303, b.do("POST", "/proj/issues/new", site, v4.Encode()).status)
181+ inbox2 := b.get("/-/inbox", as(token))
182+ var ban string
183+ for _, a := range rePendingActions.FindAllStringSubmatch(inbox2.body, -1) {
184+ if a[2] == "ban" {
185+ ban = a[1]
186+ }
187+ }
188+ require.NotEmpty(t, ban)
189+ require.Equal(t, 303, b.form(ban, token, nil).status)
190+
191+ form5 := b.get("/proj/issues/new", nil)
192+ require.Equal(t, 200, form5.status, "the form is still served; the ban bites at submission")
193+ v5 := formValues(t, form5.body)
194+ v5.Set("title", "more spam")
195+ banned := b.do("POST", "/proj/issues/new", site, v5.Encode())
196+ assert.Equal(t, 400, banned.status)
197+ assert.Contains(t, banned.body, "The submission could not be verified. Reload the page and try again.")
198+ assert.NotContains(t, banned.body, "ban")
199+ assert.Contains(t, b.get("/-/inbox", as(token)).body, "<strong>0</strong> awaiting moderation")
200+}
201+
202+// A repository that is not public with public issues has no form.
203+func TestWebPublicIssueFormIsNotThere(t *testing.T) {
204+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.issuePoW = testPoW })
205+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), createParams(h, "proj", domain.VisibilityPublic))
206+ require.NoError(t, err)
207+ b := browser{h}
208+ site := map[string]string{"Origin": publicOrigin}
209+ body := url.Values{"title": {"x"}}.Encode()
210+
211+ missing := b.get("/nope/issues/new", nil)
212+ for name, mode := range map[string]domain.IssuesMode{"off": domain.IssuesOff, "owner": domain.IssuesOwner} {
213+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &mode})
214+ require.NoError(t, err)
215+ got := b.get("/proj/issues/new", nil)
216+ assert.Equal(t, 404, got.status, name)
217+ assert.Equal(t, missing.body, got.body, name+": the same page as a missing repository")
218+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, name)
219+ }
220+
221+ pub := domain.IssuesPublic
222+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Issues: &pub})
223+ require.NoError(t, err)
224+ priv := domain.VisibilityPrivate
225+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
226+ require.NoError(t, err)
227+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status, "private")
228+ assert.Equal(t, 404, b.do("POST", "/proj/issues/new", site, body).status, "private")
229+}
230+
231+func decodeB64URL(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
M internal/usecase/issue_submissions.go +9 -3
@@ -104,6 +104,7 @@ type IssueSubmissions struct {
104104 clock ports.Clock
105105 base int
106106 limit int
107+ minFill time.Duration
107108
108109 mu sync.Mutex
109110 spent map[string]time.Time // nonce -> expiry of its challenge
@@ -125,6 +126,8 @@ type IssueSubmissionsConfig struct {
125126 // BaseDifficulty and MaxDifficulty replace the production constants; for
126127 // tests. Zero keeps the constants.
127128 BaseDifficulty, MaxDifficulty int
129+ // MinFillTime replaces minFillTime when positive; for tests.
130+ MinFillTime time.Duration
128131 }
129132
130133 // NewIssueSubmissions builds the submission use case.
@@ -132,7 +135,7 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
132135 s := &IssueSubmissions{
133136 meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
134137 keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
135- base: baseDifficulty, limit: maxDifficulty,
138+ base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
136139 spent: make(map[string]time.Time),
137140 }
138141 if cfg.BaseDifficulty > 0 {
@@ -141,6 +144,9 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
141144 if cfg.MaxDifficulty > 0 {
142145 s.limit = cfg.MaxDifficulty
143146 }
147+ if cfg.MinFillTime > 0 {
148+ s.minFill = cfg.MinFillTime
149+ }
144150 return s
145151 }
146152
@@ -195,7 +201,7 @@ func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName,
195201 Difficulty: d,
196202 FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
197203 IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
198- MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(minFillTime),
204+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
199205 ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
200206 }, nil
201207 }
@@ -316,7 +322,7 @@ func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, add
316322 if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
317323 return refuse("bad_form_token", domain.ErrSubmissionInvalid)
318324 }
319- if now.Before(time.Unix(c.IssuedAt, 0).Add(minFillTime)) {
325+ if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
320326 return refuse("too_fast", domain.ErrSubmissionInvalid)
321327 }
322328 if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||
@@ -104,6 +104,7 @@ type IssueSubmissions struct {
104 clock ports.Clock104 clock ports.Clock
105 base int105 base int
106 limit int106 limit int
107+ minFill time.Duration
107 108
108 mu sync.Mutex109 mu sync.Mutex
109 spent map[string]time.Time // nonce -> expiry of its challenge110 spent map[string]time.Time // nonce -> expiry of its challenge
@@ -125,6 +126,8 @@ type IssueSubmissionsConfig struct {
125 // BaseDifficulty and MaxDifficulty replace the production constants; for126 // BaseDifficulty and MaxDifficulty replace the production constants; for
126 // tests. Zero keeps the constants.127 // tests. Zero keeps the constants.
127 BaseDifficulty, MaxDifficulty int128 BaseDifficulty, MaxDifficulty int
129+ // MinFillTime replaces minFillTime when positive; for tests.
130+ MinFillTime time.Duration
128 }131 }
129 132
130 // NewIssueSubmissions builds the submission use case.133 // NewIssueSubmissions builds the submission use case.
@@ -132,7 +135,7 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
132 s := &IssueSubmissions{135 s := &IssueSubmissions{
133 meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,136 meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter,
134 keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,137 keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock,
135- base: baseDifficulty, limit: maxDifficulty,138+ base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime,
136 spent: make(map[string]time.Time),139 spent: make(map[string]time.Time),
137 }140 }
138 if cfg.BaseDifficulty > 0 {141 if cfg.BaseDifficulty > 0 {
@@ -141,6 +144,9 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions {
141 if cfg.MaxDifficulty > 0 {144 if cfg.MaxDifficulty > 0 {
142 s.limit = cfg.MaxDifficulty145 s.limit = cfg.MaxDifficulty
143 }146 }
147+ if cfg.MinFillTime > 0 {
148+ s.minFill = cfg.MinFillTime
149+ }
144 return s150 return s
145 }151 }
146 152
@@ -195,7 +201,7 @@ func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName,
195 Difficulty: d,201 Difficulty: d,
196 FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),202 FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)),
197 IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),203 IssuedAt: time.Unix(claims.IssuedAt, 0).UTC(),
198- MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(minFillTime),204+ MinSubmitAt: time.Unix(claims.IssuedAt, 0).UTC().Add(s.minFill),
199 ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),205 ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(),
200 }, nil206 }, nil
201 }207 }
@@ -316,7 +322,7 @@ func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, add
316 if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {322 if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) {
317 return refuse("bad_form_token", domain.ErrSubmissionInvalid)323 return refuse("bad_form_token", domain.ErrSubmissionInvalid)
318 }324 }
319- if now.Before(time.Unix(c.IssuedAt, 0).Add(minFillTime)) {325+ if now.Before(time.Unix(c.IssuedAt, 0).Add(s.minFill)) {
320 return refuse("too_fast", domain.ErrSubmissionInvalid)326 return refuse("too_fast", domain.ErrSubmissionInvalid)
321 }327 }
322 if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||328 if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size ||

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close