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 })
381 switch {
382 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
383 return "", back, &rerender{status: http.StatusConflict, msg: rule(err.Error(), "the merge request is not in a state that allows this."), err: err}
384 case err != nil:
385 return "", back, err
386 }
387 if res.SourceDeleted {
388 // The deletion follows the answer: the page says it is being done.
389 return back + "?deleting=1", back, nil
390 }
391 return back, back, nil
392}
393
394// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
395// they were shown as read. Reading a page never does it (see the top of mr.go).
396func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
397 repo := r.PathValue("repo")
398 n, err := number(r)
399 if err != nil {
400 return "", "", err
401 }
402 back := mrHref(repo, n)
403 name, err := domain.ParseRepoName(repo)
404 if err != nil {
405 return "", back, err
406 }
407 head, err := headID(r.PostFormValue("head_id"))
408 if err != nil {
409 return "", back, err
410 }
411 if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
412 return "", back, err
413 }
414 return back, back, nil
415}
416
417// formBool reads the "true" or "false" a form field carries.
418func formBool(s string) (bool, error) {
419 switch s {
420 case "true":
421 return true, nil
422 case "false":
423 return false, nil
424 }
425 return false, errors.New("must be true or false")
426}
427
428// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
429// break the use case stores.
430func normalizeNewlines(s string) string {
431 return strings.ReplaceAll(s, "\r\n", "\n")
432}
433
434// mrEditComment passes source text to the author-checked use case.
435func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
436 n, err := number(r)
437 if err != nil {
438 return "", "", err
439 }
440 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
441 name, err := domain.ParseRepoName(r.PathValue("repo"))
442 if err != nil {
443 return "", back, err
444 }
445 c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
446 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
447}
448
449// mrDeleteComment passes the note id to the author-checked use case.
450func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
451 n, err := number(r)
452 if err != nil {
453 return "", "", err
454 }
455 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
456 name, err := domain.ParseRepoName(r.PathValue("repo"))
457 if err != nil {
458 return "", back, err
459 }
460 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
461 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
462}