1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
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.
34const maxIssueFormBytes = 64 << 10
35
36// The fields of the form, and so the only ones read.
37const (
38 fieldTitle = "title"
39 fieldBody = "body"
40 fieldAuthor = "author"
41 fieldToken = "token"
42 fieldFormToken = "form_token"
43 fieldSolution = "solution"
44 fieldHoneypot = "website"
45)
46
47var issueFormFields = []string{fieldTitle, fieldBody, fieldAuthor, fieldToken, fieldFormToken, fieldSolution, fieldHoneypot}
48
49// IssueFormPage is the public form.
50type 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.
69func (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.
92func (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.
108func (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.
113func (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.
133func (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.
166func (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.
179func (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.
226func (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//
249//hexcheck:ignore suspicious-business-logic-in-adapter maps domain RepoSettings.ShowsIssueSubmissionReceipt to a 404; decides nothing itself
250func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
251 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
252 defer cancel()
253 d, err := s.issueRepo(ctx, r.PathValue("repo"))
254 if err != nil {
255 s.fail(ctx, w, r, err)
256 return
257 }
258 if !d.repo.Settings.ShowsIssueSubmissionReceipt() {
259 s.fail(ctx, w, r, errNotFound)
260 return
261 }
262 w.Header().Set("Cache-Control", noStore)
263 s.write(ctx, w, r, http.StatusOK, s.submittedPage(d.name()))
264}
265
266func (s *Server) submittedPage(repo string) templ.Component {
267 l := s.stateLayout("thanks", []Crumb{{Label: repo, Href: repoHref(repo)}, {Label: "thanks", Current: true}})
268 return State(l, StatePage{
269 Code: "ok", Title: "thanks",
270 Lines: []string{"Thanks, your issue is waiting for moderation."},
271 Back: []Link{{Label: "issues", Href: issuesBase(repo)}, {Label: "home", Href: "/"}},
272 })
273}
274
275func i64(n int64) string { return strconv.FormatInt(n, 10) }
276
277// runeCount is the length of s in characters, like the limits of the form.
278func runeCount(s string) int { return utf8.RuneCountInString(s) }