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