detached 27e453c

issues_form.go RawHistoryFolder

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) }

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close