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 generated file: collapsed
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
@@ -0,0 +1,421 @@
1+// Code generated by templ - DO NOT EDIT.
2+
3+// templ: version: v0.3.1020
4+// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+package web
7+
8+//lint:file-ignore SA4006 This context is only used if a nested component is present.
9+
10+import "github.com/a-h/templ"
11+import templruntime "github.com/a-h/templ/runtime"
12+
13+// IssueForm is the public form that proposes an issue to the owner. It needs
14+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
15+// so the form stays hidden until it runs, and the page says why without it.
16+func IssueForm(l Layout, p IssueFormPage) templ.Component {
17+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
18+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
19+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
20+ return templ_7745c5c3_CtxErr
21+ }
22+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
23+ if !templ_7745c5c3_IsBuffer {
24+ defer func() {
25+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
26+ if templ_7745c5c3_Err == nil {
27+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
28+ }
29+ }()
30+ }
31+ ctx = templ.InitializeContext(ctx)
32+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
33+ if templ_7745c5c3_Var1 == nil {
34+ templ_7745c5c3_Var1 = templ.NopComponent
35+ }
36+ ctx = templ.ClearChildren(ctx)
37+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
38+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
39+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
40+ if !templ_7745c5c3_IsBuffer {
41+ defer func() {
42+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
43+ if templ_7745c5c3_Err == nil {
44+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
45+ }
46+ }()
47+ }
48+ ctx = templ.InitializeContext(ctx)
49+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<header class=\"show mr-h\"><h1>New issue</h1></header><noscript><p class=\"lede\">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p></noscript><form class=\"cform create\" method=\"post\" action=\"")
50+ if templ_7745c5c3_Err != nil {
51+ return templ_7745c5c3_Err
52+ }
53+ var templ_7745c5c3_Var3 templ.SafeURL
54+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
55+ if templ_7745c5c3_Err != nil {
56+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 19, Col: 35}
57+ }
58+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
59+ if templ_7745c5c3_Err != nil {
60+ return templ_7745c5c3_Err
61+ }
62+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hidden data-issue-form data-nonce=\"")
63+ if templ_7745c5c3_Err != nil {
64+ return templ_7745c5c3_Err
65+ }
66+ var templ_7745c5c3_Var4 string
67+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Nonce)
68+ if templ_7745c5c3_Err != nil {
69+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 22, Col: 23}
70+ }
71+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
72+ if templ_7745c5c3_Err != nil {
73+ return templ_7745c5c3_Err
74+ }
75+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-difficulty=\"")
76+ if templ_7745c5c3_Err != nil {
77+ return templ_7745c5c3_Err
78+ }
79+ var templ_7745c5c3_Var5 string
80+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.Difficulty))
81+ if templ_7745c5c3_Err != nil {
82+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 23, Col: 39}
83+ }
84+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
85+ if templ_7745c5c3_Err != nil {
86+ return templ_7745c5c3_Err
87+ }
88+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-min-submit-at=\"")
89+ if templ_7745c5c3_Err != nil {
90+ return templ_7745c5c3_Err
91+ }
92+ var templ_7745c5c3_Var6 string
93+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(i64(p.MinSubmitAt))
94+ if templ_7745c5c3_Err != nil {
95+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 24, Col: 42}
96+ }
97+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
98+ if templ_7745c5c3_Err != nil {
99+ return templ_7745c5c3_Err
100+ }
101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-worker=\"")
102+ if templ_7745c5c3_Err != nil {
103+ return templ_7745c5c3_Err
104+ }
105+ var templ_7745c5c3_Var7 string
106+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Worker)
107+ if templ_7745c5c3_Err != nil {
108+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 25, Col: 25}
109+ }
110+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
111+ if templ_7745c5c3_Err != nil {
112+ return templ_7745c5c3_Err
113+ }
114+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" autocomplete=\"off\"><input type=\"hidden\" name=\"token\" value=\"")
115+ if templ_7745c5c3_Err != nil {
116+ return templ_7745c5c3_Err
117+ }
118+ var templ_7745c5c3_Var8 string
119+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Token)
120+ if templ_7745c5c3_Err != nil {
121+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 28, Col: 52}
122+ }
123+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
124+ if templ_7745c5c3_Err != nil {
125+ return templ_7745c5c3_Err
126+ }
127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"> <input type=\"hidden\" name=\"form_token\" value=\"")
128+ if templ_7745c5c3_Err != nil {
129+ return templ_7745c5c3_Err
130+ }
131+ var templ_7745c5c3_Var9 string
132+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.FormToken)
133+ if templ_7745c5c3_Err != nil {
134+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 29, Col: 61}
135+ }
136+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
137+ if templ_7745c5c3_Err != nil {
138+ return templ_7745c5c3_Err
139+ }
140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" maxlength=\"120\" required autocomplete=\"off\" value=\"")
141+ if templ_7745c5c3_Err != nil {
142+ return templ_7745c5c3_Err
143+ }
144+ var templ_7745c5c3_Var10 string
145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
146+ if templ_7745c5c3_Err != nil {
147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 111}
148+ }
149+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150+ if templ_7745c5c3_Err != nil {
151+ return templ_7745c5c3_Err
152+ }
153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ var templ_7745c5c3_Var11 string
158+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
159+ if templ_7745c5c3_Err != nil {
160+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}
161+ }
162+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163+ if templ_7745c5c3_Err != nil {
164+ return templ_7745c5c3_Err
165+ }
166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")
167+ if templ_7745c5c3_Err != nil {
168+ return templ_7745c5c3_Err
169+ }
170+ var templ_7745c5c3_Var12 string
171+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
172+ if templ_7745c5c3_Err != nil {
173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}
174+ }
175+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
176+ if templ_7745c5c3_Err != nil {
177+ return templ_7745c5c3_Err
178+ }
179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")
180+ if templ_7745c5c3_Err != nil {
181+ return templ_7745c5c3_Err
182+ }
183+ var templ_7745c5c3_Var13 string
184+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
185+ if templ_7745c5c3_Err != nil {
186+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}
187+ }
188+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189+ if templ_7745c5c3_Err != nil {
190+ return templ_7745c5c3_Err
191+ }
192+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")
193+ if templ_7745c5c3_Err != nil {
194+ return templ_7745c5c3_Err
195+ }
196+ if p.ErrorField == "title" {
197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")
198+ if templ_7745c5c3_Err != nil {
199+ return templ_7745c5c3_Err
200+ }
201+ var templ_7745c5c3_Var14 string
202+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203+ if templ_7745c5c3_Err != nil {
204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}
205+ }
206+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
211+ if templ_7745c5c3_Err != nil {
212+ return templ_7745c5c3_Err
213+ }
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" maxlength=\"10000\" placeholder=\"Markdown, optional\">")
216+ if templ_7745c5c3_Err != nil {
217+ return templ_7745c5c3_Err
218+ }
219+ var templ_7745c5c3_Var15 string
220+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
221+ if templ_7745c5c3_Err != nil {
222+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 109}
223+ }
224+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
225+ if templ_7745c5c3_Err != nil {
226+ return templ_7745c5c3_Err
227+ }
228+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")
229+ if templ_7745c5c3_Err != nil {
230+ return templ_7745c5c3_Err
231+ }
232+ var templ_7745c5c3_Var16 string
233+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
234+ if templ_7745c5c3_Err != nil {
235+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}
236+ }
237+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
238+ if templ_7745c5c3_Err != nil {
239+ return templ_7745c5c3_Err
240+ }
241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")
242+ if templ_7745c5c3_Err != nil {
243+ return templ_7745c5c3_Err
244+ }
245+ var templ_7745c5c3_Var17 string
246+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
247+ if templ_7745c5c3_Err != nil {
248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}
249+ }
250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
251+ if templ_7745c5c3_Err != nil {
252+ return templ_7745c5c3_Err
253+ }
254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")
255+ if templ_7745c5c3_Err != nil {
256+ return templ_7745c5c3_Err
257+ }
258+ var templ_7745c5c3_Var18 string
259+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
260+ if templ_7745c5c3_Err != nil {
261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}
262+ }
263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
264+ if templ_7745c5c3_Err != nil {
265+ return templ_7745c5c3_Err
266+ }
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")
268+ if templ_7745c5c3_Err != nil {
269+ return templ_7745c5c3_Err
270+ }
271+ if p.ErrorField == "body" {
272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")
273+ if templ_7745c5c3_Err != nil {
274+ return templ_7745c5c3_Err
275+ }
276+ var templ_7745c5c3_Var19 string
277+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278+ if templ_7745c5c3_Err != nil {
279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}
280+ }
281+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
282+ if templ_7745c5c3_Err != nil {
283+ return templ_7745c5c3_Err
284+ }
285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")
286+ if templ_7745c5c3_Err != nil {
287+ return templ_7745c5c3_Err
288+ }
289+ }
290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" maxlength=\"40\" autocomplete=\"off\" value=\"")
291+ if templ_7745c5c3_Err != nil {
292+ return templ_7745c5c3_Err
293+ }
294+ var templ_7745c5c3_Var20 string
295+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
296+ if templ_7745c5c3_Err != nil {
297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 104}
298+ }
299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")
304+ if templ_7745c5c3_Err != nil {
305+ return templ_7745c5c3_Err
306+ }
307+ var templ_7745c5c3_Var21 string
308+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309+ if templ_7745c5c3_Err != nil {
310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}
311+ }
312+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ var templ_7745c5c3_Var22 string
321+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322+ if templ_7745c5c3_Err != nil {
323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}
324+ }
325+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
326+ if templ_7745c5c3_Err != nil {
327+ return templ_7745c5c3_Err
328+ }
329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")
330+ if templ_7745c5c3_Err != nil {
331+ return templ_7745c5c3_Err
332+ }
333+ var templ_7745c5c3_Var23 string
334+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335+ if templ_7745c5c3_Err != nil {
336+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}
337+ }
338+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
339+ if templ_7745c5c3_Err != nil {
340+ return templ_7745c5c3_Err
341+ }
342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")
343+ if templ_7745c5c3_Err != nil {
344+ return templ_7745c5c3_Err
345+ }
346+ if p.ErrorField == "author" {
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var24 string
352+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ }
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var25 templ.SafeURL
370+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ return nil
383+ })
384+ templ_7745c5c3_Err = Shell(l, issueFormRail()).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
385+ if templ_7745c5c3_Err != nil {
386+ return templ_7745c5c3_Err
387+ }
388+ return nil
389+ })
390+}
391+
392+func issueFormRail() templ.Component {
393+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
394+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
395+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
396+ return templ_7745c5c3_CtxErr
397+ }
398+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
399+ if !templ_7745c5c3_IsBuffer {
400+ defer func() {
401+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
402+ if templ_7745c5c3_Err == nil {
403+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
404+ }
405+ }()
406+ }
407+ ctx = templ.InitializeContext(ctx)
408+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
409+ if templ_7745c5c3_Var26 == nil {
410+ templ_7745c5c3_Var26 = templ.NopComponent
411+ }
412+ ctx = templ.ClearChildren(ctx)
413+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414+ if templ_7745c5c3_Err != nil {
415+ return templ_7745c5c3_Err
416+ }
417+ return nil
418+ })
419+}
420+
421+var _ = templruntime.GeneratedTemplate
@@ -0,0 +1,421 @@
1+// Code generated by templ - DO NOT EDIT.
2+
3+// templ: version: v0.3.1020
4+// SPDX-License-Identifier: AGPL-3.0-or-later
5+
6+package web
7+
8+//lint:file-ignore SA4006 This context is only used if a nested component is present.
9+
10+import "github.com/a-h/templ"
11+import templruntime "github.com/a-h/templ/runtime"
12+
13+// IssueForm is the public form that proposes an issue to the owner. It needs
14+// JavaScript: the script (issue-form.js) solves a proof of work in a worker,
15+// so the form stays hidden until it runs, and the page says why without it.
16+func IssueForm(l Layout, p IssueFormPage) templ.Component {
17+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
18+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
19+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
20+ return templ_7745c5c3_CtxErr
21+ }
22+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
23+ if !templ_7745c5c3_IsBuffer {
24+ defer func() {
25+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
26+ if templ_7745c5c3_Err == nil {
27+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
28+ }
29+ }()
30+ }
31+ ctx = templ.InitializeContext(ctx)
32+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
33+ if templ_7745c5c3_Var1 == nil {
34+ templ_7745c5c3_Var1 = templ.NopComponent
35+ }
36+ ctx = templ.ClearChildren(ctx)
37+ templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
38+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
39+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
40+ if !templ_7745c5c3_IsBuffer {
41+ defer func() {
42+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
43+ if templ_7745c5c3_Err == nil {
44+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
45+ }
46+ }()
47+ }
48+ ctx = templ.InitializeContext(ctx)
49+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<header class=\"show mr-h\"><h1>New issue</h1></header><noscript><p class=\"lede\">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p></noscript><form class=\"cform create\" method=\"post\" action=\"")
50+ if templ_7745c5c3_Err != nil {
51+ return templ_7745c5c3_Err
52+ }
53+ var templ_7745c5c3_Var3 templ.SafeURL
54+ templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action))
55+ if templ_7745c5c3_Err != nil {
56+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 19, Col: 35}
57+ }
58+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
59+ if templ_7745c5c3_Err != nil {
60+ return templ_7745c5c3_Err
61+ }
62+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" hidden data-issue-form data-nonce=\"")
63+ if templ_7745c5c3_Err != nil {
64+ return templ_7745c5c3_Err
65+ }
66+ var templ_7745c5c3_Var4 string
67+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Nonce)
68+ if templ_7745c5c3_Err != nil {
69+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 22, Col: 23}
70+ }
71+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
72+ if templ_7745c5c3_Err != nil {
73+ return templ_7745c5c3_Err
74+ }
75+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" data-difficulty=\"")
76+ if templ_7745c5c3_Err != nil {
77+ return templ_7745c5c3_Err
78+ }
79+ var templ_7745c5c3_Var5 string
80+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.Difficulty))
81+ if templ_7745c5c3_Err != nil {
82+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 23, Col: 39}
83+ }
84+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
85+ if templ_7745c5c3_Err != nil {
86+ return templ_7745c5c3_Err
87+ }
88+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" data-min-submit-at=\"")
89+ if templ_7745c5c3_Err != nil {
90+ return templ_7745c5c3_Err
91+ }
92+ var templ_7745c5c3_Var6 string
93+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(i64(p.MinSubmitAt))
94+ if templ_7745c5c3_Err != nil {
95+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 24, Col: 42}
96+ }
97+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
98+ if templ_7745c5c3_Err != nil {
99+ return templ_7745c5c3_Err
100+ }
101+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\" data-worker=\"")
102+ if templ_7745c5c3_Err != nil {
103+ return templ_7745c5c3_Err
104+ }
105+ var templ_7745c5c3_Var7 string
106+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Worker)
107+ if templ_7745c5c3_Err != nil {
108+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 25, Col: 25}
109+ }
110+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
111+ if templ_7745c5c3_Err != nil {
112+ return templ_7745c5c3_Err
113+ }
114+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" autocomplete=\"off\"><input type=\"hidden\" name=\"token\" value=\"")
115+ if templ_7745c5c3_Err != nil {
116+ return templ_7745c5c3_Err
117+ }
118+ var templ_7745c5c3_Var8 string
119+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Token)
120+ if templ_7745c5c3_Err != nil {
121+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 28, Col: 52}
122+ }
123+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
124+ if templ_7745c5c3_Err != nil {
125+ return templ_7745c5c3_Err
126+ }
127+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\"> <input type=\"hidden\" name=\"form_token\" value=\"")
128+ if templ_7745c5c3_Err != nil {
129+ return templ_7745c5c3_Err
130+ }
131+ var templ_7745c5c3_Var9 string
132+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.FormToken)
133+ if templ_7745c5c3_Err != nil {
134+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 29, Col: 61}
135+ }
136+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
137+ if templ_7745c5c3_Err != nil {
138+ return templ_7745c5c3_Err
139+ }
140+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <input type=\"hidden\" name=\"solution\" value=\"\"> <label for=\"issue-title\">title</label> <input id=\"issue-title\" name=\"title\" type=\"text\" maxlength=\"120\" required autocomplete=\"off\" value=\"")
141+ if templ_7745c5c3_Err != nil {
142+ return templ_7745c5c3_Err
143+ }
144+ var templ_7745c5c3_Var10 string
145+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Title)
146+ if templ_7745c5c3_Err != nil {
147+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 32, Col: 111}
148+ }
149+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
150+ if templ_7745c5c3_Err != nil {
151+ return templ_7745c5c3_Err
152+ }
153+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"> <output data-for=\"issue-title\" data-max=\"")
154+ if templ_7745c5c3_Err != nil {
155+ return templ_7745c5c3_Err
156+ }
157+ var templ_7745c5c3_Var11 string
158+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxTitle))
159+ if templ_7745c5c3_Err != nil {
160+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 61}
161+ }
162+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
163+ if templ_7745c5c3_Err != nil {
164+ return templ_7745c5c3_Err
165+ }
166+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" for=\"issue-title\">")
167+ if templ_7745c5c3_Err != nil {
168+ return templ_7745c5c3_Err
169+ }
170+ var templ_7745c5c3_Var12 string
171+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Title)))
172+ if templ_7745c5c3_Err != nil {
173+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 108}
174+ }
175+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
176+ if templ_7745c5c3_Err != nil {
177+ return templ_7745c5c3_Err
178+ }
179+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " / ")
180+ if templ_7745c5c3_Err != nil {
181+ return templ_7745c5c3_Err
182+ }
183+ var templ_7745c5c3_Var13 string
184+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxTitle))
185+ if templ_7745c5c3_Err != nil {
186+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 33, Col: 131}
187+ }
188+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
189+ if templ_7745c5c3_Err != nil {
190+ return templ_7745c5c3_Err
191+ }
192+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</output> ")
193+ if templ_7745c5c3_Err != nil {
194+ return templ_7745c5c3_Err
195+ }
196+ if p.ErrorField == "title" {
197+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<p class=\"f-error\" role=\"alert\">")
198+ if templ_7745c5c3_Err != nil {
199+ return templ_7745c5c3_Err
200+ }
201+ var templ_7745c5c3_Var14 string
202+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
203+ if templ_7745c5c3_Err != nil {
204+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 35, Col: 45}
205+ }
206+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
207+ if templ_7745c5c3_Err != nil {
208+ return templ_7745c5c3_Err
209+ }
210+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
211+ if templ_7745c5c3_Err != nil {
212+ return templ_7745c5c3_Err
213+ }
214+ }
215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"issue-body\">description</label> <textarea id=\"issue-body\" name=\"body\" rows=\"8\" maxlength=\"10000\" placeholder=\"Markdown, optional\">")
216+ if templ_7745c5c3_Err != nil {
217+ return templ_7745c5c3_Err
218+ }
219+ var templ_7745c5c3_Var15 string
220+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(p.Body)
221+ if templ_7745c5c3_Err != nil {
222+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 38, Col: 109}
223+ }
224+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
225+ if templ_7745c5c3_Err != nil {
226+ return templ_7745c5c3_Err
227+ }
228+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</textarea> <output data-for=\"issue-body\" data-max=\"")
229+ if templ_7745c5c3_Err != nil {
230+ return templ_7745c5c3_Err
231+ }
232+ var templ_7745c5c3_Var16 string
233+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxBody))
234+ if templ_7745c5c3_Err != nil {
235+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 59}
236+ }
237+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
238+ if templ_7745c5c3_Err != nil {
239+ return templ_7745c5c3_Err
240+ }
241+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" for=\"issue-body\">")
242+ if templ_7745c5c3_Err != nil {
243+ return templ_7745c5c3_Err
244+ }
245+ var templ_7745c5c3_Var17 string
246+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Body)))
247+ if templ_7745c5c3_Err != nil {
248+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 104}
249+ }
250+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
251+ if templ_7745c5c3_Err != nil {
252+ return templ_7745c5c3_Err
253+ }
254+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " / ")
255+ if templ_7745c5c3_Err != nil {
256+ return templ_7745c5c3_Err
257+ }
258+ var templ_7745c5c3_Var18 string
259+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxBody))
260+ if templ_7745c5c3_Err != nil {
261+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 39, Col: 126}
262+ }
263+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
264+ if templ_7745c5c3_Err != nil {
265+ return templ_7745c5c3_Err
266+ }
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</output> ")
268+ if templ_7745c5c3_Err != nil {
269+ return templ_7745c5c3_Err
270+ }
271+ if p.ErrorField == "body" {
272+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<p class=\"f-error\" role=\"alert\">")
273+ if templ_7745c5c3_Err != nil {
274+ return templ_7745c5c3_Err
275+ }
276+ var templ_7745c5c3_Var19 string
277+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
278+ if templ_7745c5c3_Err != nil {
279+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 41, Col: 45}
280+ }
281+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
282+ if templ_7745c5c3_Err != nil {
283+ return templ_7745c5c3_Err
284+ }
285+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</p>")
286+ if templ_7745c5c3_Err != nil {
287+ return templ_7745c5c3_Err
288+ }
289+ }
290+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"issue-author\">name (optional, not verified)</label> <input id=\"issue-author\" name=\"author\" type=\"text\" maxlength=\"40\" autocomplete=\"off\" value=\"")
291+ if templ_7745c5c3_Err != nil {
292+ return templ_7745c5c3_Err
293+ }
294+ var templ_7745c5c3_Var20 string
295+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Author)
296+ if templ_7745c5c3_Err != nil {
297+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 44, Col: 104}
298+ }
299+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
300+ if templ_7745c5c3_Err != nil {
301+ return templ_7745c5c3_Err
302+ }
303+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"> <output data-for=\"issue-author\" data-max=\"")
304+ if templ_7745c5c3_Err != nil {
305+ return templ_7745c5c3_Err
306+ }
307+ var templ_7745c5c3_Var21 string
308+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(p.MaxAuthor))
309+ if templ_7745c5c3_Err != nil {
310+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 63}
311+ }
312+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
313+ if templ_7745c5c3_Err != nil {
314+ return templ_7745c5c3_Err
315+ }
316+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" for=\"issue-author\">")
317+ if templ_7745c5c3_Err != nil {
318+ return templ_7745c5c3_Err
319+ }
320+ var templ_7745c5c3_Var22 string
321+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(runeCount(p.Author)))
322+ if templ_7745c5c3_Err != nil {
323+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 112}
324+ }
325+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
326+ if templ_7745c5c3_Err != nil {
327+ return templ_7745c5c3_Err
328+ }
329+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " / ")
330+ if templ_7745c5c3_Err != nil {
331+ return templ_7745c5c3_Err
332+ }
333+ var templ_7745c5c3_Var23 string
334+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MaxAuthor))
335+ if templ_7745c5c3_Err != nil {
336+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 45, Col: 136}
337+ }
338+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
339+ if templ_7745c5c3_Err != nil {
340+ return templ_7745c5c3_Err
341+ }
342+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</output> ")
343+ if templ_7745c5c3_Err != nil {
344+ return templ_7745c5c3_Err
345+ }
346+ if p.ErrorField == "author" {
347+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"f-error\" role=\"alert\">")
348+ if templ_7745c5c3_Err != nil {
349+ return templ_7745c5c3_Err
350+ }
351+ var templ_7745c5c3_Var24 string
352+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(p.Error)
353+ if templ_7745c5c3_Err != nil {
354+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 47, Col: 45}
355+ }
356+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
357+ if templ_7745c5c3_Err != nil {
358+ return templ_7745c5c3_Err
359+ }
360+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>")
361+ if templ_7745c5c3_Err != nil {
362+ return templ_7745c5c3_Err
363+ }
364+ }
365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"hp\" aria-hidden=\"true\"><label for=\"issue-website\">Leave this field empty</label> <input id=\"issue-website\" name=\"website\" type=\"text\" tabindex=\"-1\" autocomplete=\"off\" value=\"\"></div><p class=\"dim\" data-status role=\"status\" aria-live=\"polite\">Checking your browser… (this takes a few seconds)</p><div class=\"f-btns\"><button type=\"submit\" class=\"btn primary\" disabled>Submit for moderation</button> <a class=\"btn\" href=\"")
366+ if templ_7745c5c3_Err != nil {
367+ return templ_7745c5c3_Err
368+ }
369+ var templ_7745c5c3_Var25 templ.SafeURL
370+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Cancel))
371+ if templ_7745c5c3_Err != nil {
372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/issues_form.templ`, Line: 56, Col: 49}
373+ }
374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
375+ if templ_7745c5c3_Err != nil {
376+ return templ_7745c5c3_Err
377+ }
378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">Cancel</a></div></form>")
379+ if templ_7745c5c3_Err != nil {
380+ return templ_7745c5c3_Err
381+ }
382+ return nil
383+ })
384+ templ_7745c5c3_Err = Shell(l, issueFormRail()).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
385+ if templ_7745c5c3_Err != nil {
386+ return templ_7745c5c3_Err
387+ }
388+ return nil
389+ })
390+}
391+
392+func issueFormRail() templ.Component {
393+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
394+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
395+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
396+ return templ_7745c5c3_CtxErr
397+ }
398+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
399+ if !templ_7745c5c3_IsBuffer {
400+ defer func() {
401+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
402+ if templ_7745c5c3_Err == nil {
403+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
404+ }
405+ }()
406+ }
407+ ctx = templ.InitializeContext(ctx)
408+ templ_7745c5c3_Var26 := templ.GetChildren(ctx)
409+ if templ_7745c5c3_Var26 == nil {
410+ templ_7745c5c3_Var26 = templ.NopComponent
411+ }
412+ ctx = templ.ClearChildren(ctx)
413+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<section aria-labelledby=\"r-about\"><h2 id=\"r-about\">Before you write</h2><p class=\"dim\">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p></section>")
414+ if templ_7745c5c3_Err != nil {
415+ return templ_7745c5c3_Err
416+ }
417+ return nil
418+ })
419+}
420+
421+var _ = templruntime.GeneratedTemplate
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