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
12 "github.com/bnema/zerowrap"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// The writes of the merge request pages. Each is a POST that passed the gate
19// (an Origin of this site and the owner's session), calls the use case as the
20// owner, and answers 303 to the page that shows the result, with
21// "Cache-Control: no-store". A request the use case refuses is answered with a
22// state page that says why, in the words of the use case for the rules the
23// owner can fix (a text rule, a moved branch, a full merge request), and
24// without detail for anything else.
25
26// maxFormBytes bounds the body of a form: a description of 8,000 characters is
27// at most 32,000 bytes.
28const maxFormBytes = 128 << 10
29
30// form reads the form of a POST, bounded.
31func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {
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 }
38 if strings.Contains(r.URL.Path, "/issues") {
39 back, what = issuesBase(r.PathValue("repo")), "an issue"
40 }
41 s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
42 return false
43 }
44 return true
45}
46
47// seeOther redirects to a page, never cached.
48func seeOther(w http.ResponseWriter, r *http.Request, to string) {
49 w.Header().Set("Cache-Control", "no-store")
50 http.Redirect(w, r, to, http.StatusSeeOther)
51}
52
53// mrAction wraps a POST handler: it bounds the use case calls, reads the form
54// and maps the error of the use case to a page.
55func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
56 return func(w http.ResponseWriter, r *http.Request) {
57 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
58 defer cancel()
59 if !s.form(w, r) {
60 return
61 }
62 to, back, err := h(ctx, r)
63 var re *rerender
64 if errors.As(err, &re) && s.rerenderMR(ctx, w, r, re) {
65 return
66 }
67 if err != nil {
68 s.actionFailed(ctx, w, r, err, back)
69 return
70 }
71 seeOther(w, r, to)
72 }
73}
74
75// rerender is the error of a merge the use case refused for a reason the
76// owner reads on the page of the request: the page is shown again, with the
77// status, and the reason in its merge box. Any other error keeps actionFailed.
78type rerender struct {
79 status int
80 msg string
81 err error
82}
83
84func (e *rerender) Error() string { return e.err.Error() }
85func (e *rerender) Unwrap() error { return e.err }
86
87type mergeErrorKey struct{}
88
89// withMergeError carries the reason of a refused merge to the page.
90func withMergeError(ctx context.Context, msg string) context.Context {
91 return context.WithValue(ctx, mergeErrorKey{}, msg)
92}
93
94func mergeErrorOf(ctx context.Context) string {
95 msg, _ := ctx.Value(mergeErrorKey{}).(string)
96 return msg
97}
98
99// rerenderMR writes the page of the request with the reason of the refusal.
100// It reports false when the page cannot be built; the caller then answers as
101// for any refused action.
102func (s *Server) rerenderMR(ctx context.Context, w http.ResponseWriter, r *http.Request, re *rerender) bool {
103 c, _, err := s.mrPage(withMergeError(ctx, re.msg), r, false)
104 if err != nil {
105 return false
106 }
107 w.Header().Set("Cache-Control", "no-store")
108 s.write(ctx, w, r, re.status, c)
109 return true
110}
111
112// actionFailed answers a refused action.
113func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
114 // The words of the use case, for the rules the owner can fix.
115 msg := err.Error()
116 // The order is that of the API's mapping of the domain errors to codes
117 // (rpc.codeOf), so that a web action answers what the API would.
118 switch {
119 case errors.Is(err, context.Canceled):
120 // The browser is gone.
121 case errors.Is(err, domain.ErrNotFound):
122 w.Header().Set("Cache-Control", "no-store")
123 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
124 case errors.Is(err, domain.ErrAlreadyExists):
125 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
126 case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
127 s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
128 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
129 s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
130 case errors.Is(err, domain.ErrResourceExhausted):
131 s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
132 case domain.IsInvalid(err):
133 s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
134 default:
135 s.internalFailure(ctx, w, r, err)
136 }
137}
138
139// internalFailure answers a failure of an action that is not the owner's to
140// fix: the 500 page with an id, and the cause in the log.
141func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
142 id := requestID()
143 log := zerowrap.FromCtx(ctx)
144 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
145 w.Header().Set("Cache-Control", "no-store")
146 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
147}
148
149// rule is a message of the use case fit to show: the invisible characters marked,
150// bounded, and a fallback for an empty one.
151func rule(msg, fallback string) string {
152 msg = strings.TrimSpace(visibleText(msg))
153 if msg == "" {
154 return fallback
155 }
156 return cutRunes(msg, 400)
157}
158
159// reject writes the page of a refused action, with the way back.
160func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
161 if back == "" {
162 back = "/"
163 }
164 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
165 w.Header().Set("Cache-Control", "no-store")
166 s.write(ctx, w, r, status, State(l, StatePage{
167 Code: itoa(status), Title: title,
168 Lines: []string{line, "nothing was changed by this request."},
169 Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
170 }))
171}
172
173// number reads the merge request number of the path.
174func number(r *http.Request) (int64, error) { return mrNumber(r) }
175
176// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
177func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
178 repo := r.PathValue("repo")
179 source := r.PostFormValue("source")
180 back := repoHref(repo, "compare", source)
181 name, err := domain.ParseRepoName(repo)
182 if err != nil {
183 return "", back, err
184 }
185 mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{
186 Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
187 Source: source, Target: r.PostFormValue("target"),
188 })
189 if err != nil {
190 return "", back, err
191 }
192 return mrHref(repo, mr.Number), back, nil
193}
194
195// formError is a form the server refuses before asking a use case: it matches
196// domain.ErrInvalid and says just what is wrong.
197type formError string
198
199func (e formError) Error() string { return string(e) }
200
201func (formError) Unwrap() error { return domain.ErrInvalid }
202
203// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
204// one on a line (path, line and side).
205func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
206 repo := r.PathValue("repo")
207 n, err := number(r)
208 if err != nil {
209 return "", "", err
210 }
211 back := mrHref(repo, n)
212 params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
213 if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
214 line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
215 if err != nil {
216 return "", back, formError("line is not a number")
217 }
218 params.Line = int(line)
219 switch r.PostFormValue("side") {
220 case "new":
221 params.Side = domain.SideNew
222 case "old":
223 params.Side = domain.SideOld
224 default:
225 return "", back, formError("side must be new or old")
226 }
227 }
228 if raw := r.PostFormValue("reply_to"); raw != "" {
229 params.ReplyTo = commentID(raw)
230 if params.ReplyTo == 0 {
231 return "", back, formError("reply_to is not a note id")
232 }
233 }
234 if raw := r.PostFormValue("start_line"); raw != "" {
235 v, e := strconv.ParseInt(raw, 10, 32)
236 if e != nil {
237 return "", back, formError("start_line is not a number")
238 }
239 params.StartLine = int(v)
240 }
241 params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
242 if err != nil {
243 return "", back, err
244 }
245 name, err := domain.ParseRepoName(repo)
246 if err != nil {
247 return "", back, err
248 }
249 back = withFrom(back, formFrom(r))
250 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
251 if err != nil {
252 return "", back, err
253 }
254 return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
255}
256
257// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
258//
259//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into a use case call; the state of the comment is decided by the use case
260func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
261 repo := r.PathValue("repo")
262 n, err := number(r)
263 if err != nil {
264 return "", "", err
265 }
266 back := mrHref(repo, n)
267 resolved, err := formBool(r.PostFormValue("resolved"))
268 if err != nil {
269 return "", back, formError("resolved: " + err.Error())
270 }
271 name, err := domain.ParseRepoName(repo)
272 if err != nil {
273 return "", back, err
274 }
275 // An id this server never issued is 0: the use case answers not found,
276 // after the repository and the scope were checked, as for any unknown
277 // comment.
278 back = withFrom(back, formFrom(r))
279 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
280 if err != nil {
281 return "", back, err
282 }
283 return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
284}
285
286// commentID parses the id of a comment in a path: a positive decimal number,
287// written without sign or leading zeros. Anything else is 0, which names no
288// comment.
289func commentID(s string) int64 {
290 n, err := strconv.ParseInt(s, 10, 64)
291 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
292 return 0
293 }
294 return n
295}
296
297// headID parses a commit id of a form; the empty string is the zero id.
298func headID(s string) (domain.ObjectID, error) {
299 if s == "" {
300 return domain.ObjectID{}, nil
301 }
302 var v domain.Violations
303 id, err := domain.ParseObjectID(s)
304 if err != nil {
305 v.Add("head_id", "is not a full commit id")
306 }
307 return id, v.Err()
308}
309
310// mrClose is POST /<repo>/merge-requests/<n>/close.
311func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
312 repo := r.PathValue("repo")
313 n, err := number(r)
314 if err != nil {
315 return "", "", err
316 }
317 back := mrHref(repo, n)
318 name, err := domain.ParseRepoName(repo)
319 if err != nil {
320 return "", back, err
321 }
322 if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil {
323 return "", back, err
324 }
325 return back, back, nil
326}
327
328// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
329// the title.
330func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
331 repo := r.PathValue("repo")
332 n, err := number(r)
333 if err != nil {
334 return "", "", err
335 }
336 back := mrHref(repo, n)
337 name, err := domain.ParseRepoName(repo)
338 if err != nil {
339 return "", back, err
340 }
341 if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
342 return "", back, err
343 }
344 return back, back, nil
345}
346
347// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
348// the head the owner was shown (head_id): the server merges only if the
349// branch still points to it, and delete_branch when the owner asked to delete
350// the source branch after the merge.
351func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
352 repo := r.PathValue("repo")
353 n, err := number(r)
354 if err != nil {
355 return "", "", err
356 }
357 back := mrHref(repo, n)
358 var mode domain.MergeMode
359 switch r.PostFormValue("mode") {
360 case "fast-forward":
361 mode = domain.MergeModeFastForward
362 case "squash":
363 mode = domain.MergeModeSquash
364 case "merge":
365 mode = domain.MergeModeMerge
366 default:
367 err := formError("mode must be fast-forward, squash or merge")
368 return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
369 }
370 name, err := domain.ParseRepoName(repo)
371 if err != nil {
372 return "", back, err
373 }
374 head, err := headID(r.PostFormValue("head_id"))
375 if err != nil {
376 return "", back, err
377 }
378 res, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
379 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
380 IgnoreChecks: r.PostFormValue("ignore_checks") == "on",
381 })
382 switch {
383 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
384 return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
385 case err != nil:
386 return "", back, err
387 }
388 if res.SourceDeleted {
389 // The deletion follows the answer: the page says it is being done.
390 return back + "?deleting=1", back, nil
391 }
392 return back, back, nil
393}
394
395// mrAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge: the form of the
396// merge (mode, head_id) asks to merge once the checks pass. The server records
397// refuses to enable when the source branch is no longer at head_id.
398func (s *Server) mrAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
399 repo := r.PathValue("repo")
400 n, err := number(r)
401 if err != nil {
402 return "", "", err
403 }
404 back := mrHref(repo, n)
405 var mode domain.MergeMode
406 switch r.PostFormValue("mode") {
407 case "fast-forward":
408 mode = domain.MergeModeFastForward
409 case "squash":
410 mode = domain.MergeModeSquash
411 case "merge":
412 mode = domain.MergeModeMerge
413 default:
414 err := formError("mode must be fast-forward, squash or merge")
415 return "", back, &rerender{status: http.StatusBadRequest, msg: err.Error(), err: err}
416 }
417 name, err := domain.ParseRepoName(repo)
418 if err != nil {
419 return "", back, err
420 }
421 head, err := headID(r.PostFormValue("head_id"))
422 if err != nil {
423 return "", back, err
424 }
425 _, err = s.cfg.MergeRequests.EnableAutoMerge(ctx, principal(ctx), name, n, ports.EnableAutoMergeParams{Mode: mode, HeadID: head})
426 switch {
427 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
428 return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
429 case err != nil:
430 return "", back, err
431 }
432 return back, back, nil
433}
434
435// mrCancelAutoMerge is POST /<repo>/merge-requests/<n>/auto-merge/cancel.
436func (s *Server) mrCancelAutoMerge(ctx context.Context, r *http.Request) (string, string, error) {
437 repo := r.PathValue("repo")
438 n, err := number(r)
439 if err != nil {
440 return "", "", err
441 }
442 back := mrHref(repo, n)
443 name, err := domain.ParseRepoName(repo)
444 if err != nil {
445 return "", back, err
446 }
447 _, err = s.cfg.MergeRequests.CancelAutoMerge(ctx, principal(ctx), name, n)
448 switch {
449 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
450 return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
451 case err != nil:
452 return "", back, err
453 }
454 return back, back, nil
455}
456
457// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
458// they were shown as read. Reading a page never does it (see the top of mr.go).
459func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
460 repo := r.PathValue("repo")
461 n, err := number(r)
462 if err != nil {
463 return "", "", err
464 }
465 back := mrHref(repo, n)
466 name, err := domain.ParseRepoName(repo)
467 if err != nil {
468 return "", back, err
469 }
470 head, err := headID(r.PostFormValue("head_id"))
471 if err != nil {
472 return "", back, err
473 }
474 if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
475 return "", back, err
476 }
477 return back, back, nil
478}
479
480// formBool reads the "true" or "false" a form field carries.
481func formBool(s string) (bool, error) {
482 switch s {
483 case "true":
484 return true, nil
485 case "false":
486 return false, nil
487 }
488 return false, errors.New("must be true or false")
489}
490
491// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
492// break the use case stores.
493func normalizeNewlines(s string) string {
494 return strings.ReplaceAll(s, "\r\n", "\n")
495}
496
497// mrEditComment passes source text to the author-checked use case.
498func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
499 n, err := number(r)
500 if err != nil {
501 return "", "", err
502 }
503 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
504 name, err := domain.ParseRepoName(r.PathValue("repo"))
505 if err != nil {
506 return "", back, err
507 }
508 c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
509 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
510}
511
512// mrDeleteComment passes the note id to the author-checked use case.
513func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
514 n, err := number(r)
515 if err != nil {
516 return "", "", err
517 }
518 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
519 name, err := domain.ParseRepoName(r.PathValue("repo"))
520 if err != nil {
521 return "", back, err
522 }
523 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
524 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
525}