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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
22 files · +2067 -122
M internal/adapters/web/inbox.go +111 -6
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "net/http" | |
| 8 | 8 | "net/url" | |
| 9 | 9 | "strconv" | |
| 10 | + | "strings" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/a-h/templ" | |
| @@ -40,6 +41,25 @@ type InboxRow struct { | |||
| 40 | 41 | 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 | 63 | // InboxRepoLink is an entry of the repository filter. | |
| 44 | 64 | type InboxRepoLink struct { | |
| 45 | 65 | Label string | |
| @@ -51,10 +71,13 @@ type InboxRepoLink struct { | |||
| 51 | 71 | type InboxPage struct { | |
| 52 | 72 | // MergeRequests and Issues count every open item, whatever the filter. | |
| 53 | 73 | 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 | |
| 58 | 81 | } | |
| 59 | 82 | ||
| 60 | 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 | 105 | // inboxPage builds the page from the overview. | |
| 83 | 106 | func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage { | |
| 84 | 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 | 112 | row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow { | |
| 87 | 113 | ts := updated | |
| 88 | 114 | if ts.IsZero() { | |
| @@ -114,6 +140,85 @@ func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) Inbox | |||
| 114 | 140 | 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 | 222 | // inbox is GET /-/inbox. | |
| 118 | 223 | func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 119 | 224 | if !SignedIn(ctx) { | |
| @@ -178,7 +283,7 @@ func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment | |||
| 178 | 283 | } | |
| 179 | 284 | p := l.s.inboxPage(ov, filter) | |
| 180 | 285 | 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 | 287 | f, err := renderFragment(ctx, inboxEvent, c) | |
| 183 | 288 | if err != nil { | |
| 184 | 289 | 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 bool | 41 | 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 string | 65 | 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 int | 73 | MergeRequests, Issues int | ||
| 54 | - | Filter string // the repository filtered on, "" for all | 74 | + | // PendingCount counts every issue awaiting moderation. |
| 55 | - | Repos []InboxRepoLink | 75 | + | PendingCount int |
| 56 | - | MRs []InboxRow | 76 | + | Pending []PendingRow |
| 57 | - | IssueRows []InboxRow | 77 | + | 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 := updated | 113 | 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 p | 140 | 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 []fragment | 285 | 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, err | 289 | return nil, err | ||
M internal/adapters/web/inbox.templ +39 -1
| @@ -11,6 +11,7 @@ templ Inbox(l Layout, p InboxPage) { | |||
| 11 | 11 | <div class="inbox"> | |
| 12 | 12 | @InboxCounters(p) | |
| 13 | 13 | @InboxRepos(p) | |
| 14 | + | @InboxPending(p) | |
| 14 | 15 | @InboxMRs(p) | |
| 15 | 16 | @InboxIssues(p) | |
| 16 | 17 | </div> | |
| @@ -19,7 +20,7 @@ templ Inbox(l Layout, p InboxPage) { | |||
| 19 | 20 | ||
| 20 | 21 | templ InboxCounters(p InboxPage) { | |
| 21 | 22 | <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 | |
| 23 | 24 | </p> | |
| 24 | 25 | } | |
| 25 | 26 | ||
| @@ -36,6 +37,43 @@ templ InboxRepos(p InboxPage) { | |||
| 36 | 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 | 77 | templ InboxMRs(p InboxPage) { | |
| 40 | 78 | <section id="inbox-mrs" aria-labelledby="inbox-mrs-h"> | |
| 41 | 79 | <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> issues | 23 | + | <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, "<script>alert(1)</script> 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 <i>") | |
| 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, "<script>alert(1)</script> 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 <i>") | |||
| 64 | + | assert.Contains(t, body, ">anonymous<", "no label") | |||
| 65 | + | assert.Contains(t, body, "…</p>", "a preview of about 300 characters") | |||
| 66 | + | assert.Contains(t, body, `action="/-/inbox/pending/alpha/`+p.ID.String()+`/approve"`) | |||
| 67 | + | assert.Contains(t, body, `/reject"`) | |||
| 68 | + | assert.Contains(t, body, `/ban"`) | |||
| 69 | + | assert.Contains(t, body, "Reject and ban") | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestInboxPendingKeepsTheFilterInItsActions(t *testing.T) { | |||
| 73 | + | t.Parallel() | |||
| 74 | + | p := testPendingIssue(t) | |||
| 75 | + | ov := inboxOverview(t) | |||
| 76 | + | ov.Pending = []domain.PendingIssue{p} | |||
| 77 | + | ov.Counts.Pending = 1 | |||
| 78 | + | svc := mocks.NewInboxService(t) | |||
| 79 | + | svc.EXPECT().Overview(mock.Anything, mock.Anything, repoName(t, "alpha")).Return(ov, nil) | |||
| 80 | + | srv := moderationServer(t, svc, mocks.NewIssueModerationService(t)) | |||
| 81 | + | rec := inboxGet(srv, "/-/inbox?repo=alpha", true) | |||
| 82 | + | assert.Contains(t, rec.Body.String(), `/approve?repo=alpha"`) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func TestInboxModerationActions(t *testing.T) { | |||
| 86 | + | t.Parallel() | |||
| 87 | + | post := func(srv *Server, path string, hdr map[string]string) *httptest.ResponseRecorder { | |||
| 88 | + | req := httptest.NewRequest(http.MethodPost, path, nil) | |||
| 89 | + | for k, v := range hdr { | |||
| 90 | + | req.Header.Set(k, v) | |||
| 91 | + | } | |||
| 92 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |||
| 93 | + | rec := httptest.NewRecorder() | |||
| 94 | + | srv.ServeHTTP(rec, req) | |||
| 95 | + | return rec | |||
| 96 | + | } | |||
| 97 | + | id, err := domain.ParsePendingIssueID(testPendingID) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | alpha := repoName(t, "alpha") | |||
| 100 | + | site := map[string]string{"Origin": testPublicURL} | |||
| 101 | + | ||||
| 102 | + | t.Run("approve", func(t *testing.T) { | |||
| 103 | + | t.Parallel() | |||
| 104 | + | mod := mocks.NewIssueModerationService(t) | |||
| 105 | + | mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, nil).Once() | |||
| 106 | + | rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve?repo=alpha", site) | |||
| 107 | + | assert.Equal(t, 303, rec.Code) | |||
| 108 | + | assert.Equal(t, "/-/inbox?repo=alpha", rec.Header().Get("Location")) | |||
| 109 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |||
| 110 | + | }) | |||
| 111 | + | t.Run("reject", func(t *testing.T) { | |||
| 112 | + | t.Parallel() | |||
| 113 | + | mod := mocks.NewIssueModerationService(t) | |||
| 114 | + | mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, false).Return(nil).Once() | |||
| 115 | + | rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/reject", site) | |||
| 116 | + | assert.Equal(t, 303, rec.Code) | |||
| 117 | + | assert.Equal(t, "/-/inbox", rec.Header().Get("Location")) | |||
| 118 | + | }) | |||
| 119 | + | t.Run("ban", func(t *testing.T) { | |||
| 120 | + | t.Parallel() | |||
| 121 | + | mod := mocks.NewIssueModerationService(t) | |||
| 122 | + | mod.EXPECT().Reject(mock.Anything, mock.Anything, alpha, id, true).Return(nil).Once() | |||
| 123 | + | rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/ban", site) | |||
| 124 | + | assert.Equal(t, 303, rec.Code) | |||
| 125 | + | }) | |||
| 126 | + | t.Run("gone", func(t *testing.T) { | |||
| 127 | + | t.Parallel() | |||
| 128 | + | mod := mocks.NewIssueModerationService(t) | |||
| 129 | + | mod.EXPECT().Approve(mock.Anything, mock.Anything, alpha, id).Return(domain.Issue{}, domain.ErrNotFound).Once() | |||
| 130 | + | rec := post(moderationServer(t, mocks.NewInboxService(t), mod), "/-/inbox/pending/alpha/"+testPendingID+"/approve", site) | |||
| 131 | + | assert.Equal(t, 404, rec.Code) | |||
| 132 | + | }) | |||
| 133 | + | t.Run("a malformed id is not found and calls nothing", func(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | rec := post(moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)), "/-/inbox/pending/alpha/nope/approve", site) | |||
| 136 | + | assert.Equal(t, 404, rec.Code) | |||
| 137 | + | }) | |||
| 138 | + | t.Run("the gate: no origin, no session", func(t *testing.T) { | |||
| 139 | + | t.Parallel() | |||
| 140 | + | srv := moderationServer(t, mocks.NewInboxService(t), mocks.NewIssueModerationService(t)) | |||
| 141 | + | assert.Equal(t, 403, post(srv, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil).Code) | |||
| 142 | + | req := httptest.NewRequest(http.MethodPost, "/-/inbox/pending/alpha/"+testPendingID+"/approve", nil) | |||
| 143 | + | req.Header.Set("Origin", testPublicURL) | |||
| 144 | + | rec := httptest.NewRecorder() | |||
| 145 | + | srv.ServeHTTP(rec, req) | |||
| 146 | + | assert.Equal(t, 403, rec.Code, "anonymous") | |||
| 147 | + | }) | |||
| 148 | + | } | |||
M internal/adapters/web/inbox_templ.go +354 -98
| @@ -58,6 +58,10 @@ func Inbox(l Layout, p InboxPage) templ.Component { | |||
| 58 | 58 | if templ_7745c5c3_Err != nil { | |
| 59 | 59 | return templ_7745c5c3_Err | |
| 60 | 60 | } | |
| 61 | + | templ_7745c5c3_Err = InboxPending(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 62 | + | if templ_7745c5c3_Err != nil { | |
| 63 | + | return templ_7745c5c3_Err | |
| 64 | + | } | |
| 61 | 65 | templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer) | |
| 62 | 66 | if templ_7745c5c3_Err != nil { | |
| 63 | 67 | return templ_7745c5c3_Err | |
| @@ -108,7 +112,7 @@ func InboxCounters(p InboxPage) templ.Component { | |||
| 108 | 112 | var templ_7745c5c3_Var4 string | |
| 109 | 113 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests)) | |
| 110 | 114 | if templ_7745c5c3_Err != nil { | |
| 111 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33} | |
| 115 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 33} | |
| 112 | 116 | } | |
| 113 | 117 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 114 | 118 | if templ_7745c5c3_Err != nil { | |
| @@ -121,13 +125,26 @@ func InboxCounters(p InboxPage) templ.Component { | |||
| 121 | 125 | var templ_7745c5c3_Var5 string | |
| 122 | 126 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues)) | |
| 123 | 127 | if templ_7745c5c3_Err != nil { | |
| 124 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87} | |
| 128 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 87} | |
| 125 | 129 | } | |
| 126 | 130 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | |
| 127 | 131 | if templ_7745c5c3_Err != nil { | |
| 128 | 132 | return templ_7745c5c3_Err | |
| 129 | 133 | } | |
| 130 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>") | |
| 134 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues · <strong>") | |
| 135 | + | if templ_7745c5c3_Err != nil { | |
| 136 | + | return templ_7745c5c3_Err | |
| 137 | + | } | |
| 138 | + | var templ_7745c5c3_Var6 string | |
| 139 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount)) | |
| 140 | + | if templ_7745c5c3_Err != nil { | |
| 141 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 139} | |
| 142 | + | } | |
| 143 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 144 | + | if templ_7745c5c3_Err != nil { | |
| 145 | + | return templ_7745c5c3_Err | |
| 146 | + | } | |
| 147 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong> awaiting moderation</p>") | |
| 131 | 148 | if templ_7745c5c3_Err != nil { | |
| 132 | 149 | return templ_7745c5c3_Err | |
| 133 | 150 | } | |
| @@ -151,58 +168,297 @@ func InboxRepos(p InboxPage) templ.Component { | |||
| 151 | 168 | }() | |
| 152 | 169 | } | |
| 153 | 170 | ctx = templ.InitializeContext(ctx) | |
| 154 | - | templ_7745c5c3_Var6 := templ.GetChildren(ctx) | |
| 155 | - | if templ_7745c5c3_Var6 == nil { | |
| 156 | - | templ_7745c5c3_Var6 = templ.NopComponent | |
| 171 | + | templ_7745c5c3_Var7 := templ.GetChildren(ctx) | |
| 172 | + | if templ_7745c5c3_Var7 == nil { | |
| 173 | + | templ_7745c5c3_Var7 = templ.NopComponent | |
| 157 | 174 | } | |
| 158 | 175 | ctx = templ.ClearChildren(ctx) | |
| 159 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") | |
| 176 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") | |
| 160 | 177 | if templ_7745c5c3_Err != nil { | |
| 161 | 178 | return templ_7745c5c3_Err | |
| 162 | 179 | } | |
| 163 | 180 | for _, r := range p.Repos { | |
| 164 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"") | |
| 181 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"") | |
| 165 | 182 | if templ_7745c5c3_Err != nil { | |
| 166 | 183 | return templ_7745c5c3_Err | |
| 167 | 184 | } | |
| 168 | - | var templ_7745c5c3_Var7 templ.SafeURL | |
| 169 | - | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 185 | + | var templ_7745c5c3_Var8 templ.SafeURL | |
| 186 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 170 | 187 | if templ_7745c5c3_Err != nil { | |
| 171 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32} | |
| 188 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 31, Col: 32} | |
| 172 | 189 | } | |
| 173 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 190 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 174 | 191 | if templ_7745c5c3_Err != nil { | |
| 175 | 192 | return templ_7745c5c3_Err | |
| 176 | 193 | } | |
| 177 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"") | |
| 194 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"") | |
| 178 | 195 | if templ_7745c5c3_Err != nil { | |
| 179 | 196 | return templ_7745c5c3_Err | |
| 180 | 197 | } | |
| 181 | 198 | if r.Current { | |
| 182 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"") | |
| 199 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"page\"") | |
| 183 | 200 | if templ_7745c5c3_Err != nil { | |
| 184 | 201 | return templ_7745c5c3_Err | |
| 185 | 202 | } | |
| 186 | 203 | } | |
| 187 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">") | |
| 204 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">") | |
| 188 | 205 | if templ_7745c5c3_Err != nil { | |
| 189 | 206 | return templ_7745c5c3_Err | |
| 190 | 207 | } | |
| 191 | - | var templ_7745c5c3_Var8 string | |
| 192 | - | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) | |
| 208 | + | var templ_7745c5c3_Var9 string | |
| 209 | + | templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) | |
| 193 | 210 | if templ_7745c5c3_Err != nil { | |
| 194 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13} | |
| 211 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 35, Col: 13} | |
| 195 | 212 | } | |
| 196 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 213 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) | |
| 197 | 214 | if templ_7745c5c3_Err != nil { | |
| 198 | 215 | return templ_7745c5c3_Err | |
| 199 | 216 | } | |
| 200 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>") | |
| 217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>") | |
| 201 | 218 | if templ_7745c5c3_Err != nil { | |
| 202 | 219 | return templ_7745c5c3_Err | |
| 203 | 220 | } | |
| 204 | 221 | } | |
| 205 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>") | |
| 222 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</nav>") | |
| 223 | + | if templ_7745c5c3_Err != nil { | |
| 224 | + | return templ_7745c5c3_Err | |
| 225 | + | } | |
| 226 | + | return nil | |
| 227 | + | }) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // InboxPending is the issues awaiting moderation, first on the page. Their | |
| 231 | + | // texts come from anonymous visitors: they are shown as plain text, escaped, | |
| 232 | + | // never as Markdown. | |
| 233 | + | func InboxPending(p InboxPage) templ.Component { | |
| 234 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 235 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 236 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 237 | + | return templ_7745c5c3_CtxErr | |
| 238 | + | } | |
| 239 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 240 | + | if !templ_7745c5c3_IsBuffer { | |
| 241 | + | defer func() { | |
| 242 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 243 | + | if templ_7745c5c3_Err == nil { | |
| 244 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 245 | + | } | |
| 246 | + | }() | |
| 247 | + | } | |
| 248 | + | ctx = templ.InitializeContext(ctx) | |
| 249 | + | templ_7745c5c3_Var10 := templ.GetChildren(ctx) | |
| 250 | + | if templ_7745c5c3_Var10 == nil { | |
| 251 | + | templ_7745c5c3_Var10 = templ.NopComponent | |
| 252 | + | } | |
| 253 | + | ctx = templ.ClearChildren(ctx) | |
| 254 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section id=\"inbox-pending\" aria-labelledby=\"inbox-pending-h\"><h2 id=\"inbox-pending-h\" class=\"rule-h\"><span>Awaiting moderation</span><span class=\"count\">") | |
| 255 | + | if templ_7745c5c3_Err != nil { | |
| 256 | + | return templ_7745c5c3_Err | |
| 257 | + | } | |
| 258 | + | var templ_7745c5c3_Var11 string | |
| 259 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount)) | |
| 260 | + | if templ_7745c5c3_Err != nil { | |
| 261 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 45, Col: 116} | |
| 262 | + | } | |
| 263 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 264 | + | if templ_7745c5c3_Err != nil { | |
| 265 | + | return templ_7745c5c3_Err | |
| 266 | + | } | |
| 267 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></h2>") | |
| 268 | + | if templ_7745c5c3_Err != nil { | |
| 269 | + | return templ_7745c5c3_Err | |
| 270 | + | } | |
| 271 | + | if len(p.Pending) > 0 { | |
| 272 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<ul class=\"pending-list\">") | |
| 273 | + | if templ_7745c5c3_Err != nil { | |
| 274 | + | return templ_7745c5c3_Err | |
| 275 | + | } | |
| 276 | + | for _, m := range p.Pending { | |
| 277 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<li class=\"pending\"><div class=\"p-head\"><a href=\"") | |
| 278 | + | if templ_7745c5c3_Err != nil { | |
| 279 | + | return templ_7745c5c3_Err | |
| 280 | + | } | |
| 281 | + | var templ_7745c5c3_Var12 templ.SafeURL | |
| 282 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |
| 283 | + | if templ_7745c5c3_Err != nil { | |
| 284 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 42} | |
| 285 | + | } | |
| 286 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 287 | + | if templ_7745c5c3_Err != nil { | |
| 288 | + | return templ_7745c5c3_Err | |
| 289 | + | } | |
| 290 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") | |
| 291 | + | if templ_7745c5c3_Err != nil { | |
| 292 | + | return templ_7745c5c3_Err | |
| 293 | + | } | |
| 294 | + | var templ_7745c5c3_Var13 string | |
| 295 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |
| 296 | + | if templ_7745c5c3_Err != nil { | |
| 297 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 53} | |
| 298 | + | } | |
| 299 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 300 | + | if templ_7745c5c3_Err != nil { | |
| 301 | + | return templ_7745c5c3_Err | |
| 302 | + | } | |
| 303 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> <span>") | |
| 304 | + | if templ_7745c5c3_Err != nil { | |
| 305 | + | return templ_7745c5c3_Err | |
| 306 | + | } | |
| 307 | + | var templ_7745c5c3_Var14 string | |
| 308 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author) | |
| 309 | + | if templ_7745c5c3_Err != nil { | |
| 310 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 52, Col: 23} | |
| 311 | + | } | |
| 312 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |
| 313 | + | if templ_7745c5c3_Err != nil { | |
| 314 | + | return templ_7745c5c3_Err | |
| 315 | + | } | |
| 316 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ") | |
| 317 | + | if templ_7745c5c3_Err != nil { | |
| 318 | + | return templ_7745c5c3_Err | |
| 319 | + | } | |
| 320 | + | if m.WhenAt != "" { | |
| 321 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<time datetime=\"") | |
| 322 | + | if templ_7745c5c3_Err != nil { | |
| 323 | + | return templ_7745c5c3_Err | |
| 324 | + | } | |
| 325 | + | var templ_7745c5c3_Var15 string | |
| 326 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 327 | + | if templ_7745c5c3_Err != nil { | |
| 328 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 33} | |
| 329 | + | } | |
| 330 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 331 | + | if templ_7745c5c3_Err != nil { | |
| 332 | + | return templ_7745c5c3_Err | |
| 333 | + | } | |
| 334 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"") | |
| 335 | + | if templ_7745c5c3_Err != nil { | |
| 336 | + | return templ_7745c5c3_Err | |
| 337 | + | } | |
| 338 | + | var templ_7745c5c3_Var16 string | |
| 339 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 340 | + | if templ_7745c5c3_Err != nil { | |
| 341 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 52} | |
| 342 | + | } | |
| 343 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 344 | + | if templ_7745c5c3_Err != nil { | |
| 345 | + | return templ_7745c5c3_Err | |
| 346 | + | } | |
| 347 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") | |
| 348 | + | if templ_7745c5c3_Err != nil { | |
| 349 | + | return templ_7745c5c3_Err | |
| 350 | + | } | |
| 351 | + | var templ_7745c5c3_Var17 string | |
| 352 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |
| 353 | + | if templ_7745c5c3_Err != nil { | |
| 354 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 63} | |
| 355 | + | } | |
| 356 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 357 | + | if templ_7745c5c3_Err != nil { | |
| 358 | + | return templ_7745c5c3_Err | |
| 359 | + | } | |
| 360 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</time>") | |
| 361 | + | if templ_7745c5c3_Err != nil { | |
| 362 | + | return templ_7745c5c3_Err | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><p class=\"p-title\">") | |
| 366 | + | if templ_7745c5c3_Err != nil { | |
| 367 | + | return templ_7745c5c3_Err | |
| 368 | + | } | |
| 369 | + | var templ_7745c5c3_Var18 string | |
| 370 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |
| 371 | + | if templ_7745c5c3_Err != nil { | |
| 372 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 57, Col: 34} | |
| 373 | + | } | |
| 374 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 375 | + | if templ_7745c5c3_Err != nil { | |
| 376 | + | return templ_7745c5c3_Err | |
| 377 | + | } | |
| 378 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p>") | |
| 379 | + | if templ_7745c5c3_Err != nil { | |
| 380 | + | return templ_7745c5c3_Err | |
| 381 | + | } | |
| 382 | + | if m.Preview != "" { | |
| 383 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"p-body\">") | |
| 384 | + | if templ_7745c5c3_Err != nil { | |
| 385 | + | return templ_7745c5c3_Err | |
| 386 | + | } | |
| 387 | + | var templ_7745c5c3_Var19 string | |
| 388 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Preview) | |
| 389 | + | if templ_7745c5c3_Err != nil { | |
| 390 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 59, Col: 36} | |
| 391 | + | } | |
| 392 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 393 | + | if templ_7745c5c3_Err != nil { | |
| 394 | + | return templ_7745c5c3_Err | |
| 395 | + | } | |
| 396 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>") | |
| 397 | + | if templ_7745c5c3_Err != nil { | |
| 398 | + | return templ_7745c5c3_Err | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | if m.Approve != "" { | |
| 402 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"p-btns\"><form method=\"post\" action=\"") | |
| 403 | + | if templ_7745c5c3_Err != nil { | |
| 404 | + | return templ_7745c5c3_Err | |
| 405 | + | } | |
| 406 | + | var templ_7745c5c3_Var20 templ.SafeURL | |
| 407 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Approve)) | |
| 408 | + | if templ_7745c5c3_Err != nil { | |
| 409 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 63, Col: 61} | |
| 410 | + | } | |
| 411 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |
| 412 | + | if templ_7745c5c3_Err != nil { | |
| 413 | + | return templ_7745c5c3_Err | |
| 414 | + | } | |
| 415 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><button type=\"submit\" class=\"btn primary\">Approve</button></form><form method=\"post\" action=\"") | |
| 416 | + | if templ_7745c5c3_Err != nil { | |
| 417 | + | return templ_7745c5c3_Err | |
| 418 | + | } | |
| 419 | + | var templ_7745c5c3_Var21 templ.SafeURL | |
| 420 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Reject)) | |
| 421 | + | if templ_7745c5c3_Err != nil { | |
| 422 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 64, Col: 60} | |
| 423 | + | } | |
| 424 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 425 | + | if templ_7745c5c3_Err != nil { | |
| 426 | + | return templ_7745c5c3_Err | |
| 427 | + | } | |
| 428 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><button type=\"submit\" class=\"btn\">Reject</button></form><form method=\"post\" action=\"") | |
| 429 | + | if templ_7745c5c3_Err != nil { | |
| 430 | + | return templ_7745c5c3_Err | |
| 431 | + | } | |
| 432 | + | var templ_7745c5c3_Var22 templ.SafeURL | |
| 433 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Ban)) | |
| 434 | + | if templ_7745c5c3_Err != nil { | |
| 435 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 57} | |
| 436 | + | } | |
| 437 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 438 | + | if templ_7745c5c3_Err != nil { | |
| 439 | + | return templ_7745c5c3_Err | |
| 440 | + | } | |
| 441 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><button type=\"submit\" class=\"btn danger\">Reject and ban</button></form></div>") | |
| 442 | + | if templ_7745c5c3_Err != nil { | |
| 443 | + | return templ_7745c5c3_Err | |
| 444 | + | } | |
| 445 | + | } | |
| 446 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</li>") | |
| 447 | + | if templ_7745c5c3_Err != nil { | |
| 448 | + | return templ_7745c5c3_Err | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</ul>") | |
| 452 | + | if templ_7745c5c3_Err != nil { | |
| 453 | + | return templ_7745c5c3_Err | |
| 454 | + | } | |
| 455 | + | } else { | |
| 456 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"lede dim\">nothing awaits moderation.</p>") | |
| 457 | + | if templ_7745c5c3_Err != nil { | |
| 458 | + | return templ_7745c5c3_Err | |
| 459 | + | } | |
| 460 | + | } | |
| 461 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>") | |
| 206 | 462 | if templ_7745c5c3_Err != nil { | |
| 207 | 463 | return templ_7745c5c3_Err | |
| 208 | 464 | } | |
| @@ -226,25 +482,25 @@ func InboxMRs(p InboxPage) templ.Component { | |||
| 226 | 482 | }() | |
| 227 | 483 | } | |
| 228 | 484 | ctx = templ.InitializeContext(ctx) | |
| 229 | - | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | |
| 230 | - | if templ_7745c5c3_Var9 == nil { | |
| 231 | - | templ_7745c5c3_Var9 = templ.NopComponent | |
| 485 | + | templ_7745c5c3_Var23 := templ.GetChildren(ctx) | |
| 486 | + | if templ_7745c5c3_Var23 == nil { | |
| 487 | + | templ_7745c5c3_Var23 = templ.NopComponent | |
| 232 | 488 | } | |
| 233 | 489 | ctx = templ.ClearChildren(ctx) | |
| 234 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") | |
| 490 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") | |
| 235 | 491 | if templ_7745c5c3_Err != nil { | |
| 236 | 492 | return templ_7745c5c3_Err | |
| 237 | 493 | } | |
| 238 | - | var templ_7745c5c3_Var10 string | |
| 239 | - | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) | |
| 494 | + | var templ_7745c5c3_Var24 string | |
| 495 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) | |
| 240 | 496 | if templ_7745c5c3_Err != nil { | |
| 241 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108} | |
| 497 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 79, Col: 108} | |
| 242 | 498 | } | |
| 243 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 499 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 244 | 500 | if templ_7745c5c3_Err != nil { | |
| 245 | 501 | return templ_7745c5c3_Err | |
| 246 | 502 | } | |
| 247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>") | |
| 503 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></h2>") | |
| 248 | 504 | if templ_7745c5c3_Err != nil { | |
| 249 | 505 | return templ_7745c5c3_Err | |
| 250 | 506 | } | |
| @@ -253,12 +509,12 @@ func InboxMRs(p InboxPage) templ.Component { | |||
| 253 | 509 | return templ_7745c5c3_Err | |
| 254 | 510 | } | |
| 255 | 511 | if len(p.MRs) == 0 { | |
| 256 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>") | |
| 512 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"lede dim\">no open merge requests.</p>") | |
| 257 | 513 | if templ_7745c5c3_Err != nil { | |
| 258 | 514 | return templ_7745c5c3_Err | |
| 259 | 515 | } | |
| 260 | 516 | } | |
| 261 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>") | |
| 517 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>") | |
| 262 | 518 | if templ_7745c5c3_Err != nil { | |
| 263 | 519 | return templ_7745c5c3_Err | |
| 264 | 520 | } | |
| @@ -282,25 +538,25 @@ func InboxIssues(p InboxPage) templ.Component { | |||
| 282 | 538 | }() | |
| 283 | 539 | } | |
| 284 | 540 | ctx = templ.InitializeContext(ctx) | |
| 285 | - | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | |
| 286 | - | if templ_7745c5c3_Var11 == nil { | |
| 287 | - | templ_7745c5c3_Var11 = templ.NopComponent | |
| 541 | + | templ_7745c5c3_Var25 := templ.GetChildren(ctx) | |
| 542 | + | if templ_7745c5c3_Var25 == nil { | |
| 543 | + | templ_7745c5c3_Var25 = templ.NopComponent | |
| 288 | 544 | } | |
| 289 | 545 | ctx = templ.ClearChildren(ctx) | |
| 290 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") | |
| 546 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") | |
| 291 | 547 | if templ_7745c5c3_Err != nil { | |
| 292 | 548 | return templ_7745c5c3_Err | |
| 293 | 549 | } | |
| 294 | - | var templ_7745c5c3_Var12 string | |
| 295 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) | |
| 550 | + | var templ_7745c5c3_Var26 string | |
| 551 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) | |
| 296 | 552 | if templ_7745c5c3_Err != nil { | |
| 297 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109} | |
| 553 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 89, Col: 109} | |
| 298 | 554 | } | |
| 299 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 555 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 300 | 556 | if templ_7745c5c3_Err != nil { | |
| 301 | 557 | return templ_7745c5c3_Err | |
| 302 | 558 | } | |
| 303 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>") | |
| 559 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></h2>") | |
| 304 | 560 | if templ_7745c5c3_Err != nil { | |
| 305 | 561 | return templ_7745c5c3_Err | |
| 306 | 562 | } | |
| @@ -309,12 +565,12 @@ func InboxIssues(p InboxPage) templ.Component { | |||
| 309 | 565 | return templ_7745c5c3_Err | |
| 310 | 566 | } | |
| 311 | 567 | if len(p.IssueRows) == 0 { | |
| 312 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>") | |
| 568 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"lede dim\">no open issues.</p>") | |
| 313 | 569 | if templ_7745c5c3_Err != nil { | |
| 314 | 570 | return templ_7745c5c3_Err | |
| 315 | 571 | } | |
| 316 | 572 | } | |
| 317 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>") | |
| 573 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>") | |
| 318 | 574 | if templ_7745c5c3_Err != nil { | |
| 319 | 575 | return templ_7745c5c3_Err | |
| 320 | 576 | } | |
| @@ -338,160 +594,160 @@ func inboxRows(rows []InboxRow, label string) templ.Component { | |||
| 338 | 594 | }() | |
| 339 | 595 | } | |
| 340 | 596 | ctx = templ.InitializeContext(ctx) | |
| 341 | - | templ_7745c5c3_Var13 := templ.GetChildren(ctx) | |
| 342 | - | if templ_7745c5c3_Var13 == nil { | |
| 343 | - | templ_7745c5c3_Var13 = templ.NopComponent | |
| 597 | + | templ_7745c5c3_Var27 := templ.GetChildren(ctx) | |
| 598 | + | if templ_7745c5c3_Var27 == nil { | |
| 599 | + | templ_7745c5c3_Var27 = templ.NopComponent | |
| 344 | 600 | } | |
| 345 | 601 | ctx = templ.ClearChildren(ctx) | |
| 346 | 602 | if len(rows) > 0 { | |
| 347 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"") | |
| 603 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<table class=\"list mrs\" aria-label=\"") | |
| 348 | 604 | if templ_7745c5c3_Err != nil { | |
| 349 | 605 | return templ_7745c5c3_Err | |
| 350 | 606 | } | |
| 351 | - | var templ_7745c5c3_Var14 string | |
| 352 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) | |
| 607 | + | var templ_7745c5c3_Var28 string | |
| 608 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) | |
| 353 | 609 | if templ_7745c5c3_Err != nil { | |
| 354 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44} | |
| 610 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 99, Col: 44} | |
| 355 | 611 | } | |
| 356 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 612 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) | |
| 357 | 613 | if templ_7745c5c3_Err != nil { | |
| 358 | 614 | return templ_7745c5c3_Err | |
| 359 | 615 | } | |
| 360 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>") | |
| 616 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><tbody data-rows>") | |
| 361 | 617 | if templ_7745c5c3_Err != nil { | |
| 362 | 618 | return templ_7745c5c3_Err | |
| 363 | 619 | } | |
| 364 | 620 | for _, m := range rows { | |
| 365 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr") | |
| 621 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr") | |
| 366 | 622 | if templ_7745c5c3_Err != nil { | |
| 367 | 623 | return templ_7745c5c3_Err | |
| 368 | 624 | } | |
| 369 | 625 | if m.Cursor { | |
| 370 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor") | |
| 626 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " data-cursor") | |
| 371 | 627 | if templ_7745c5c3_Err != nil { | |
| 372 | 628 | return templ_7745c5c3_Err | |
| 373 | 629 | } | |
| 374 | 630 | } | |
| 375 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">") | |
| 631 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " data-state=\"open\"><td class=\"c-n mute\">") | |
| 376 | 632 | if templ_7745c5c3_Err != nil { | |
| 377 | 633 | return templ_7745c5c3_Err | |
| 378 | 634 | } | |
| 379 | - | var templ_7745c5c3_Var15 string | |
| 380 | - | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) | |
| 635 | + | var templ_7745c5c3_Var29 string | |
| 636 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) | |
| 381 | 637 | if templ_7745c5c3_Err != nil { | |
| 382 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37} | |
| 638 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 103, Col: 37} | |
| 383 | 639 | } | |
| 384 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | |
| 640 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 385 | 641 | if templ_7745c5c3_Err != nil { | |
| 386 | 642 | return templ_7745c5c3_Err | |
| 387 | 643 | } | |
| 388 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"") | |
| 644 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-subject\"><a href=\"") | |
| 389 | 645 | if templ_7745c5c3_Err != nil { | |
| 390 | 646 | return templ_7745c5c3_Err | |
| 391 | 647 | } | |
| 392 | - | var templ_7745c5c3_Var16 templ.SafeURL | |
| 393 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) | |
| 648 | + | var templ_7745c5c3_Var30 templ.SafeURL | |
| 649 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) | |
| 394 | 650 | if templ_7745c5c3_Err != nil { | |
| 395 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59} | |
| 651 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 59} | |
| 396 | 652 | } | |
| 397 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 653 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 398 | 654 | if templ_7745c5c3_Err != nil { | |
| 399 | 655 | return templ_7745c5c3_Err | |
| 400 | 656 | } | |
| 401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") | |
| 657 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">") | |
| 402 | 658 | if templ_7745c5c3_Err != nil { | |
| 403 | 659 | return templ_7745c5c3_Err | |
| 404 | 660 | } | |
| 405 | - | var templ_7745c5c3_Var17 string | |
| 406 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |
| 661 | + | var templ_7745c5c3_Var31 string | |
| 662 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |
| 407 | 663 | if templ_7745c5c3_Err != nil { | |
| 408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71} | |
| 664 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 71} | |
| 409 | 665 | } | |
| 410 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |
| 666 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 411 | 667 | if templ_7745c5c3_Err != nil { | |
| 412 | 668 | return templ_7745c5c3_Err | |
| 413 | 669 | } | |
| 414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"") | |
| 670 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</a> <a class=\"mute\" href=\"") | |
| 415 | 671 | if templ_7745c5c3_Err != nil { | |
| 416 | 672 | return templ_7745c5c3_Err | |
| 417 | 673 | } | |
| 418 | - | var templ_7745c5c3_Var18 templ.SafeURL | |
| 419 | - | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |
| 674 | + | var templ_7745c5c3_Var32 templ.SafeURL | |
| 675 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |
| 420 | 676 | if templ_7745c5c3_Err != nil { | |
| 421 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126} | |
| 677 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 126} | |
| 422 | 678 | } | |
| 423 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |
| 679 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) | |
| 424 | 680 | if templ_7745c5c3_Err != nil { | |
| 425 | 681 | return templ_7745c5c3_Err | |
| 426 | 682 | } | |
| 427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | |
| 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">") | |
| 428 | 684 | if templ_7745c5c3_Err != nil { | |
| 429 | 685 | return templ_7745c5c3_Err | |
| 430 | 686 | } | |
| 431 | - | var templ_7745c5c3_Var19 string | |
| 432 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |
| 687 | + | var templ_7745c5c3_Var33 string | |
| 688 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |
| 433 | 689 | if templ_7745c5c3_Err != nil { | |
| 434 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137} | |
| 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 137} | |
| 435 | 691 | } | |
| 436 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |
| 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 437 | 693 | if templ_7745c5c3_Err != nil { | |
| 438 | 694 | return templ_7745c5c3_Err | |
| 439 | 695 | } | |
| 440 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">") | |
| 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a></td><td class=\"c-when nowrap dim\">") | |
| 441 | 697 | if templ_7745c5c3_Err != nil { | |
| 442 | 698 | return templ_7745c5c3_Err | |
| 443 | 699 | } | |
| 444 | 700 | if m.WhenAt != "" { | |
| 445 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"") | |
| 701 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<time datetime=\"") | |
| 446 | 702 | if templ_7745c5c3_Err != nil { | |
| 447 | 703 | return templ_7745c5c3_Err | |
| 448 | 704 | } | |
| 449 | - | var templ_7745c5c3_Var20 string | |
| 450 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 705 | + | var templ_7745c5c3_Var34 string | |
| 706 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 451 | 707 | if templ_7745c5c3_Err != nil { | |
| 452 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33} | |
| 708 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 33} | |
| 453 | 709 | } | |
| 454 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | |
| 710 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) | |
| 455 | 711 | if templ_7745c5c3_Err != nil { | |
| 456 | 712 | return templ_7745c5c3_Err | |
| 457 | 713 | } | |
| 458 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"") | |
| 714 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"") | |
| 459 | 715 | if templ_7745c5c3_Err != nil { | |
| 460 | 716 | return templ_7745c5c3_Err | |
| 461 | 717 | } | |
| 462 | - | var templ_7745c5c3_Var21 string | |
| 463 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 718 | + | var templ_7745c5c3_Var35 string | |
| 719 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |
| 464 | 720 | if templ_7745c5c3_Err != nil { | |
| 465 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52} | |
| 721 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 52} | |
| 466 | 722 | } | |
| 467 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | |
| 723 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) | |
| 468 | 724 | if templ_7745c5c3_Err != nil { | |
| 469 | 725 | return templ_7745c5c3_Err | |
| 470 | 726 | } | |
| 471 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | |
| 727 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">") | |
| 472 | 728 | if templ_7745c5c3_Err != nil { | |
| 473 | 729 | return templ_7745c5c3_Err | |
| 474 | 730 | } | |
| 475 | - | var templ_7745c5c3_Var22 string | |
| 476 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |
| 731 | + | var templ_7745c5c3_Var36 string | |
| 732 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |
| 477 | 733 | if templ_7745c5c3_Err != nil { | |
| 478 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63} | |
| 734 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 63} | |
| 479 | 735 | } | |
| 480 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 736 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 481 | 737 | if templ_7745c5c3_Err != nil { | |
| 482 | 738 | return templ_7745c5c3_Err | |
| 483 | 739 | } | |
| 484 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>") | |
| 740 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time>") | |
| 485 | 741 | if templ_7745c5c3_Err != nil { | |
| 486 | 742 | return templ_7745c5c3_Err | |
| 487 | 743 | } | |
| 488 | 744 | } | |
| 489 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>") | |
| 745 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td></tr>") | |
| 490 | 746 | if templ_7745c5c3_Err != nil { | |
| 491 | 747 | return templ_7745c5c3_Err | |
| 492 | 748 | } | |
| 493 | 749 | } | |
| 494 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>") | |
| 750 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>") | |
| 495 | 751 | if templ_7745c5c3_Err != nil { | |
| 496 | 752 | return templ_7745c5c3_Err | |
| 497 | 753 | } | |
| @@ -58,6 +58,10 @@ func Inbox(l Layout, p InboxPage) templ.Component { | |||||
| 58 | if templ_7745c5c3_Err != nil { | 58 | if templ_7745c5c3_Err != nil { | ||
| 59 | return templ_7745c5c3_Err | 59 | return templ_7745c5c3_Err | ||
| 60 | } | 60 | } | ||
| 61 | + | templ_7745c5c3_Err = InboxPending(p).Render(ctx, templ_7745c5c3_Buffer) | |||
| 62 | + | if templ_7745c5c3_Err != nil { | |||
| 63 | + | return templ_7745c5c3_Err | |||
| 64 | + | } | |||
| 61 | templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer) | 65 | templ_7745c5c3_Err = InboxMRs(p).Render(ctx, templ_7745c5c3_Buffer) | ||
| 62 | if templ_7745c5c3_Err != nil { | 66 | if templ_7745c5c3_Err != nil { | ||
| 63 | return templ_7745c5c3_Err | 67 | return templ_7745c5c3_Err | ||
| @@ -108,7 +112,7 @@ func InboxCounters(p InboxPage) templ.Component { | |||||
| 108 | var templ_7745c5c3_Var4 string | 112 | var templ_7745c5c3_Var4 string | ||
| 109 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests)) | 113 | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.MergeRequests)) | ||
| 110 | if templ_7745c5c3_Err != nil { | 114 | if templ_7745c5c3_Err != nil { | ||
| 111 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 33} | 115 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 33} |
| 112 | } | 116 | } | ||
| 113 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | 117 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | ||
| 114 | if templ_7745c5c3_Err != nil { | 118 | if templ_7745c5c3_Err != nil { | ||
| @@ -121,13 +125,26 @@ func InboxCounters(p InboxPage) templ.Component { | |||||
| 121 | var templ_7745c5c3_Var5 string | 125 | var templ_7745c5c3_Var5 string | ||
| 122 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues)) | 126 | templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Issues)) | ||
| 123 | if templ_7745c5c3_Err != nil { | 127 | if templ_7745c5c3_Err != nil { | ||
| 124 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 22, Col: 87} | 128 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 87} |
| 125 | } | 129 | } | ||
| 126 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | 130 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) | ||
| 127 | if templ_7745c5c3_Err != nil { | 131 | if templ_7745c5c3_Err != nil { | ||
| 128 | return templ_7745c5c3_Err | 132 | return templ_7745c5c3_Err | ||
| 129 | } | 133 | } | ||
| 130 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues</p>") | 134 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</strong> issues · <strong>") |
| 135 | + | if templ_7745c5c3_Err != nil { | |||
| 136 | + | return templ_7745c5c3_Err | |||
| 137 | + | } | |||
| 138 | + | var templ_7745c5c3_Var6 string | |||
| 139 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount)) | |||
| 140 | + | if templ_7745c5c3_Err != nil { | |||
| 141 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 23, Col: 139} | |||
| 142 | + | } | |||
| 143 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |||
| 144 | + | if templ_7745c5c3_Err != nil { | |||
| 145 | + | return templ_7745c5c3_Err | |||
| 146 | + | } | |||
| 147 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</strong> awaiting moderation</p>") | |||
| 131 | if templ_7745c5c3_Err != nil { | 148 | if templ_7745c5c3_Err != nil { | ||
| 132 | return templ_7745c5c3_Err | 149 | return templ_7745c5c3_Err | ||
| 133 | } | 150 | } | ||
| @@ -151,58 +168,297 @@ func InboxRepos(p InboxPage) templ.Component { | |||||
| 151 | }() | 168 | }() | ||
| 152 | } | 169 | } | ||
| 153 | ctx = templ.InitializeContext(ctx) | 170 | ctx = templ.InitializeContext(ctx) | ||
| 154 | - | templ_7745c5c3_Var6 := templ.GetChildren(ctx) | 171 | + | templ_7745c5c3_Var7 := templ.GetChildren(ctx) |
| 155 | - | if templ_7745c5c3_Var6 == nil { | 172 | + | if templ_7745c5c3_Var7 == nil { |
| 156 | - | templ_7745c5c3_Var6 = templ.NopComponent | 173 | + | templ_7745c5c3_Var7 = templ.NopComponent |
| 157 | } | 174 | } | ||
| 158 | ctx = templ.ClearChildren(ctx) | 175 | ctx = templ.ClearChildren(ctx) | ||
| 159 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") | 176 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<nav id=\"inbox-repos\" class=\"since\" aria-label=\"Repository\">") |
| 160 | if templ_7745c5c3_Err != nil { | 177 | if templ_7745c5c3_Err != nil { | ||
| 161 | return templ_7745c5c3_Err | 178 | return templ_7745c5c3_Err | ||
| 162 | } | 179 | } | ||
| 163 | for _, r := range p.Repos { | 180 | for _, r := range p.Repos { | ||
| 164 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"") | 181 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<a href=\"") |
| 165 | if templ_7745c5c3_Err != nil { | 182 | if templ_7745c5c3_Err != nil { | ||
| 166 | return templ_7745c5c3_Err | 183 | return templ_7745c5c3_Err | ||
| 167 | } | 184 | } | ||
| 168 | - | var templ_7745c5c3_Var7 templ.SafeURL | 185 | + | var templ_7745c5c3_Var8 templ.SafeURL |
| 169 | - | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | 186 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) |
| 170 | if templ_7745c5c3_Err != nil { | 187 | if templ_7745c5c3_Err != nil { | ||
| 171 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 30, Col: 32} | 188 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 31, Col: 32} |
| 172 | } | 189 | } | ||
| 173 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | 190 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) |
| 174 | if templ_7745c5c3_Err != nil { | 191 | if templ_7745c5c3_Err != nil { | ||
| 175 | return templ_7745c5c3_Err | 192 | return templ_7745c5c3_Err | ||
| 176 | } | 193 | } | ||
| 177 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"") | 194 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"") |
| 178 | if templ_7745c5c3_Err != nil { | 195 | if templ_7745c5c3_Err != nil { | ||
| 179 | return templ_7745c5c3_Err | 196 | return templ_7745c5c3_Err | ||
| 180 | } | 197 | } | ||
| 181 | if r.Current { | 198 | if r.Current { | ||
| 182 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " aria-current=\"page\"") | 199 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " aria-current=\"page\"") |
| 183 | if templ_7745c5c3_Err != nil { | 200 | if templ_7745c5c3_Err != nil { | ||
| 184 | return templ_7745c5c3_Err | 201 | return templ_7745c5c3_Err | ||
| 185 | } | 202 | } | ||
| 186 | } | 203 | } | ||
| 187 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ">") | 204 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, ">") |
| 188 | if templ_7745c5c3_Err != nil { | 205 | if templ_7745c5c3_Err != nil { | ||
| 189 | return templ_7745c5c3_Err | 206 | return templ_7745c5c3_Err | ||
| 190 | } | 207 | } | ||
| 191 | - | var templ_7745c5c3_Var8 string | 208 | + | var templ_7745c5c3_Var9 string |
| 192 | - | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) | 209 | + | templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Label) |
| 193 | if templ_7745c5c3_Err != nil { | 210 | if templ_7745c5c3_Err != nil { | ||
| 194 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 34, Col: 13} | 211 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 35, Col: 13} |
| 195 | } | 212 | } | ||
| 196 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | 213 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) |
| 197 | if templ_7745c5c3_Err != nil { | 214 | if templ_7745c5c3_Err != nil { | ||
| 198 | return templ_7745c5c3_Err | 215 | return templ_7745c5c3_Err | ||
| 199 | } | 216 | } | ||
| 200 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</a>") | 217 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</a>") |
| 201 | if templ_7745c5c3_Err != nil { | 218 | if templ_7745c5c3_Err != nil { | ||
| 202 | return templ_7745c5c3_Err | 219 | return templ_7745c5c3_Err | ||
| 203 | } | 220 | } | ||
| 204 | } | 221 | } | ||
| 205 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</nav>") | 222 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</nav>") |
| 223 | + | if templ_7745c5c3_Err != nil { | |||
| 224 | + | return templ_7745c5c3_Err | |||
| 225 | + | } | |||
| 226 | + | return nil | |||
| 227 | + | }) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // InboxPending is the issues awaiting moderation, first on the page. Their | |||
| 231 | + | // texts come from anonymous visitors: they are shown as plain text, escaped, | |||
| 232 | + | // never as Markdown. | |||
| 233 | + | func InboxPending(p InboxPage) templ.Component { | |||
| 234 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 235 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 236 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 237 | + | return templ_7745c5c3_CtxErr | |||
| 238 | + | } | |||
| 239 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 240 | + | if !templ_7745c5c3_IsBuffer { | |||
| 241 | + | defer func() { | |||
| 242 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 243 | + | if templ_7745c5c3_Err == nil { | |||
| 244 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 245 | + | } | |||
| 246 | + | }() | |||
| 247 | + | } | |||
| 248 | + | ctx = templ.InitializeContext(ctx) | |||
| 249 | + | templ_7745c5c3_Var10 := templ.GetChildren(ctx) | |||
| 250 | + | if templ_7745c5c3_Var10 == nil { | |||
| 251 | + | templ_7745c5c3_Var10 = templ.NopComponent | |||
| 252 | + | } | |||
| 253 | + | ctx = templ.ClearChildren(ctx) | |||
| 254 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<section id=\"inbox-pending\" aria-labelledby=\"inbox-pending-h\"><h2 id=\"inbox-pending-h\" class=\"rule-h\"><span>Awaiting moderation</span><span class=\"count\">") | |||
| 255 | + | if templ_7745c5c3_Err != nil { | |||
| 256 | + | return templ_7745c5c3_Err | |||
| 257 | + | } | |||
| 258 | + | var templ_7745c5c3_Var11 string | |||
| 259 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.PendingCount)) | |||
| 260 | + | if templ_7745c5c3_Err != nil { | |||
| 261 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 45, Col: 116} | |||
| 262 | + | } | |||
| 263 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |||
| 264 | + | if templ_7745c5c3_Err != nil { | |||
| 265 | + | return templ_7745c5c3_Err | |||
| 266 | + | } | |||
| 267 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span></h2>") | |||
| 268 | + | if templ_7745c5c3_Err != nil { | |||
| 269 | + | return templ_7745c5c3_Err | |||
| 270 | + | } | |||
| 271 | + | if len(p.Pending) > 0 { | |||
| 272 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<ul class=\"pending-list\">") | |||
| 273 | + | if templ_7745c5c3_Err != nil { | |||
| 274 | + | return templ_7745c5c3_Err | |||
| 275 | + | } | |||
| 276 | + | for _, m := range p.Pending { | |||
| 277 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<li class=\"pending\"><div class=\"p-head\"><a href=\"") | |||
| 278 | + | if templ_7745c5c3_Err != nil { | |||
| 279 | + | return templ_7745c5c3_Err | |||
| 280 | + | } | |||
| 281 | + | var templ_7745c5c3_Var12 templ.SafeURL | |||
| 282 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | |||
| 283 | + | if templ_7745c5c3_Err != nil { | |||
| 284 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 42} | |||
| 285 | + | } | |||
| 286 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |||
| 287 | + | if templ_7745c5c3_Err != nil { | |||
| 288 | + | return templ_7745c5c3_Err | |||
| 289 | + | } | |||
| 290 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">") | |||
| 291 | + | if templ_7745c5c3_Err != nil { | |||
| 292 | + | return templ_7745c5c3_Err | |||
| 293 | + | } | |||
| 294 | + | var templ_7745c5c3_Var13 string | |||
| 295 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | |||
| 296 | + | if templ_7745c5c3_Err != nil { | |||
| 297 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 53} | |||
| 298 | + | } | |||
| 299 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |||
| 300 | + | if templ_7745c5c3_Err != nil { | |||
| 301 | + | return templ_7745c5c3_Err | |||
| 302 | + | } | |||
| 303 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a> <span>") | |||
| 304 | + | if templ_7745c5c3_Err != nil { | |||
| 305 | + | return templ_7745c5c3_Err | |||
| 306 | + | } | |||
| 307 | + | var templ_7745c5c3_Var14 string | |||
| 308 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(m.Author) | |||
| 309 | + | if templ_7745c5c3_Err != nil { | |||
| 310 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 52, Col: 23} | |||
| 311 | + | } | |||
| 312 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | |||
| 313 | + | if templ_7745c5c3_Err != nil { | |||
| 314 | + | return templ_7745c5c3_Err | |||
| 315 | + | } | |||
| 316 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span> ") | |||
| 317 | + | if templ_7745c5c3_Err != nil { | |||
| 318 | + | return templ_7745c5c3_Err | |||
| 319 | + | } | |||
| 320 | + | if m.WhenAt != "" { | |||
| 321 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<time datetime=\"") | |||
| 322 | + | if templ_7745c5c3_Err != nil { | |||
| 323 | + | return templ_7745c5c3_Err | |||
| 324 | + | } | |||
| 325 | + | var templ_7745c5c3_Var15 string | |||
| 326 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |||
| 327 | + | if templ_7745c5c3_Err != nil { | |||
| 328 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 33} | |||
| 329 | + | } | |||
| 330 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |||
| 331 | + | if templ_7745c5c3_Err != nil { | |||
| 332 | + | return templ_7745c5c3_Err | |||
| 333 | + | } | |||
| 334 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" title=\"") | |||
| 335 | + | if templ_7745c5c3_Err != nil { | |||
| 336 | + | return templ_7745c5c3_Err | |||
| 337 | + | } | |||
| 338 | + | var templ_7745c5c3_Var16 string | |||
| 339 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | |||
| 340 | + | if templ_7745c5c3_Err != nil { | |||
| 341 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 52} | |||
| 342 | + | } | |||
| 343 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |||
| 344 | + | if templ_7745c5c3_Err != nil { | |||
| 345 | + | return templ_7745c5c3_Err | |||
| 346 | + | } | |||
| 347 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") | |||
| 348 | + | if templ_7745c5c3_Err != nil { | |||
| 349 | + | return templ_7745c5c3_Err | |||
| 350 | + | } | |||
| 351 | + | var templ_7745c5c3_Var17 string | |||
| 352 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | |||
| 353 | + | if templ_7745c5c3_Err != nil { | |||
| 354 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 54, Col: 63} | |||
| 355 | + | } | |||
| 356 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | |||
| 357 | + | if templ_7745c5c3_Err != nil { | |||
| 358 | + | return templ_7745c5c3_Err | |||
| 359 | + | } | |||
| 360 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</time>") | |||
| 361 | + | if templ_7745c5c3_Err != nil { | |||
| 362 | + | return templ_7745c5c3_Err | |||
| 363 | + | } | |||
| 364 | + | } | |||
| 365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</div><p class=\"p-title\">") | |||
| 366 | + | if templ_7745c5c3_Err != nil { | |||
| 367 | + | return templ_7745c5c3_Err | |||
| 368 | + | } | |||
| 369 | + | var templ_7745c5c3_Var18 string | |||
| 370 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | |||
| 371 | + | if templ_7745c5c3_Err != nil { | |||
| 372 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 57, Col: 34} | |||
| 373 | + | } | |||
| 374 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | |||
| 375 | + | if templ_7745c5c3_Err != nil { | |||
| 376 | + | return templ_7745c5c3_Err | |||
| 377 | + | } | |||
| 378 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</p>") | |||
| 379 | + | if templ_7745c5c3_Err != nil { | |||
| 380 | + | return templ_7745c5c3_Err | |||
| 381 | + | } | |||
| 382 | + | if m.Preview != "" { | |||
| 383 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<p class=\"p-body\">") | |||
| 384 | + | if templ_7745c5c3_Err != nil { | |||
| 385 | + | return templ_7745c5c3_Err | |||
| 386 | + | } | |||
| 387 | + | var templ_7745c5c3_Var19 string | |||
| 388 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Preview) | |||
| 389 | + | if templ_7745c5c3_Err != nil { | |||
| 390 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 59, Col: 36} | |||
| 391 | + | } | |||
| 392 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | |||
| 393 | + | if templ_7745c5c3_Err != nil { | |||
| 394 | + | return templ_7745c5c3_Err | |||
| 395 | + | } | |||
| 396 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</p>") | |||
| 397 | + | if templ_7745c5c3_Err != nil { | |||
| 398 | + | return templ_7745c5c3_Err | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | if m.Approve != "" { | |||
| 402 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"p-btns\"><form method=\"post\" action=\"") | |||
| 403 | + | if templ_7745c5c3_Err != nil { | |||
| 404 | + | return templ_7745c5c3_Err | |||
| 405 | + | } | |||
| 406 | + | var templ_7745c5c3_Var20 templ.SafeURL | |||
| 407 | + | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Approve)) | |||
| 408 | + | if templ_7745c5c3_Err != nil { | |||
| 409 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 63, Col: 61} | |||
| 410 | + | } | |||
| 411 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) | |||
| 412 | + | if templ_7745c5c3_Err != nil { | |||
| 413 | + | return templ_7745c5c3_Err | |||
| 414 | + | } | |||
| 415 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\"><button type=\"submit\" class=\"btn primary\">Approve</button></form><form method=\"post\" action=\"") | |||
| 416 | + | if templ_7745c5c3_Err != nil { | |||
| 417 | + | return templ_7745c5c3_Err | |||
| 418 | + | } | |||
| 419 | + | var templ_7745c5c3_Var21 templ.SafeURL | |||
| 420 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Reject)) | |||
| 421 | + | if templ_7745c5c3_Err != nil { | |||
| 422 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 64, Col: 60} | |||
| 423 | + | } | |||
| 424 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |||
| 425 | + | if templ_7745c5c3_Err != nil { | |||
| 426 | + | return templ_7745c5c3_Err | |||
| 427 | + | } | |||
| 428 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\"><button type=\"submit\" class=\"btn\">Reject</button></form><form method=\"post\" action=\"") | |||
| 429 | + | if templ_7745c5c3_Err != nil { | |||
| 430 | + | return templ_7745c5c3_Err | |||
| 431 | + | } | |||
| 432 | + | var templ_7745c5c3_Var22 templ.SafeURL | |||
| 433 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Ban)) | |||
| 434 | + | if templ_7745c5c3_Err != nil { | |||
| 435 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 57} | |||
| 436 | + | } | |||
| 437 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |||
| 438 | + | if templ_7745c5c3_Err != nil { | |||
| 439 | + | return templ_7745c5c3_Err | |||
| 440 | + | } | |||
| 441 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\"><button type=\"submit\" class=\"btn danger\">Reject and ban</button></form></div>") | |||
| 442 | + | if templ_7745c5c3_Err != nil { | |||
| 443 | + | return templ_7745c5c3_Err | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</li>") | |||
| 447 | + | if templ_7745c5c3_Err != nil { | |||
| 448 | + | return templ_7745c5c3_Err | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</ul>") | |||
| 452 | + | if templ_7745c5c3_Err != nil { | |||
| 453 | + | return templ_7745c5c3_Err | |||
| 454 | + | } | |||
| 455 | + | } else { | |||
| 456 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<p class=\"lede dim\">nothing awaits moderation.</p>") | |||
| 457 | + | if templ_7745c5c3_Err != nil { | |||
| 458 | + | return templ_7745c5c3_Err | |||
| 459 | + | } | |||
| 460 | + | } | |||
| 461 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</section>") | |||
| 206 | if templ_7745c5c3_Err != nil { | 462 | if templ_7745c5c3_Err != nil { | ||
| 207 | return templ_7745c5c3_Err | 463 | return templ_7745c5c3_Err | ||
| 208 | } | 464 | } | ||
| @@ -226,25 +482,25 @@ func InboxMRs(p InboxPage) templ.Component { | |||||
| 226 | }() | 482 | }() | ||
| 227 | } | 483 | } | ||
| 228 | ctx = templ.InitializeContext(ctx) | 484 | ctx = templ.InitializeContext(ctx) | ||
| 229 | - | templ_7745c5c3_Var9 := templ.GetChildren(ctx) | 485 | + | templ_7745c5c3_Var23 := templ.GetChildren(ctx) |
| 230 | - | if templ_7745c5c3_Var9 == nil { | 486 | + | if templ_7745c5c3_Var23 == nil { |
| 231 | - | templ_7745c5c3_Var9 = templ.NopComponent | 487 | + | templ_7745c5c3_Var23 = templ.NopComponent |
| 232 | } | 488 | } | ||
| 233 | ctx = templ.ClearChildren(ctx) | 489 | ctx = templ.ClearChildren(ctx) | ||
| 234 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") | 490 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<section id=\"inbox-mrs\" aria-labelledby=\"inbox-mrs-h\"><h2 id=\"inbox-mrs-h\" class=\"rule-h\"><span>Open merge requests</span><span class=\"count\">") |
| 235 | if templ_7745c5c3_Err != nil { | 491 | if templ_7745c5c3_Err != nil { | ||
| 236 | return templ_7745c5c3_Err | 492 | return templ_7745c5c3_Err | ||
| 237 | } | 493 | } | ||
| 238 | - | var templ_7745c5c3_Var10 string | 494 | + | var templ_7745c5c3_Var24 string |
| 239 | - | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) | 495 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.MRs))) |
| 240 | if templ_7745c5c3_Err != nil { | 496 | if templ_7745c5c3_Err != nil { | ||
| 241 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 41, Col: 108} | 497 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 79, Col: 108} |
| 242 | } | 498 | } | ||
| 243 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | 499 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) |
| 244 | if templ_7745c5c3_Err != nil { | 500 | if templ_7745c5c3_Err != nil { | ||
| 245 | return templ_7745c5c3_Err | 501 | return templ_7745c5c3_Err | ||
| 246 | } | 502 | } | ||
| 247 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</span></h2>") | 503 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></h2>") |
| 248 | if templ_7745c5c3_Err != nil { | 504 | if templ_7745c5c3_Err != nil { | ||
| 249 | return templ_7745c5c3_Err | 505 | return templ_7745c5c3_Err | ||
| 250 | } | 506 | } | ||
| @@ -253,12 +509,12 @@ func InboxMRs(p InboxPage) templ.Component { | |||||
| 253 | return templ_7745c5c3_Err | 509 | return templ_7745c5c3_Err | ||
| 254 | } | 510 | } | ||
| 255 | if len(p.MRs) == 0 { | 511 | if len(p.MRs) == 0 { | ||
| 256 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p class=\"lede dim\">no open merge requests.</p>") | 512 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<p class=\"lede dim\">no open merge requests.</p>") |
| 257 | if templ_7745c5c3_Err != nil { | 513 | if templ_7745c5c3_Err != nil { | ||
| 258 | return templ_7745c5c3_Err | 514 | return templ_7745c5c3_Err | ||
| 259 | } | 515 | } | ||
| 260 | } | 516 | } | ||
| 261 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</section>") | 517 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</section>") |
| 262 | if templ_7745c5c3_Err != nil { | 518 | if templ_7745c5c3_Err != nil { | ||
| 263 | return templ_7745c5c3_Err | 519 | return templ_7745c5c3_Err | ||
| 264 | } | 520 | } | ||
| @@ -282,25 +538,25 @@ func InboxIssues(p InboxPage) templ.Component { | |||||
| 282 | }() | 538 | }() | ||
| 283 | } | 539 | } | ||
| 284 | ctx = templ.InitializeContext(ctx) | 540 | ctx = templ.InitializeContext(ctx) | ||
| 285 | - | templ_7745c5c3_Var11 := templ.GetChildren(ctx) | 541 | + | templ_7745c5c3_Var25 := templ.GetChildren(ctx) |
| 286 | - | if templ_7745c5c3_Var11 == nil { | 542 | + | if templ_7745c5c3_Var25 == nil { |
| 287 | - | templ_7745c5c3_Var11 = templ.NopComponent | 543 | + | templ_7745c5c3_Var25 = templ.NopComponent |
| 288 | } | 544 | } | ||
| 289 | ctx = templ.ClearChildren(ctx) | 545 | ctx = templ.ClearChildren(ctx) | ||
| 290 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") | 546 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<section id=\"inbox-issues\" aria-labelledby=\"inbox-issues-h\"><h2 id=\"inbox-issues-h\" class=\"rule-h\"><span>Open issues</span><span class=\"count\">") |
| 291 | if templ_7745c5c3_Err != nil { | 547 | if templ_7745c5c3_Err != nil { | ||
| 292 | return templ_7745c5c3_Err | 548 | return templ_7745c5c3_Err | ||
| 293 | } | 549 | } | ||
| 294 | - | var templ_7745c5c3_Var12 string | 550 | + | var templ_7745c5c3_Var26 string |
| 295 | - | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) | 551 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.IssueRows))) |
| 296 | if templ_7745c5c3_Err != nil { | 552 | if templ_7745c5c3_Err != nil { | ||
| 297 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 51, Col: 109} | 553 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 89, Col: 109} |
| 298 | } | 554 | } | ||
| 299 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | 555 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) |
| 300 | if templ_7745c5c3_Err != nil { | 556 | if templ_7745c5c3_Err != nil { | ||
| 301 | return templ_7745c5c3_Err | 557 | return templ_7745c5c3_Err | ||
| 302 | } | 558 | } | ||
| 303 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</span></h2>") | 559 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span></h2>") |
| 304 | if templ_7745c5c3_Err != nil { | 560 | if templ_7745c5c3_Err != nil { | ||
| 305 | return templ_7745c5c3_Err | 561 | return templ_7745c5c3_Err | ||
| 306 | } | 562 | } | ||
| @@ -309,12 +565,12 @@ func InboxIssues(p InboxPage) templ.Component { | |||||
| 309 | return templ_7745c5c3_Err | 565 | return templ_7745c5c3_Err | ||
| 310 | } | 566 | } | ||
| 311 | if len(p.IssueRows) == 0 { | 567 | if len(p.IssueRows) == 0 { | ||
| 312 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "<p class=\"lede dim\">no open issues.</p>") | 568 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<p class=\"lede dim\">no open issues.</p>") |
| 313 | if templ_7745c5c3_Err != nil { | 569 | if templ_7745c5c3_Err != nil { | ||
| 314 | return templ_7745c5c3_Err | 570 | return templ_7745c5c3_Err | ||
| 315 | } | 571 | } | ||
| 316 | } | 572 | } | ||
| 317 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</section>") | 573 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</section>") |
| 318 | if templ_7745c5c3_Err != nil { | 574 | if templ_7745c5c3_Err != nil { | ||
| 319 | return templ_7745c5c3_Err | 575 | return templ_7745c5c3_Err | ||
| 320 | } | 576 | } | ||
| @@ -338,160 +594,160 @@ func inboxRows(rows []InboxRow, label string) templ.Component { | |||||
| 338 | }() | 594 | }() | ||
| 339 | } | 595 | } | ||
| 340 | ctx = templ.InitializeContext(ctx) | 596 | ctx = templ.InitializeContext(ctx) | ||
| 341 | - | templ_7745c5c3_Var13 := templ.GetChildren(ctx) | 597 | + | templ_7745c5c3_Var27 := templ.GetChildren(ctx) |
| 342 | - | if templ_7745c5c3_Var13 == nil { | 598 | + | if templ_7745c5c3_Var27 == nil { |
| 343 | - | templ_7745c5c3_Var13 = templ.NopComponent | 599 | + | templ_7745c5c3_Var27 = templ.NopComponent |
| 344 | } | 600 | } | ||
| 345 | ctx = templ.ClearChildren(ctx) | 601 | ctx = templ.ClearChildren(ctx) | ||
| 346 | if len(rows) > 0 { | 602 | if len(rows) > 0 { | ||
| 347 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<table class=\"list mrs\" aria-label=\"") | 603 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<table class=\"list mrs\" aria-label=\"") |
| 348 | if templ_7745c5c3_Err != nil { | 604 | if templ_7745c5c3_Err != nil { | ||
| 349 | return templ_7745c5c3_Err | 605 | return templ_7745c5c3_Err | ||
| 350 | } | 606 | } | ||
| 351 | - | var templ_7745c5c3_Var14 string | 607 | + | var templ_7745c5c3_Var28 string |
| 352 | - | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) | 608 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(label) |
| 353 | if templ_7745c5c3_Err != nil { | 609 | if templ_7745c5c3_Err != nil { | ||
| 354 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 61, Col: 44} | 610 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 99, Col: 44} |
| 355 | } | 611 | } | ||
| 356 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | 612 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) |
| 357 | if templ_7745c5c3_Err != nil { | 613 | if templ_7745c5c3_Err != nil { | ||
| 358 | return templ_7745c5c3_Err | 614 | return templ_7745c5c3_Err | ||
| 359 | } | 615 | } | ||
| 360 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"><tbody data-rows>") | 616 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\"><tbody data-rows>") |
| 361 | if templ_7745c5c3_Err != nil { | 617 | if templ_7745c5c3_Err != nil { | ||
| 362 | return templ_7745c5c3_Err | 618 | return templ_7745c5c3_Err | ||
| 363 | } | 619 | } | ||
| 364 | for _, m := range rows { | 620 | for _, m := range rows { | ||
| 365 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<tr") | 621 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<tr") |
| 366 | if templ_7745c5c3_Err != nil { | 622 | if templ_7745c5c3_Err != nil { | ||
| 367 | return templ_7745c5c3_Err | 623 | return templ_7745c5c3_Err | ||
| 368 | } | 624 | } | ||
| 369 | if m.Cursor { | 625 | if m.Cursor { | ||
| 370 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " data-cursor") | 626 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " data-cursor") |
| 371 | if templ_7745c5c3_Err != nil { | 627 | if templ_7745c5c3_Err != nil { | ||
| 372 | return templ_7745c5c3_Err | 628 | return templ_7745c5c3_Err | ||
| 373 | } | 629 | } | ||
| 374 | } | 630 | } | ||
| 375 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " data-state=\"open\"><td class=\"c-n mute\">") | 631 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " data-state=\"open\"><td class=\"c-n mute\">") |
| 376 | if templ_7745c5c3_Err != nil { | 632 | if templ_7745c5c3_Err != nil { | ||
| 377 | return templ_7745c5c3_Err | 633 | return templ_7745c5c3_Err | ||
| 378 | } | 634 | } | ||
| 379 | - | var templ_7745c5c3_Var15 string | 635 | + | var templ_7745c5c3_Var29 string |
| 380 | - | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) | 636 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(m.Number) |
| 381 | if templ_7745c5c3_Err != nil { | 637 | if templ_7745c5c3_Err != nil { | ||
| 382 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 65, Col: 37} | 638 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 103, Col: 37} |
| 383 | } | 639 | } | ||
| 384 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | 640 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) |
| 385 | if templ_7745c5c3_Err != nil { | 641 | if templ_7745c5c3_Err != nil { | ||
| 386 | return templ_7745c5c3_Err | 642 | return templ_7745c5c3_Err | ||
| 387 | } | 643 | } | ||
| 388 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</td><td class=\"c-subject\"><a href=\"") | 644 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-subject\"><a href=\"") |
| 389 | if templ_7745c5c3_Err != nil { | 645 | if templ_7745c5c3_Err != nil { | ||
| 390 | return templ_7745c5c3_Err | 646 | return templ_7745c5c3_Err | ||
| 391 | } | 647 | } | ||
| 392 | - | var templ_7745c5c3_Var16 templ.SafeURL | 648 | + | var templ_7745c5c3_Var30 templ.SafeURL |
| 393 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) | 649 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.Href)) |
| 394 | if templ_7745c5c3_Err != nil { | 650 | if templ_7745c5c3_Err != nil { | ||
| 395 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 59} | 651 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 59} |
| 396 | } | 652 | } | ||
| 397 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | 653 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) |
| 398 | if templ_7745c5c3_Err != nil { | 654 | if templ_7745c5c3_Err != nil { | ||
| 399 | return templ_7745c5c3_Err | 655 | return templ_7745c5c3_Err | ||
| 400 | } | 656 | } | ||
| 401 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") | 657 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\">") |
| 402 | if templ_7745c5c3_Err != nil { | 658 | if templ_7745c5c3_Err != nil { | ||
| 403 | return templ_7745c5c3_Err | 659 | return templ_7745c5c3_Err | ||
| 404 | } | 660 | } | ||
| 405 | - | var templ_7745c5c3_Var17 string | 661 | + | var templ_7745c5c3_Var31 string |
| 406 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) | 662 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) |
| 407 | if templ_7745c5c3_Err != nil { | 663 | if templ_7745c5c3_Err != nil { | ||
| 408 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 71} | 664 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 71} |
| 409 | } | 665 | } | ||
| 410 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) | 666 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) |
| 411 | if templ_7745c5c3_Err != nil { | 667 | if templ_7745c5c3_Err != nil { | ||
| 412 | return templ_7745c5c3_Err | 668 | return templ_7745c5c3_Err | ||
| 413 | } | 669 | } | ||
| 414 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</a> <a class=\"mute\" href=\"") | 670 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</a> <a class=\"mute\" href=\"") |
| 415 | if templ_7745c5c3_Err != nil { | 671 | if templ_7745c5c3_Err != nil { | ||
| 416 | return templ_7745c5c3_Err | 672 | return templ_7745c5c3_Err | ||
| 417 | } | 673 | } | ||
| 418 | - | var templ_7745c5c3_Var18 templ.SafeURL | 674 | + | var templ_7745c5c3_Var32 templ.SafeURL |
| 419 | - | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) | 675 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(m.RepoHref)) |
| 420 | if templ_7745c5c3_Err != nil { | 676 | if templ_7745c5c3_Err != nil { | ||
| 421 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 126} | 677 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 126} |
| 422 | } | 678 | } | ||
| 423 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | 679 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) |
| 424 | if templ_7745c5c3_Err != nil { | 680 | if templ_7745c5c3_Err != nil { | ||
| 425 | return templ_7745c5c3_Err | 681 | return templ_7745c5c3_Err | ||
| 426 | } | 682 | } | ||
| 427 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\">") | 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">") |
| 428 | if templ_7745c5c3_Err != nil { | 684 | if templ_7745c5c3_Err != nil { | ||
| 429 | return templ_7745c5c3_Err | 685 | return templ_7745c5c3_Err | ||
| 430 | } | 686 | } | ||
| 431 | - | var templ_7745c5c3_Var19 string | 687 | + | var templ_7745c5c3_Var33 string |
| 432 | - | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) | 688 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(m.Repo) |
| 433 | if templ_7745c5c3_Err != nil { | 689 | if templ_7745c5c3_Err != nil { | ||
| 434 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 66, Col: 137} | 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 104, Col: 137} |
| 435 | } | 691 | } | ||
| 436 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) |
| 437 | if templ_7745c5c3_Err != nil { | 693 | if templ_7745c5c3_Err != nil { | ||
| 438 | return templ_7745c5c3_Err | 694 | return templ_7745c5c3_Err | ||
| 439 | } | 695 | } | ||
| 440 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</a></td><td class=\"c-when nowrap dim\">") | 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</a></td><td class=\"c-when nowrap dim\">") |
| 441 | if templ_7745c5c3_Err != nil { | 697 | if templ_7745c5c3_Err != nil { | ||
| 442 | return templ_7745c5c3_Err | 698 | return templ_7745c5c3_Err | ||
| 443 | } | 699 | } | ||
| 444 | if m.WhenAt != "" { | 700 | if m.WhenAt != "" { | ||
| 445 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<time datetime=\"") | 701 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<time datetime=\"") |
| 446 | if templ_7745c5c3_Err != nil { | 702 | if templ_7745c5c3_Err != nil { | ||
| 447 | return templ_7745c5c3_Err | 703 | return templ_7745c5c3_Err | ||
| 448 | } | 704 | } | ||
| 449 | - | var templ_7745c5c3_Var20 string | 705 | + | var templ_7745c5c3_Var34 string |
| 450 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | 706 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) |
| 451 | if templ_7745c5c3_Err != nil { | 707 | if templ_7745c5c3_Err != nil { | ||
| 452 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 33} | 708 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 33} |
| 453 | } | 709 | } | ||
| 454 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | 710 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34) |
| 455 | if templ_7745c5c3_Err != nil { | 711 | if templ_7745c5c3_Err != nil { | ||
| 456 | return templ_7745c5c3_Err | 712 | return templ_7745c5c3_Err | ||
| 457 | } | 713 | } | ||
| 458 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" title=\"") | 714 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "\" title=\"") |
| 459 | if templ_7745c5c3_Err != nil { | 715 | if templ_7745c5c3_Err != nil { | ||
| 460 | return templ_7745c5c3_Err | 716 | return templ_7745c5c3_Err | ||
| 461 | } | 717 | } | ||
| 462 | - | var templ_7745c5c3_Var21 string | 718 | + | var templ_7745c5c3_Var35 string |
| 463 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) | 719 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.WhenAt) |
| 464 | if templ_7745c5c3_Err != nil { | 720 | if templ_7745c5c3_Err != nil { | ||
| 465 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 52} | 721 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 52} |
| 466 | } | 722 | } | ||
| 467 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | 723 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35) |
| 468 | if templ_7745c5c3_Err != nil { | 724 | if templ_7745c5c3_Err != nil { | ||
| 469 | return templ_7745c5c3_Err | 725 | return templ_7745c5c3_Err | ||
| 470 | } | 726 | } | ||
| 471 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">") | 727 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\">") |
| 472 | if templ_7745c5c3_Err != nil { | 728 | if templ_7745c5c3_Err != nil { | ||
| 473 | return templ_7745c5c3_Err | 729 | return templ_7745c5c3_Err | ||
| 474 | } | 730 | } | ||
| 475 | - | var templ_7745c5c3_Var22 string | 731 | + | var templ_7745c5c3_Var36 string |
| 476 | - | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) | 732 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(m.When) |
| 477 | if templ_7745c5c3_Err != nil { | 733 | if templ_7745c5c3_Err != nil { | ||
| 478 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 69, Col: 63} | 734 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/inbox.templ`, Line: 107, Col: 63} |
| 479 | } | 735 | } | ||
| 480 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | 736 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) |
| 481 | if templ_7745c5c3_Err != nil { | 737 | if templ_7745c5c3_Err != nil { | ||
| 482 | return templ_7745c5c3_Err | 738 | return templ_7745c5c3_Err | ||
| 483 | } | 739 | } | ||
| 484 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</time>") | 740 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</time>") |
| 485 | if templ_7745c5c3_Err != nil { | 741 | if templ_7745c5c3_Err != nil { | ||
| 486 | return templ_7745c5c3_Err | 742 | return templ_7745c5c3_Err | ||
| 487 | } | 743 | } | ||
| 488 | } | 744 | } | ||
| 489 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</td></tr>") | 745 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</td></tr>") |
| 490 | if templ_7745c5c3_Err != nil { | 746 | if templ_7745c5c3_Err != nil { | ||
| 491 | return templ_7745c5c3_Err | 747 | return templ_7745c5c3_Err | ||
| 492 | } | 748 | } | ||
| 493 | } | 749 | } | ||
| 494 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</tbody></table>") | 750 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>") |
| 495 | if templ_7745c5c3_Err != nil { | 751 | if templ_7745c5c3_Err != nil { | ||
| 496 | return templ_7745c5c3_Err | 752 | return templ_7745c5c3_Err | ||
| 497 | } | 753 | } | ||
M internal/adapters/web/inbox_test.go +5 -4
| @@ -180,11 +180,12 @@ func TestInboxLiveFragments(t *testing.T) { | |||
| 180 | 180 | ls := &liveStream{s: srv, view: "inbox"} | |
| 181 | 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 | 182 | require.NoError(t, err) | |
| 183 | - | require.Len(t, frags, 4) | |
| 183 | + | require.Len(t, frags, 5) | |
| 184 | 184 | assert.Contains(t, frags[0].html, `id="inbox-counts"`) | |
| 185 | 185 | 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"`) | |
| 188 | 189 | ||
| 189 | 190 | none, err := ls.render(withOwner(t.Context()), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "beta")}}) | |
| 190 | 191 | require.NoError(t, err) | |
| @@ -296,7 +297,7 @@ func TestInboxRefreshesOnRepoEvents(t *testing.T) { | |||
| 296 | 297 | ls := &liveStream{s: srv, view: "inbox"} | |
| 297 | 298 | frags, err := ls.render(withOwner(t.Context()), ports.WatchEvent{ID: "b-1", Event: ev}) | |
| 298 | 299 | require.NoError(t, err) | |
| 299 | - | require.Len(t, frags, 4) | |
| 300 | + | require.Len(t, frags, 5) | |
| 300 | 301 | all := "" | |
| 301 | 302 | for _, f := range frags { | |
| 302 | 303 | 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.html | 303 | 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 | |||
| 179 | 179 | if token != "" { | |
| 180 | 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 | 183 | p.NewHref = issuesBase(d.name()) + "/new" | |
| 184 | 184 | } | |
| 185 | 185 | ||
| @@ -179,7 +179,7 @@ func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Componen | |||||
| 179 | if token != "" { | 179 | if token != "" { | ||
| 180 | p.Newest = link("") | 180 | p.Newest = link("") | ||
| 181 | } | 181 | } | ||
| 182 | - | if SignedIn(ctx) { | 182 | + | if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.Issues.OrDefault() == domain.IssuesPublic) { |
| 183 | p.NewHref = issuesBase(d.name()) + "/new" | 183 | p.NewHref = issuesBase(d.name()) + "/new" | ||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
A internal/adapters/web/issues_form.go +276 -0
| @@ -0,0 +1,276 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/a-h/templ" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 17 | + | "bnema.dev/ermite/internal/domain" | |
| 18 | + | "bnema.dev/ermite/internal/ports" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // The public issue form (ADR 0031). A visitor of a public repository whose | |
| 22 | + | // issues mode is public writes an issue that awaits the owner's moderation. | |
| 23 | + | // | |
| 24 | + | // GET /<repo>/issues/new the form, with a proof of work solved by script | |
| 25 | + | // POST /<repo>/issues/new the submission (the one write a visitor may make) | |
| 26 | + | // GET /<repo>/issues/submitted the thanks page | |
| 27 | + | // | |
| 28 | + | // Every answer is "Cache-Control: no-store". The challenge token is never | |
| 29 | + | // echoed back, and neither the address of the client nor the text of the | |
| 30 | + | // submission is logged. A refusal for a bot-related reason is one generic page. | |
| 31 | + | ||
| 32 | + | // maxIssueFormBytes bounds the body of the submission: 10,000 characters are | |
| 33 | + | // at most 40,000 bytes, plus the title, the name and the tokens. | |
| 34 | + | const maxIssueFormBytes = 64 << 10 | |
| 35 | + | ||
| 36 | + | // The fields of the form, and so the only ones read. | |
| 37 | + | const ( | |
| 38 | + | fieldTitle = "title" | |
| 39 | + | fieldBody = "body" | |
| 40 | + | fieldAuthor = "author" | |
| 41 | + | fieldToken = "token" | |
| 42 | + | fieldFormToken = "form_token" | |
| 43 | + | fieldSolution = "solution" | |
| 44 | + | fieldHoneypot = "website" | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot} | |
| 48 | + | ||
| 49 | + | // IssueFormPage is the public form. | |
| 50 | + | type IssueFormPage struct { | |
| 51 | + | Action, Cancel string | |
| 52 | + | Token string | |
| 53 | + | FormToken string | |
| 54 | + | Nonce string | |
| 55 | + | Difficulty int | |
| 56 | + | MinSubmitAt int64 // unix milliseconds | |
| 57 | + | Worker string | |
| 58 | + | // Title, Body and Author are what the visitor already wrote. | |
| 59 | + | Title, Body, Author string | |
| 60 | + | // ErrorField is "title", "body" or "author" and Error the message to show | |
| 61 | + | // next to it; both empty without an error. | |
| 62 | + | ErrorField, Error string | |
| 63 | + | // Max lengths, in characters. | |
| 64 | + | MaxTitle, MaxBody, MaxAuthor int | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in, | |
| 68 | + | // else the public form, else the page that is not there. | |
| 69 | + | func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) { | |
| 70 | + | if SignedIn(r.Context()) || s.cfg.IssueForm == nil { | |
| 71 | + | s.page(s.issueNew)(w, r) | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 75 | + | defer cancel() | |
| 76 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 77 | + | if err != nil { | |
| 78 | + | s.fail(ctx, w, r, err) | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | page := IssueFormPage{} | |
| 82 | + | status, err := s.fillIssueForm(ctx, r, name, &page) | |
| 83 | + | if err != nil { | |
| 84 | + | s.formFailed(ctx, w, r, err, page.Cancel) | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | s.writeIssueForm(ctx, w, r, status, name, page) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // fillIssueForm asks a challenge for the client and fills what the form needs | |
| 91 | + | // from it, leaving the text of the visitor as it is. | |
| 92 | + | func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) { | |
| 93 | + | base := issuesBase(name.String()) | |
| 94 | + | p.Cancel = base | |
| 95 | + | ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r)) | |
| 96 | + | if err != nil { | |
| 97 | + | return 0, err | |
| 98 | + | } | |
| 99 | + | p.Action = base + "/new" | |
| 100 | + | p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty | |
| 101 | + | p.MinSubmitAt = ch.MinSubmitAt.UnixMilli() | |
| 102 | + | p.Worker = s.assets.urls.PowWorker | |
| 103 | + | p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen | |
| 104 | + | return http.StatusOK, nil | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // formClient is the key of the client for the limits of the submissions. | |
| 108 | + | func (s *Server) formClient(r *http.Request) string { | |
| 109 | + | return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // writeIssueForm renders the form in the layout of the issues of the repository. | |
| 113 | + | func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) { | |
| 114 | + | d, err := s.issueRepo(ctx, name.String()) | |
| 115 | + | if err != nil { | |
| 116 | + | s.fail(ctx, w, r, err) | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | l := s.repoLayout(ctx, d, "issues") | |
| 120 | + | l.Title = "new issue · " + d.name() + " · " + s.instance | |
| 121 | + | l.Crumbs[0].Current = false | |
| 122 | + | l.Crumbs = append(l.Crumbs, | |
| 123 | + | Crumb{Label: "issues", Href: issuesBase(d.name())}, | |
| 124 | + | Crumb{Label: "new", Current: true}, | |
| 125 | + | ) | |
| 126 | + | l.HasRail = true | |
| 127 | + | l.IssueForm = true | |
| 128 | + | w.Header().Set("Cache-Control", noStore) | |
| 129 | + | s.write(ctx, w, r, status, IssueForm(l, p)) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // formFailed answers an error of the challenge or of the submission. | |
| 133 | + | func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |
| 134 | + | var retry *domain.SubmissionRetryError | |
| 135 | + | switch { | |
| 136 | + | case errors.Is(err, context.Canceled): | |
| 137 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText): | |
| 138 | + | // The same page, with the same headers, as every other page that is | |
| 139 | + | // not there (a POST has set no-store already). | |
| 140 | + | s.fail(ctx, w, r, domain.ErrNotFound) | |
| 141 | + | case errors.As(err, &retry): | |
| 142 | + | secs := int(retry.After.Round(time.Second).Seconds()) | |
| 143 | + | if retry.After > time.Duration(secs)*time.Second { | |
| 144 | + | secs++ | |
| 145 | + | } | |
| 146 | + | secs = max(secs, 1) | |
| 147 | + | w.Header().Set("Retry-After", strconv.Itoa(secs)) | |
| 148 | + | mins := (secs + 59) / 60 | |
| 149 | + | unit := " minutes." | |
| 150 | + | if mins == 1 { | |
| 151 | + | unit = " minute." | |
| 152 | + | } | |
| 153 | + | s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions", | |
| 154 | + | "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back) | |
| 155 | + | case errors.Is(err, domain.ErrSubmissionsPaused): | |
| 156 | + | s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back) | |
| 157 | + | case errors.Is(err, domain.ErrSubmissionRefused): | |
| 158 | + | s.refusal(ctx, w, r, http.StatusBadRequest, "not verified", | |
| 159 | + | "The submission could not be verified. Reload the page and try again.", back) | |
| 160 | + | default: | |
| 161 | + | s.internalFailure(ctx, w, r, err) | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // refusal writes a state page for a visitor, never cached. | |
| 166 | + | func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) { | |
| 167 | + | if back == "" { | |
| 168 | + | back = "/" | |
| 169 | + | } | |
| 170 | + | l := s.stateLayout(title, []Crumb{{Label: title, Current: true}}) | |
| 171 | + | w.Header().Set("Cache-Control", noStore) | |
| 172 | + | s.write(ctx, w, r, status, State(l, StatePage{ | |
| 173 | + | Code: itoa(status), Title: title, Lines: []string{line}, | |
| 174 | + | Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}}, | |
| 175 | + | })) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // issueFormPost is POST /<repo>/issues/new. | |
| 179 | + | func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) { | |
| 180 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 181 | + | defer cancel() | |
| 182 | + | w.Header().Set("Cache-Control", noStore) | |
| 183 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |
| 184 | + | if err != nil { | |
| 185 | + | s.formFailed(ctx, w, r, err, "") | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | base := issuesBase(name.String()) | |
| 189 | + | r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes) | |
| 190 | + | if err := r.ParseForm(); err != nil { | |
| 191 | + | var tooBig *http.MaxBytesError | |
| 192 | + | if errors.As(err, &tooBig) { | |
| 193 | + | s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new") | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |
| 197 | + | return | |
| 198 | + | } | |
| 199 | + | for _, f := range issueFormFields { | |
| 200 | + | if len(r.PostForm[f]) > 1 { | |
| 201 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | in := ports.IssueSubmission{ | |
| 206 | + | Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution), | |
| 207 | + | FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot), | |
| 208 | + | Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)), | |
| 209 | + | Author: r.PostForm.Get(fieldAuthor), | |
| 210 | + | } | |
| 211 | + | err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in) | |
| 212 | + | if err == nil { | |
| 213 | + | seeOther(w, r, base+"/submitted") | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | var te *domain.TextError | |
| 217 | + | if errors.As(err, &te) { | |
| 218 | + | s.formAgain(ctx, w, r, name, in, err, te) | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | s.formFailed(ctx, w, r, err, base+"/new") | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // formAgain shows the form again after a text the visitor can fix: a new | |
| 225 | + | // challenge (the first one is spent), the text kept, the message at its field. | |
| 226 | + | func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) { | |
| 227 | + | p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author} | |
| 228 | + | field, _, _ := strings.Cut(err.Error(), ": ") | |
| 229 | + | switch field { | |
| 230 | + | case fieldTitle, fieldBody, fieldAuthor: | |
| 231 | + | default: | |
| 232 | + | field = fieldBody | |
| 233 | + | } | |
| 234 | + | label := field | |
| 235 | + | if field == fieldAuthor { | |
| 236 | + | label = "name" | |
| 237 | + | } | |
| 238 | + | p.ErrorField = field | |
| 239 | + | p.Error = "The " + label + " " + visibleText(te.Reason) + "." | |
| 240 | + | if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil { | |
| 241 | + | s.formFailed(ctx, w, r, cerr, p.Cancel) | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // issueSubmittedPage is GET /<repo>/issues/submitted. | |
| 248 | + | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |
| 249 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 250 | + | defer cancel() | |
| 251 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |
| 252 | + | if err != nil { | |
| 253 | + | s.fail(ctx, w, r, err) | |
| 254 | + | return | |
| 255 | + | } | |
| 256 | + | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 257 | + | s.fail(ctx, w, r, errNotFound) | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | w.Header().Set("Cache-Control", noStore) | |
| 261 | + | s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name())) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func (s *Server) submittedPage(repo string) templ.Component { | |
| 265 | + | l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}}) | |
| 266 | + | return State(l, StatePage{ | |
| 267 | + | Code: "ok", Title: "thanks", | |
| 268 | + | Lines: []string{"Thanks, your issue is waiting for moderation."}, | |
| 269 | + | Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}}, | |
| 270 | + | }) | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | func i64(n int64) string { return strconv.FormatInt(n, 10) } | |
| 274 | + | ||
| 275 | + | // runeCount is the length of s in characters, like the limits of the form. | |
| 276 | + | func runeCount(s string) int { return utf8.RuneCountInString(s) } | |
| @@ -0,0 +1,276 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/http" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | "unicode/utf8" | |||
| 13 | + | ||||
| 14 | + | "github.com/a-h/templ" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 17 | + | "bnema.dev/ermite/internal/domain" | |||
| 18 | + | "bnema.dev/ermite/internal/ports" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // The public issue form (ADR 0031). A visitor of a public repository whose | |||
| 22 | + | // issues mode is public writes an issue that awaits the owner's moderation. | |||
| 23 | + | // | |||
| 24 | + | // GET /<repo>/issues/new the form, with a proof of work solved by script | |||
| 25 | + | // POST /<repo>/issues/new the submission (the one write a visitor may make) | |||
| 26 | + | // GET /<repo>/issues/submitted the thanks page | |||
| 27 | + | // | |||
| 28 | + | // Every answer is "Cache-Control: no-store". The challenge token is never | |||
| 29 | + | // echoed back, and neither the address of the client nor the text of the | |||
| 30 | + | // submission is logged. A refusal for a bot-related reason is one generic page. | |||
| 31 | + | ||||
| 32 | + | // maxIssueFormBytes bounds the body of the submission: 10,000 characters are | |||
| 33 | + | // at most 40,000 bytes, plus the title, the name and the tokens. | |||
| 34 | + | const maxIssueFormBytes = 64 << 10 | |||
| 35 | + | ||||
| 36 | + | // The fields of the form, and so the only ones read. | |||
| 37 | + | const ( | |||
| 38 | + | fieldTitle = "title" | |||
| 39 | + | fieldBody = "body" | |||
| 40 | + | fieldAuthor = "author" | |||
| 41 | + | fieldToken = "token" | |||
| 42 | + | fieldFormToken = "form_token" | |||
| 43 | + | fieldSolution = "solution" | |||
| 44 | + | fieldHoneypot = "website" | |||
| 45 | + | ) | |||
| 46 | + | ||||
| 47 | + | var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot} | |||
| 48 | + | ||||
| 49 | + | // IssueFormPage is the public form. | |||
| 50 | + | type IssueFormPage struct { | |||
| 51 | + | Action, Cancel string | |||
| 52 | + | Token string | |||
| 53 | + | FormToken string | |||
| 54 | + | Nonce string | |||
| 55 | + | Difficulty int | |||
| 56 | + | MinSubmitAt int64 // unix milliseconds | |||
| 57 | + | Worker string | |||
| 58 | + | // Title, Body and Author are what the visitor already wrote. | |||
| 59 | + | Title, Body, Author string | |||
| 60 | + | // ErrorField is "title", "body" or "author" and Error the message to show | |||
| 61 | + | // next to it; both empty without an error. | |||
| 62 | + | ErrorField, Error string | |||
| 63 | + | // Max lengths, in characters. | |||
| 64 | + | MaxTitle, MaxBody, MaxAuthor int | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // issueNewRoute is GET /<repo>/issues/new: the owner's form when signed in, | |||
| 68 | + | // else the public form, else the page that is not there. | |||
| 69 | + | func (s *Server) issueNewRoute(w http.ResponseWriter, r *http.Request) { | |||
| 70 | + | if SignedIn(r.Context()) || s.cfg.IssueForm == nil { | |||
| 71 | + | s.page(s.issueNew)(w, r) | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 75 | + | defer cancel() | |||
| 76 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |||
| 77 | + | if err != nil { | |||
| 78 | + | s.fail(ctx, w, r, err) | |||
| 79 | + | return | |||
| 80 | + | } | |||
| 81 | + | page := IssueFormPage{} | |||
| 82 | + | status, err := s.fillIssueForm(ctx, r, name, &page) | |||
| 83 | + | if err != nil { | |||
| 84 | + | s.formFailed(ctx, w, r, err, page.Cancel) | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | s.writeIssueForm(ctx, w, r, status, name, page) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // fillIssueForm asks a challenge for the client and fills what the form needs | |||
| 91 | + | // from it, leaving the text of the visitor as it is. | |||
| 92 | + | func (s *Server) fillIssueForm(ctx context.Context, r *http.Request, name domain.RepoName, p *IssueFormPage) (int, error) { | |||
| 93 | + | base := issuesBase(name.String()) | |||
| 94 | + | p.Cancel = base | |||
| 95 | + | ch, err := s.cfg.IssueForm.Challenge(ctx, name, s.formClient(r)) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return 0, err | |||
| 98 | + | } | |||
| 99 | + | p.Action = base + "/new" | |||
| 100 | + | p.Token, p.FormToken, p.Nonce, p.Difficulty = ch.Token, ch.FormToken, ch.Nonce, ch.Difficulty | |||
| 101 | + | p.MinSubmitAt = ch.MinSubmitAt.UnixMilli() | |||
| 102 | + | p.Worker = s.assets.urls.PowWorker | |||
| 103 | + | p.MaxTitle, p.MaxBody, p.MaxAuthor = domain.MaxIssueTitleLen, domain.MaxIssueBodyLen, domain.MaxAuthorLabelLen | |||
| 104 | + | return http.StatusOK, nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // formClient is the key of the client for the limits of the submissions. | |||
| 108 | + | func (s *Server) formClient(r *http.Request) string { | |||
| 109 | + | return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // writeIssueForm renders the form in the layout of the issues of the repository. | |||
| 113 | + | func (s *Server) writeIssueForm(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, name domain.RepoName, p IssueFormPage) { | |||
| 114 | + | d, err := s.issueRepo(ctx, name.String()) | |||
| 115 | + | if err != nil { | |||
| 116 | + | s.fail(ctx, w, r, err) | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | l := s.repoLayout(ctx, d, "issues") | |||
| 120 | + | l.Title = "new issue · " + d.name() + " · " + s.instance | |||
| 121 | + | l.Crumbs[0].Current = false | |||
| 122 | + | l.Crumbs = append(l.Crumbs, | |||
| 123 | + | Crumb{Label: "issues", Href: issuesBase(d.name())}, | |||
| 124 | + | Crumb{Label: "new", Current: true}, | |||
| 125 | + | ) | |||
| 126 | + | l.HasRail = true | |||
| 127 | + | l.IssueForm = true | |||
| 128 | + | w.Header().Set("Cache-Control", noStore) | |||
| 129 | + | s.write(ctx, w, r, status, IssueForm(l, p)) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | // formFailed answers an error of the challenge or of the submission. | |||
| 133 | + | func (s *Server) formFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) { | |||
| 134 | + | var retry *domain.SubmissionRetryError | |||
| 135 | + | switch { | |||
| 136 | + | case errors.Is(err, context.Canceled): | |||
| 137 | + | case errors.Is(err, domain.ErrNotFound), domain.IsInvalid(err) && !errors.Is(err, domain.ErrInvalidText): | |||
| 138 | + | // The same page, with the same headers, as every other page that is | |||
| 139 | + | // not there (a POST has set no-store already). | |||
| 140 | + | s.fail(ctx, w, r, domain.ErrNotFound) | |||
| 141 | + | case errors.As(err, &retry): | |||
| 142 | + | secs := int(retry.After.Round(time.Second).Seconds()) | |||
| 143 | + | if retry.After > time.Duration(secs)*time.Second { | |||
| 144 | + | secs++ | |||
| 145 | + | } | |||
| 146 | + | secs = max(secs, 1) | |||
| 147 | + | w.Header().Set("Retry-After", strconv.Itoa(secs)) | |||
| 148 | + | mins := (secs + 59) / 60 | |||
| 149 | + | unit := " minutes." | |||
| 150 | + | if mins == 1 { | |||
| 151 | + | unit = " minute." | |||
| 152 | + | } | |||
| 153 | + | s.refusal(ctx, w, r, http.StatusTooManyRequests, "too many submissions", | |||
| 154 | + | "Too many submissions from your network, try again in "+strconv.Itoa(mins)+unit, back) | |||
| 155 | + | case errors.Is(err, domain.ErrSubmissionsPaused): | |||
| 156 | + | s.refusal(ctx, w, r, http.StatusServiceUnavailable, "submissions paused", "Submissions are paused, try later.", back) | |||
| 157 | + | case errors.Is(err, domain.ErrSubmissionRefused): | |||
| 158 | + | s.refusal(ctx, w, r, http.StatusBadRequest, "not verified", | |||
| 159 | + | "The submission could not be verified. Reload the page and try again.", back) | |||
| 160 | + | default: | |||
| 161 | + | s.internalFailure(ctx, w, r, err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // refusal writes a state page for a visitor, never cached. | |||
| 166 | + | func (s *Server) refusal(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) { | |||
| 167 | + | if back == "" { | |||
| 168 | + | back = "/" | |||
| 169 | + | } | |||
| 170 | + | l := s.stateLayout(title, []Crumb{{Label: title, Current: true}}) | |||
| 171 | + | w.Header().Set("Cache-Control", noStore) | |||
| 172 | + | s.write(ctx, w, r, status, State(l, StatePage{ | |||
| 173 | + | Code: itoa(status), Title: title, Lines: []string{line}, | |||
| 174 | + | Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}}, | |||
| 175 | + | })) | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | // issueFormPost is POST /<repo>/issues/new. | |||
| 179 | + | func (s *Server) issueFormPost(w http.ResponseWriter, r *http.Request) { | |||
| 180 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 181 | + | defer cancel() | |||
| 182 | + | w.Header().Set("Cache-Control", noStore) | |||
| 183 | + | name, err := domain.ParseRepoName(r.PathValue("repo")) | |||
| 184 | + | if err != nil { | |||
| 185 | + | s.formFailed(ctx, w, r, err, "") | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | base := issuesBase(name.String()) | |||
| 189 | + | r.Body = http.MaxBytesReader(w, r.Body, maxIssueFormBytes) | |||
| 190 | + | if err := r.ParseForm(); err != nil { | |||
| 191 | + | var tooBig *http.MaxBytesError | |||
| 192 | + | if errors.As(err, &tooBig) { | |||
| 193 | + | s.refusal(ctx, w, r, http.StatusRequestEntityTooLarge, "form too large", "The form is larger than an issue can be.", base+"/new") | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |||
| 197 | + | return | |||
| 198 | + | } | |||
| 199 | + | for _, f := range issueFormFields { | |||
| 200 | + | if len(r.PostForm[f]) > 1 { | |||
| 201 | + | s.formFailed(ctx, w, r, domain.ErrSubmissionInvalid, base+"/new") | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | in := ports.IssueSubmission{ | |||
| 206 | + | Token: r.PostForm.Get(fieldToken), Solution: r.PostForm.Get(fieldSolution), | |||
| 207 | + | FormToken: r.PostForm.Get(fieldFormToken), Honeypot: r.PostForm.Get(fieldHoneypot), | |||
| 208 | + | Title: r.PostForm.Get(fieldTitle), Body: normalizeNewlines(r.PostForm.Get(fieldBody)), | |||
| 209 | + | Author: r.PostForm.Get(fieldAuthor), | |||
| 210 | + | } | |||
| 211 | + | err = s.cfg.IssueForm.Submit(ctx, name, s.formClient(r), in) | |||
| 212 | + | if err == nil { | |||
| 213 | + | seeOther(w, r, base+"/submitted") | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | var te *domain.TextError | |||
| 217 | + | if errors.As(err, &te) { | |||
| 218 | + | s.formAgain(ctx, w, r, name, in, err, te) | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | s.formFailed(ctx, w, r, err, base+"/new") | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // formAgain shows the form again after a text the visitor can fix: a new | |||
| 225 | + | // challenge (the first one is spent), the text kept, the message at its field. | |||
| 226 | + | func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.Request, name domain.RepoName, in ports.IssueSubmission, err error, te *domain.TextError) { | |||
| 227 | + | p := IssueFormPage{Title: in.Title, Body: in.Body, Author: in.Author} | |||
| 228 | + | field, _, _ := strings.Cut(err.Error(), ": ") | |||
| 229 | + | switch field { | |||
| 230 | + | case fieldTitle, fieldBody, fieldAuthor: | |||
| 231 | + | default: | |||
| 232 | + | field = fieldBody | |||
| 233 | + | } | |||
| 234 | + | label := field | |||
| 235 | + | if field == fieldAuthor { | |||
| 236 | + | label = "name" | |||
| 237 | + | } | |||
| 238 | + | p.ErrorField = field | |||
| 239 | + | p.Error = "The " + label + " " + visibleText(te.Reason) + "." | |||
| 240 | + | if _, cerr := s.fillIssueForm(ctx, r, name, &p); cerr != nil { | |||
| 241 | + | s.formFailed(ctx, w, r, cerr, p.Cancel) | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | s.writeIssueForm(ctx, w, r, http.StatusUnprocessableEntity, name, p) | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // issueSubmittedPage is GET /<repo>/issues/submitted. | |||
| 248 | + | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |||
| 249 | + | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |||
| 250 | + | defer cancel() | |||
| 251 | + | d, err := s.issueRepo(ctx, r.PathValue("repo")) | |||
| 252 | + | if err != nil { | |||
| 253 | + | s.fail(ctx, w, r, err) | |||
| 254 | + | return | |||
| 255 | + | } | |||
| 256 | + | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | |||
| 257 | + | s.fail(ctx, w, r, errNotFound) | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | w.Header().Set("Cache-Control", noStore) | |||
| 261 | + | s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name())) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func (s *Server) submittedPage(repo string) templ.Component { | |||
| 265 | + | l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}}) | |||
| 266 | + | return State(l, StatePage{ | |||
| 267 | + | Code: "ok", Title: "thanks", | |||
| 268 | + | Lines: []string{"Thanks, your issue is waiting for moderation."}, | |||
| 269 | + | Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}}, | |||
| 270 | + | }) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | func i64(n int64) string { return strconv.FormatInt(n, 10) } | |||
| 274 | + | ||||
| 275 | + | // runeCount is the length of s in characters, like the limits of the form. | |||
| 276 | + | func runeCount(s string) int { return utf8.RuneCountInString(s) } | |||
A internal/adapters/web/issues_form.templ +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // IssueForm is the public form that proposes an issue to the owner. It needs | |
| 6 | + | // JavaScript: the script (issue-form.js) solves a proof of work in a worker, | |
| 7 | + | // so the form stays hidden until it runs, and the page says why without it. | |
| 8 | + | templ IssueForm(l Layout, p IssueFormPage) { | |
| 9 | + | @Shell(l, issueFormRail()) { | |
| 10 | + | <header class="show mr-h"> | |
| 11 | + | <h1>New issue</h1> | |
| 12 | + | </header> | |
| 13 | + | <noscript> | |
| 14 | + | <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p> | |
| 15 | + | </noscript> | |
| 16 | + | <form | |
| 17 | + | class="cform create" | |
| 18 | + | method="post" | |
| 19 | + | action={ templ.SafeURL(p.Action) } | |
| 20 | + | hidden | |
| 21 | + | data-issue-form | |
| 22 | + | data-nonce={ p.Nonce } | |
| 23 | + | data-difficulty={ itoa(p.Difficulty) } | |
| 24 | + | data-min-submit-at={ i64(p.MinSubmitAt) } | |
| 25 | + | data-worker={ p.Worker } | |
| 26 | + | autocomplete="off" | |
| 27 | + | > | |
| 28 | + | <input type="hidden" name="token" value={ p.Token }/> | |
| 29 | + | <input type="hidden" name="form_token" value={ p.FormToken }/> | |
| 30 | + | <input type="hidden" name="solution" value=""/> | |
| 31 | + | <label for="issue-title">title</label> | |
| 32 | + | <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/> | |
| 33 | + | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | |
| 34 | + | if p.ErrorField == "title" { | |
| 35 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 36 | + | } | |
| 37 | + | <label for="issue-body">description</label> | |
| 38 | + | <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea> | |
| 39 | + | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | |
| 40 | + | if p.ErrorField == "body" { | |
| 41 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 42 | + | } | |
| 43 | + | <label for="issue-author">name (optional, not verified)</label> | |
| 44 | + | <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/> | |
| 45 | + | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | |
| 46 | + | if p.ErrorField == "author" { | |
| 47 | + | <p class="f-error" role="alert">{ p.Error }</p> | |
| 48 | + | } | |
| 49 | + | <div class="hp" aria-hidden="true"> | |
| 50 | + | <label for="issue-website">Leave this field empty</label> | |
| 51 | + | <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/> | |
| 52 | + | </div> | |
| 53 | + | <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p> | |
| 54 | + | <div class="f-btns"> | |
| 55 | + | <button type="submit" class="btn primary" disabled>Submit for moderation</button> | |
| 56 | + | <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a> | |
| 57 | + | </div> | |
| 58 | + | </form> | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | templ issueFormRail() { | |
| 63 | + | <section aria-labelledby="r-about"> | |
| 64 | + | <h2 id="r-about">Before you write</h2> | |
| 65 | + | <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p> | |
| 66 | + | </section> | |
| 67 | + | } | |
| @@ -0,0 +1,67 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // IssueForm is the public form that proposes an issue to the owner. It needs | |||
| 6 | + | // JavaScript: the script (issue-form.js) solves a proof of work in a worker, | |||
| 7 | + | // so the form stays hidden until it runs, and the page says why without it. | |||
| 8 | + | templ IssueForm(l Layout, p IssueFormPage) { | |||
| 9 | + | @Shell(l, issueFormRail()) { | |||
| 10 | + | <header class="show mr-h"> | |||
| 11 | + | <h1>New issue</h1> | |||
| 12 | + | </header> | |||
| 13 | + | <noscript> | |||
| 14 | + | <p class="lede">This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.</p> | |||
| 15 | + | </noscript> | |||
| 16 | + | <form | |||
| 17 | + | class="cform create" | |||
| 18 | + | method="post" | |||
| 19 | + | action={ templ.SafeURL(p.Action) } | |||
| 20 | + | hidden | |||
| 21 | + | data-issue-form | |||
| 22 | + | data-nonce={ p.Nonce } | |||
| 23 | + | data-difficulty={ itoa(p.Difficulty) } | |||
| 24 | + | data-min-submit-at={ i64(p.MinSubmitAt) } | |||
| 25 | + | data-worker={ p.Worker } | |||
| 26 | + | autocomplete="off" | |||
| 27 | + | > | |||
| 28 | + | <input type="hidden" name="token" value={ p.Token }/> | |||
| 29 | + | <input type="hidden" name="form_token" value={ p.FormToken }/> | |||
| 30 | + | <input type="hidden" name="solution" value=""/> | |||
| 31 | + | <label for="issue-title">title</label> | |||
| 32 | + | <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" value={ p.Title }/> | |||
| 33 | + | <output data-for="issue-title" data-max={ itoa(p.MaxTitle) } for="issue-title">{ itoa(runeCount(p.Title)) } / { itoa(p.MaxTitle) }</output> | |||
| 34 | + | if p.ErrorField == "title" { | |||
| 35 | + | <p class="f-error" role="alert">{ p.Error }</p> | |||
| 36 | + | } | |||
| 37 | + | <label for="issue-body">description</label> | |||
| 38 | + | <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional">{ p.Body }</textarea> | |||
| 39 | + | <output data-for="issue-body" data-max={ itoa(p.MaxBody) } for="issue-body">{ itoa(runeCount(p.Body)) } / { itoa(p.MaxBody) }</output> | |||
| 40 | + | if p.ErrorField == "body" { | |||
| 41 | + | <p class="f-error" role="alert">{ p.Error }</p> | |||
| 42 | + | } | |||
| 43 | + | <label for="issue-author">name (optional, not verified)</label> | |||
| 44 | + | <input id="issue-author" name="author" type="text" maxlength="40" autocomplete="off" value={ p.Author }/> | |||
| 45 | + | <output data-for="issue-author" data-max={ itoa(p.MaxAuthor) } for="issue-author">{ itoa(runeCount(p.Author)) } / { itoa(p.MaxAuthor) }</output> | |||
| 46 | + | if p.ErrorField == "author" { | |||
| 47 | + | <p class="f-error" role="alert">{ p.Error }</p> | |||
| 48 | + | } | |||
| 49 | + | <div class="hp" aria-hidden="true"> | |||
| 50 | + | <label for="issue-website">Leave this field empty</label> | |||
| 51 | + | <input id="issue-website" name="website" type="text" tabindex="-1" autocomplete="off" value=""/> | |||
| 52 | + | </div> | |||
| 53 | + | <p class="dim" data-status role="status" aria-live="polite">Checking your browser… (this takes a few seconds)</p> | |||
| 54 | + | <div class="f-btns"> | |||
| 55 | + | <button type="submit" class="btn primary" disabled>Submit for moderation</button> | |||
| 56 | + | <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a> | |||
| 57 | + | </div> | |||
| 58 | + | </form> | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | templ issueFormRail() { | |||
| 63 | + | <section aria-labelledby="r-about"> | |||
| 64 | + | <h2 id="r-about">Before you write</h2> | |||
| 65 | + | <p class="dim">The owner reads every issue before it appears. Plain text and Markdown only; no address of yours is kept.</p> | |||
| 66 | + | </section> | |||
| 67 | + | } | |||
A internal/adapters/web/issues_form_templ.go +421 -0 generated file: collapsed
A internal/adapters/web/issues_form_test.go +322 -0
| @@ -0,0 +1,322 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | "bnema.dev/ermite/internal/ports" | |
| 20 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | type formRig struct { | |
| 24 | + | *issueRig | |
| 25 | + | form *mocks.IssueSubmissionService | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func newFormRig(t *testing.T, mode domain.IssuesMode) *formRig { | |
| 29 | + | t.Helper() | |
| 30 | + | r := &formRig{issueRig: &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}, form: mocks.NewIssueSubmissionService(t)} | |
| 31 | + | browse := mocks.NewBrowseService(t) | |
| 32 | + | browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe() | |
| 33 | + | r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe() | |
| 34 | + | r.srv = newTestServer(t, Config{ | |
| 35 | + | Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues, | |
| 36 | + | IssueForm: r.form, Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{}, | |
| 37 | + | }) | |
| 38 | + | return r | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func challenge(n int) ports.IssueChallenge { | |
| 42 | + | return ports.IssueChallenge{ | |
| 43 | + | Token: fmt.Sprintf("tok-%d.sig", n), Nonce: "AAECAwQFBgcICQoLDA0ODw", Difficulty: 18, | |
| 44 | + | FormToken: fmt.Sprintf("form-%d", n), | |
| 45 | + | IssuedAt: time.UnixMilli(1_700_000_000_000), MinSubmitAt: time.UnixMilli(1_700_000_003_000), | |
| 46 | + | ExpiresAt: time.UnixMilli(1_700_000_600_000), | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // post sends a submission as a browser of this site would. | |
| 51 | + | func (r *formRig) post(path string, hdr map[string]string, body string) *httptest.ResponseRecorder { | |
| 52 | + | req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) | |
| 53 | + | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 54 | + | for k, v := range hdr { | |
| 55 | + | req.Header.Set(k, v) | |
| 56 | + | } | |
| 57 | + | rec := httptest.NewRecorder() | |
| 58 | + | r.srv.ServeHTTP(rec, req) | |
| 59 | + | return rec | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | var fromSite = map[string]string{"Origin": testPublicURL} | |
| 63 | + | ||
| 64 | + | func goodForm() url.Values { | |
| 65 | + | return url.Values{"title": {"It crashes"}, "body": {"line1\r\nline2"}, "author": {"Alex"}, "token": {"tok-1.sig"}, "form_token": {"form-1"}, "solution": {"42"}, "website": {""}} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestIssueFormGet(t *testing.T) { | |
| 69 | + | t.Parallel() | |
| 70 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 71 | + | r.form.EXPECT().Challenge(mock.Anything, repoName(t, "proj"), mock.Anything).Return(challenge(1), nil).Once() | |
| 72 | + | ||
| 73 | + | rec := r.do("GET", "/proj/issues/new", false, nil) | |
| 74 | + | require.Equal(t, 200, rec.Code, rec.Body.String()) | |
| 75 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 76 | + | body := rec.Body.String() | |
| 77 | + | for _, want := range []string{ | |
| 78 | + | `<noscript>`, "This form needs JavaScript: it runs a short proof of work so that bots cannot flood the tracker.", | |
| 79 | + | `action="/proj/issues/new"`, ` hidden`, `data-issue-form`, `data-nonce="AAECAwQFBgcICQoLDA0ODw"`, | |
| 80 | + | `data-difficulty="18"`, `data-min-submit-at="1700000003000"`, `data-worker="/-/static/js/pow-worker.`, | |
| 81 | + | `name="token" value="tok-1.sig"`, `name="form_token" value="form-1"`, `name="solution" value=""`, | |
| 82 | + | `name="title" type="text" maxlength="120" required`, `maxlength="10000"`, `name="author" type="text" maxlength="40"`, | |
| 83 | + | `<div class="hp" aria-hidden="true">`, `name="website" type="text" tabindex="-1" autocomplete="off"`, | |
| 84 | + | `<output data-for="issue-title" data-max="120"`, `0 / 10000`, `Checking your browser… (this takes a few seconds)`, | |
| 85 | + | `/-/static/js/issue-form.`, | |
| 86 | + | } { | |
| 87 | + | assert.Contains(t, body, want) | |
| 88 | + | } | |
| 89 | + | assert.NotContains(t, body, `name="website" type="hidden"`) | |
| 90 | + | assert.NotContains(t, body, `type="hidden" name="website"`) | |
| 91 | + | noscript := body[strings.Index(body, "<noscript>"):strings.Index(body, "</noscript>")] | |
| 92 | + | assert.NotContains(t, noscript, "<form", "no usable form without JavaScript") | |
| 93 | + | assert.NotContains(t, body, "style=", "no inline style") | |
| 94 | + | for _, tag := range strings.Split(body, "<script")[1:] { | |
| 95 | + | head, _, _ := strings.Cut(tag, ">") | |
| 96 | + | assert.Contains(t, head, `src="`, "no inline script") | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestIssueFormIsNotThereUnlessPublic(t *testing.T) { | |
| 101 | + | t.Parallel() | |
| 102 | + | want := "" | |
| 103 | + | for i, tc := range []struct { | |
| 104 | + | name string | |
| 105 | + | mode domain.IssuesMode | |
| 106 | + | err error | |
| 107 | + | }{{"unknown", domain.IssuesPublic, domain.ErrNotFound}, {"owner mode", domain.IssuesOwner, domain.ErrNotFound}, {"off", domain.IssuesOff, domain.ErrNotFound}} { | |
| 108 | + | r := newFormRig(t, tc.mode) | |
| 109 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, tc.err).Maybe() | |
| 110 | + | path := "/proj/issues/new" | |
| 111 | + | if tc.name == "unknown" { | |
| 112 | + | path = "/nope/issues/new" | |
| 113 | + | } | |
| 114 | + | rec := r.do("GET", path, false, nil) | |
| 115 | + | assert.Equal(t, 404, rec.Code, tc.name) | |
| 116 | + | if i == 0 { | |
| 117 | + | want = rec.Body.String() | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | assert.Equal(t, want, rec.Body.String(), tc.name+": the same page") | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestIssueFormOwnerKeepsHisForm(t *testing.T) { | |
| 125 | + | t.Parallel() | |
| 126 | + | r := newFormRig(t, domain.IssuesPublic) // no Challenge expected | |
| 127 | + | rec := r.do("GET", "/proj/issues/new", true, nil) | |
| 128 | + | require.Equal(t, 200, rec.Code) | |
| 129 | + | assert.Contains(t, rec.Body.String(), `action="/proj/issues"`) | |
| 130 | + | assert.NotContains(t, rec.Body.String(), "data-issue-form") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestIssueFormGetPausedAndLimited(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 136 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(ports.IssueChallenge{}, domain.ErrSubmissionsPaused).Once() | |
| 137 | + | rec := r.do("GET", "/proj/issues/new", false, nil) | |
| 138 | + | assert.Equal(t, 503, rec.Code) | |
| 139 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 140 | + | assert.Contains(t, rec.Body.String(), "Submissions are paused, try later.") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestIssueFormPostOutcomes(t *testing.T) { | |
| 144 | + | t.Parallel() | |
| 145 | + | generic := "The submission could not be verified. Reload the page and try again." | |
| 146 | + | tests := []struct { | |
| 147 | + | name string | |
| 148 | + | err error | |
| 149 | + | status int | |
| 150 | + | body string | |
| 151 | + | hdr map[string]string | |
| 152 | + | }{ | |
| 153 | + | {"not found", domain.ErrNotFound, 404, "no such page", nil}, | |
| 154 | + | {"rate limited", &domain.SubmissionRetryError{After: 25 * time.Minute}, 429, "Too many submissions from your network, try again in 25 minutes.", map[string]string{"Retry-After": "1500"}}, | |
| 155 | + | {"rate limited, rounded up", &domain.SubmissionRetryError{After: 90 * time.Second}, 429, "try again in 2 minutes.", map[string]string{"Retry-After": "90"}}, | |
| 156 | + | {"paused", domain.ErrSubmissionsPaused, 503, "Submissions are paused, try later.", nil}, | |
| 157 | + | {"invalid", domain.ErrSubmissionInvalid, 400, generic, nil}, | |
| 158 | + | {"wrapped invalid", fmt.Errorf("%w: x", domain.ErrSubmissionInvalid), 400, generic, nil}, | |
| 159 | + | } | |
| 160 | + | for _, tc := range tests { | |
| 161 | + | t.Run(tc.name, func(t *testing.T) { | |
| 162 | + | t.Parallel() | |
| 163 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 164 | + | r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, mock.Anything).Return(tc.err).Once() | |
| 165 | + | rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()) | |
| 166 | + | assert.Equal(t, tc.status, rec.Code, rec.Body.String()) | |
| 167 | + | assert.Contains(t, rec.Body.String(), tc.body) | |
| 168 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 169 | + | for k, v := range tc.hdr { | |
| 170 | + | assert.Equal(t, v, rec.Header().Get(k)) | |
| 171 | + | } | |
| 172 | + | assert.NotContains(t, rec.Body.String(), "tok-1.sig", "the token is never echoed") | |
| 173 | + | for _, leak := range []string{"honeypot", "proof", "too fast", "replay", "banned"} { | |
| 174 | + | assert.NotContains(t, rec.Body.String(), leak, "no detail") | |
| 175 | + | } | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func TestIssueFormPostSuccess(t *testing.T) { | |
| 181 | + | t.Parallel() | |
| 182 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 183 | + | r.form.EXPECT().Submit(mock.Anything, repoName(t, "proj"), mock.Anything, ports.IssueSubmission{ | |
| 184 | + | Token: "tok-1.sig", Solution: "42", FormToken: "form-1", Title: "It crashes", Body: "line1\nline2", Author: "Alex", | |
| 185 | + | }).Return(nil).Once() | |
| 186 | + | rec := r.post("/proj/issues/new", fromSite, goodForm().Encode()) | |
| 187 | + | assert.Equal(t, 303, rec.Code) | |
| 188 | + | assert.Equal(t, "/proj/issues/submitted", rec.Header().Get("Location")) | |
| 189 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 190 | + | ||
| 191 | + | thanks := r.do("GET", "/proj/issues/submitted", false, nil) | |
| 192 | + | assert.Equal(t, 200, thanks.Code) | |
| 193 | + | assert.Equal(t, "no-store", thanks.Header().Get("Cache-Control")) | |
| 194 | + | assert.Contains(t, thanks.Body.String(), "Thanks, your issue is waiting for moderation.") | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestIssueSubmittedPageOnlyForPublic(t *testing.T) { | |
| 198 | + | t.Parallel() | |
| 199 | + | r := newFormRig(t, domain.IssuesOwner) | |
| 200 | + | assert.Equal(t, 404, r.do("GET", "/proj/issues/submitted", false, nil).Code) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func TestIssueFormTextErrorShowsTheFormAgain(t *testing.T) { | |
| 204 | + | t.Parallel() | |
| 205 | + | r := newFormRig(t, domain.IssuesPublic) | |
| 206 | + | r.form.EXPECT().Submit(mock.Anything, mock.Anything, mock.Anything, mock.Anything). | |
| 207 | + | Return(fmt.Errorf("title: %w", &domain.TextError{Reason: "is empty"})).Once() | |
| 208 | + | r.form.EXPECT().Challenge(mock.Anything, mock.Anything, mock.Anything).Return(challenge(2), nil).Once() | |
| 209 | + | f := goodForm() | |
| 210 | + | f.Set("title", "<b>mine</b>") | |
| 211 | + | rec := r.post("/proj/issues/new", fromSite, f.Encode()) | |
| 212 | + | require.Equal(t, 422, rec.Code, rec.Body.String()) | |
| 213 | + | assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) | |
| 214 | + | body := rec.Body.String() | |
| 215 | + | assert.Contains(t, body, "<b>mine</b>", "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 <b>") | |
| 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, "<b>mine</b>", "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 <b>") | |||
| 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 { | |||
| 32 | 32 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) | |
| 33 | 33 | if err := r.ParseForm(); err != nil { | |
| 34 | 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 | 38 | if strings.Contains(r.URL.Path, "/issues") { | |
| 36 | 39 | back, what = issuesBase(r.PathValue("repo")), "an issue" | |
| 37 | 40 | } | |
| @@ -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 { | |||
| 196 | 196 | if r.Method != http.MethodPost { | |
| 197 | 197 | 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 | 217 | // 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 false | 197 | 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 of | 217 | // 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 @@ | |||
| 173 | 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 | 192 | .list.mrs { | |
| 177 | 193 | & .c-n { width: 64px; } | |
| 178 | 194 | & .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 { | |||
| 63 | 63 | // it there is no such redirect. Leave it unset rather than assigning a | |
| 64 | 64 | // typed nil pointer. | |
| 65 | 65 | 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 | 75 | // Inbox serves the owner's inbox page (/-/inbox) and the button that | |
| 67 | 76 | // leads to it. Without it there is neither. Leave it unset rather than | |
| 68 | 77 | // assigning a typed nil pointer. | |
| @@ -181,6 +190,11 @@ func (s *Server) routes() *http.ServeMux { | |||
| 181 | 190 | } | |
| 182 | 191 | if s.cfg.Inbox != nil { | |
| 183 | 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 | 199 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 186 | 200 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| @@ -214,7 +228,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 214 | 228 | if s.cfg.Issues != nil { | |
| 215 | 229 | mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList)) | |
| 216 | 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 | 232 | mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR)) | |
| 219 | 233 | // The writes: POST only, behind the gate (ServeHTTP). | |
| 220 | 234 | mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate)) | |
| @@ -222,6 +236,10 @@ func (s *Server) routes() *http.ServeMux { | |||
| 222 | 236 | mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose)) | |
| 223 | 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 | 243 | if s.cfg.Runs != nil { | |
| 226 | 244 | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 227 | 245 | 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 a | 63 | // it there is no such redirect. Leave it unset rather than assigning a | ||
| 64 | // typed nil pointer. | 64 | // typed nil pointer. | ||
| 65 | IssueNumbers ports.IssueNumberResolver | 65 | 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 that | 75 | // 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 than | 76 | // 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) { | |||
| 59 | 59 | res = c.cli(t, adminToken, "repo", "show", "proj").ok(t) | |
| 60 | 60 | assert.Regexp(t, `issues\s+owner`, res.stdout) | |
| 61 | 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") | |
| 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) | |
| 64 | 67 | ||
| 65 | 68 | // A merge request first: the issue takes the next number. | |
| 66 | 69 | 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 { | |||
| 318 | 318 | "may be merged with (fast-forward, squash; default for a new repository: both), and\n" + | |
| 319 | 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 | 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 | 322 | "Needs the repo:admin scope." + jsonRepoHelp, | |
| 323 | 323 | Args: cobra.ExactArgs(1), | |
| 324 | 324 | 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 { | |||
| 75 | 75 | // oidcKey generates the signing keys of the ID tokens; nil generates RSA | |
| 76 | 76 | // keys (tests give prepared ones: a 3072-bit key takes a while). | |
| 77 | 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 | 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 | 349 | f.IssueSubmissions = usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{ | |
| 346 | 350 | Meta: store, Pending: f.PendingIssues, Bans: f.IssueBans, Limiter: f.SubmissionLimiter, | |
| 347 | 351 | Keys: f.IssueKeys, Bus: f.Bus, Clock: clock, | |
| 352 | + | BaseDifficulty: opts.issuePoW, MaxDifficulty: opts.issuePoW, MinFillTime: fastFill(opts.issuePoW), | |
| 348 | 353 | }) | |
| 349 | 354 | f.Inbox = usecase.NewInbox(usecase.InboxConfig{ | |
| 350 | 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 | 576 | f.Browse.WithVerifier(sigs) | |
| 572 | 577 | 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 | + | } | |
| @@ -75,6 +75,10 @@ type options struct { | |||||
| 75 | // oidcKey generates the signing keys of the ID tokens; nil generates RSA | 75 | // 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 nil | 577 | 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) { | |||
| 225 | 225 | _, err = r.root.issue.CreateIssue(ctx, connect.NewRequest(&pb.CreateIssueRequest{Repo: "priv", Title: "t"})) | |
| 226 | 226 | require.NoError(t, err) | |
| 227 | 227 | ||
| 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) | |
| 230 | 233 | _, err = update(r.root, pb.IssuesMode_ISSUES_MODE_UNSPECIFIED) | |
| 231 | 234 | assert.Equal(t, "invalid_argument", codeName(err)) | |
| 232 | 235 | 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 | |||
| 48 | 48 | Issues: f.Issues, | |
| 49 | 49 | IssueNumbers: f.Issues, | |
| 50 | 50 | Inbox: f.Inbox, | |
| 51 | + | IssueForm: f.IssueSubmissions, | |
| 52 | + | Moderation: f.IssueModeration, | |
| 51 | 53 | Runs: f.CI, | |
| 52 | 54 | Artifacts: artifacts, | |
| 53 | 55 | 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) { | |||
| 51 | 51 | ev := stream.next(t, watchWait) | |
| 52 | 52 | assert.Equal(t, "inbox", ev.name) | |
| 53 | 53 | 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 | |
| 55 | 55 | stream.next(t, watchWait) | |
| 56 | 56 | } | |
| 57 | 57 | _, err = r.forge.Issues.Create(r.ctx, root, r.name("other"), ports.CreateIssueParams{Title: "search is slow"}) | |
| 58 | 58 | require.NoError(t, err) | |
| 59 | 59 | var seen strings.Builder | |
| 60 | - | for range 4 { // the four fragments of this event | |
| 60 | + | for range 5 { // the five fragments of this event | |
| 61 | 61 | seen.WriteString(stream.next(t, watchWait).data) | |
| 62 | 62 | } | |
| 63 | 63 | 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 event | 54 | + | 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.Builder | 59 | var seen strings.Builder | ||
| 60 | - | for range 4 { // the four fragments of this event | 60 | + | 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 <b>slow</b>") | |
| 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 <b>slow</b>") | |
| 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 <b>slow</b>") | |||
| 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 <b>slow</b>") | |||
| 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 { | |||
| 104 | 104 | clock ports.Clock | |
| 105 | 105 | base int | |
| 106 | 106 | limit int | |
| 107 | + | minFill time.Duration | |
| 107 | 108 | ||
| 108 | 109 | mu sync.Mutex | |
| 109 | 110 | spent map[string]time.Time // nonce -> expiry of its challenge | |
| @@ -125,6 +126,8 @@ type IssueSubmissionsConfig struct { | |||
| 125 | 126 | // BaseDifficulty and MaxDifficulty replace the production constants; for | |
| 126 | 127 | // tests. Zero keeps the constants. | |
| 127 | 128 | BaseDifficulty, MaxDifficulty int | |
| 129 | + | // MinFillTime replaces minFillTime when positive; for tests. | |
| 130 | + | MinFillTime time.Duration | |
| 128 | 131 | } | |
| 129 | 132 | ||
| 130 | 133 | // NewIssueSubmissions builds the submission use case. | |
| @@ -132,7 +135,7 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions { | |||
| 132 | 135 | s := &IssueSubmissions{ | |
| 133 | 136 | meta: cfg.Meta, pending: cfg.Pending, bans: cfg.Bans, limiter: cfg.Limiter, | |
| 134 | 137 | keys: cfg.Keys, bus: cfg.Bus, clock: cfg.Clock, | |
| 135 | - | base: baseDifficulty, limit: maxDifficulty, | |
| 138 | + | base: baseDifficulty, limit: maxDifficulty, minFill: minFillTime, | |
| 136 | 139 | spent: make(map[string]time.Time), | |
| 137 | 140 | } | |
| 138 | 141 | if cfg.BaseDifficulty > 0 { | |
| @@ -141,6 +144,9 @@ func NewIssueSubmissions(cfg IssueSubmissionsConfig) *IssueSubmissions { | |||
| 141 | 144 | if cfg.MaxDifficulty > 0 { | |
| 142 | 145 | s.limit = cfg.MaxDifficulty | |
| 143 | 146 | } | |
| 147 | + | if cfg.MinFillTime > 0 { | |
| 148 | + | s.minFill = cfg.MinFillTime | |
| 149 | + | } | |
| 144 | 150 | return s | |
| 145 | 151 | } | |
| 146 | 152 | ||
| @@ -195,7 +201,7 @@ func (s *IssueSubmissions) Challenge(ctx context.Context, repo domain.RepoName, | |||
| 195 | 201 | Difficulty: d, | |
| 196 | 202 | FormToken: enc64(s.sign(s.keys.FormTokenKey(), nonce)), | |
| 197 | 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 | 205 | ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC(), | |
| 200 | 206 | }, nil | |
| 201 | 207 | } | |
| @@ -316,7 +322,7 @@ func (s *IssueSubmissions) Submit(ctx context.Context, repo domain.RepoName, add | |||
| 316 | 322 | if !hmac.Equal([]byte(f.FormToken), []byte(enc64(s.sign(s.keys.FormTokenKey(), c.Nonce)))) { | |
| 317 | 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 | 326 | return refuse("too_fast", domain.ErrSubmissionInvalid) | |
| 321 | 327 | } | |
| 322 | 328 | if !validSolution(f.Solution) || c.Difficulty < 1 || c.Difficulty > 8*sha256.Size || | |
| @@ -104,6 +104,7 @@ type IssueSubmissions struct { | |||||
| 104 | clock ports.Clock | 104 | clock ports.Clock | ||
| 105 | base int | 105 | base int | ||
| 106 | limit int | 106 | limit int | ||
| 107 | + | minFill time.Duration | |||
| 107 | 108 | ||||
| 108 | mu sync.Mutex | 109 | mu sync.Mutex | ||
| 109 | spent map[string]time.Time // nonce -> expiry of its challenge | 110 | 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; for | 126 | // BaseDifficulty and MaxDifficulty replace the production constants; for | ||
| 126 | // tests. Zero keeps the constants. | 127 | // tests. Zero keeps the constants. | ||
| 127 | BaseDifficulty, MaxDifficulty int | 128 | 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.MaxDifficulty | 145 | s.limit = cfg.MaxDifficulty | ||
| 143 | } | 146 | } | ||
| 147 | + | if cfg.MinFillTime > 0 { | |||
| 148 | + | s.minFill = cfg.MinFillTime | |||
| 149 | + | } | |||
| 144 | return s | 150 | 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 | }, nil | 206 | }, 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 || | ||