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 if err != nil {
64 s.actionFailed(ctx, w, r, err, back)
65 return
66 }
67 seeOther(w, r, to)
68 }
69}
70
71// actionFailed answers a refused action.
72func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
73 // The words of the use case, for the rules the owner can fix.
74 msg := err.Error()
75 // The order is that of the API's mapping of the domain errors to codes
76 // (rpc.codeOf), so that a web action answers what the API would.
77 switch {
78 case errors.Is(err, context.Canceled):
79 // The browser is gone.
80 case errors.Is(err, domain.ErrNotFound):
81 w.Header().Set("Cache-Control", "no-store")
82 s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
83 case errors.Is(err, domain.ErrAlreadyExists):
84 s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
85 case errors.Is(err, domain.ErrUnauthenticated), errors.Is(err, domain.ErrForbidden), errors.Is(err, domain.ErrRejected):
86 s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
87 case errors.Is(err, domain.ErrPreconditionFailed), errors.Is(err, domain.ErrConflict):
88 s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
89 case errors.Is(err, domain.ErrResourceExhausted):
90 s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
91 case domain.IsInvalid(err):
92 s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
93 default:
94 s.internalFailure(ctx, w, r, err)
95 }
96}
97
98// internalFailure answers a failure of an action that is not the owner's to
99// fix: the 500 page with an id, and the cause in the log.
100func (s *Server) internalFailure(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) {
101 id := requestID()
102 log := zerowrap.FromCtx(ctx)
103 log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
104 w.Header().Set("Cache-Control", "no-store")
105 s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
106}
107
108// rule is a message of the use case fit to show: the invisible characters marked,
109// bounded, and a fallback for an empty one.
110func rule(msg, fallback string) string {
111 msg = strings.TrimSpace(visibleText(msg))
112 if msg == "" {
113 return fallback
114 }
115 return cutRunes(msg, 400)
116}
117
118// reject writes the page of a refused action, with the way back.
119func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
120 if back == "" {
121 back = "/"
122 }
123 l := s.stateLayout(title, []Crumb{{Label: title, Current: true}})
124 w.Header().Set("Cache-Control", "no-store")
125 s.write(ctx, w, r, status, State(l, StatePage{
126 Code: itoa(status), Title: title,
127 Lines: []string{line, "nothing was changed by this request."},
128 Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
129 }))
130}
131
132// number reads the merge request number of the path.
133func number(r *http.Request) (int64, error) { return mrNumber(r) }
134
135// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
136func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
137 repo := r.PathValue("repo")
138 source := r.PostFormValue("source")
139 back := repoHref(repo, "compare", source)
140 name, err := domain.ParseRepoName(repo)
141 if err != nil {
142 return "", back, err
143 }
144 mr, err := s.cfg.MergeRequests.Create(ctx, principal(ctx), name, ports.CreateMergeRequestParams{
145 Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
146 Source: source, Target: r.PostFormValue("target"),
147 })
148 if err != nil {
149 return "", back, err
150 }
151 return mrHref(repo, mr.Number), back, nil
152}
153
154// formError is a form the server refuses before asking a use case: it matches
155// domain.ErrInvalid and says just what is wrong.
156type formError string
157
158func (e formError) Error() string { return string(e) }
159
160func (formError) Unwrap() error { return domain.ErrInvalid }
161
162// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
163// one on a line (path, line and side).
164func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
165 repo := r.PathValue("repo")
166 n, err := number(r)
167 if err != nil {
168 return "", "", err
169 }
170 back := mrHref(repo, n)
171 params := ports.AddCommentParams{Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
172 if params.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
173 line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
174 if err != nil {
175 return "", back, formError("line is not a number")
176 }
177 params.Line = int(line)
178 switch r.PostFormValue("side") {
179 case "new":
180 params.Side = domain.SideNew
181 case "old":
182 params.Side = domain.SideOld
183 default:
184 return "", back, formError("side must be new or old")
185 }
186 }
187 if raw := r.PostFormValue("reply_to"); raw != "" {
188 params.ReplyTo = commentID(raw)
189 if params.ReplyTo == 0 {
190 return "", back, formError("reply_to is not a note id")
191 }
192 }
193 if raw := r.PostFormValue("start_line"); raw != "" {
194 v, e := strconv.ParseInt(raw, 10, 32)
195 if e != nil {
196 return "", back, formError("start_line is not a number")
197 }
198 params.StartLine = int(v)
199 }
200 params.Verdict, err = domain.ParseVerdict(r.PostFormValue("verdict"))
201 if err != nil {
202 return "", back, err
203 }
204 name, err := domain.ParseRepoName(repo)
205 if err != nil {
206 return "", back, err
207 }
208 back = withFrom(back, formFrom(r))
209 c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params)
210 if err != nil {
211 return "", back, err
212 }
213 return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
214}
215
216// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
217//
218//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
219func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
220 repo := r.PathValue("repo")
221 n, err := number(r)
222 if err != nil {
223 return "", "", err
224 }
225 back := mrHref(repo, n)
226 resolved, err := formBool(r.PostFormValue("resolved"))
227 if err != nil {
228 return "", back, formError("resolved: " + err.Error())
229 }
230 name, err := domain.ParseRepoName(repo)
231 if err != nil {
232 return "", back, err
233 }
234 // An id this server never issued is 0: the use case answers not found,
235 // after the repository and the scope were checked, as for any unknown
236 // comment.
237 back = withFrom(back, formFrom(r))
238 c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved)
239 if err != nil {
240 return "", back, err
241 }
242 return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
243}
244
245// commentID parses the id of a comment in a path: a positive decimal number,
246// written without sign or leading zeros. Anything else is 0, which names no
247// comment.
248func commentID(s string) int64 {
249 n, err := strconv.ParseInt(s, 10, 64)
250 if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
251 return 0
252 }
253 return n
254}
255
256// headID parses a commit id of a form; the empty string is the zero id.
257//
258//hexcheck:ignore suspicious-business-logic-in-adapter parses a form field into the domain id and names the field in the error; no rule is decided here
259func headID(s string) (domain.ObjectID, error) {
260 if s == "" {
261 return domain.ObjectID{}, nil
262 }
263 var v domain.Violations
264 id, err := domain.ParseObjectID(s)
265 if err != nil {
266 v.Add("head_id", "is not a full commit id")
267 }
268 return id, v.Err()
269}
270
271// mrClose is POST /<repo>/merge-requests/<n>/close.
272func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
273 repo := r.PathValue("repo")
274 n, err := number(r)
275 if err != nil {
276 return "", "", err
277 }
278 back := mrHref(repo, n)
279 name, err := domain.ParseRepoName(repo)
280 if err != nil {
281 return "", back, err
282 }
283 if _, err := s.cfg.MergeRequests.Close(ctx, principal(ctx), name, n); err != nil {
284 return "", back, err
285 }
286 return back, back, nil
287}
288
289// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
290// the title.
291func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
292 repo := r.PathValue("repo")
293 n, err := number(r)
294 if err != nil {
295 return "", "", err
296 }
297 back := mrHref(repo, n)
298 name, err := domain.ParseRepoName(repo)
299 if err != nil {
300 return "", back, err
301 }
302 if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
303 return "", back, err
304 }
305 return back, back, nil
306}
307
308// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
309// the head the owner was shown (head_id): the server merges only if the
310// branch still points to it, and delete_branch when the owner asked to delete
311// the source branch after the merge.
312func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
313 repo := r.PathValue("repo")
314 n, err := number(r)
315 if err != nil {
316 return "", "", err
317 }
318 back := mrHref(repo, n)
319 var mode domain.MergeMode
320 switch r.PostFormValue("mode") {
321 case "fast-forward":
322 mode = domain.MergeModeFastForward
323 case "squash":
324 mode = domain.MergeModeSquash
325 default:
326 return "", back, formError("mode must be fast-forward or squash")
327 }
328 name, err := domain.ParseRepoName(repo)
329 if err != nil {
330 return "", back, err
331 }
332 head, err := headID(r.PostFormValue("head_id"))
333 if err != nil {
334 return "", back, err
335 }
336 if _, err := s.cfg.MergeRequests.Merge(ctx, principal(ctx), name, n, ports.MergeParams{
337 Mode: mode, HeadID: head, DeleteSource: r.PostFormValue("delete_branch") == "1",
338 }); err != nil {
339 return "", back, err
340 }
341 return back, back, nil
342}
343
344// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
345// they were shown as read. Reading a page never does it (see the top of mr.go).
346func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
347 repo := r.PathValue("repo")
348 n, err := number(r)
349 if err != nil {
350 return "", "", err
351 }
352 back := mrHref(repo, n)
353 name, err := domain.ParseRepoName(repo)
354 if err != nil {
355 return "", back, err
356 }
357 head, err := headID(r.PostFormValue("head_id"))
358 if err != nil {
359 return "", back, err
360 }
361 if _, err := s.cfg.MergeRequests.MarkRead(ctx, principal(ctx), name, n, head); err != nil {
362 return "", back, err
363 }
364 return back, back, nil
365}
366
367// formBool reads the "true" or "false" a form field carries.
368func formBool(s string) (bool, error) {
369 switch s {
370 case "true":
371 return true, nil
372 case "false":
373 return false, nil
374 }
375 return false, errors.New("must be true or false")
376}
377
378// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
379// break the use case stores.
380func normalizeNewlines(s string) string {
381 return strings.ReplaceAll(s, "\r\n", "\n")
382}
383
384// mrEditComment passes source text to the author-checked use case.
385func (s *Server) mrEditComment(ctx context.Context, r *http.Request) (string, string, error) {
386 n, err := number(r)
387 if err != nil {
388 return "", "", err
389 }
390 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
391 name, err := domain.ParseRepoName(r.PathValue("repo"))
392 if err != nil {
393 return "", back, err
394 }
395 c, err := s.cfg.MergeRequests.EditComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), normalizeNewlines(r.PostFormValue("text")))
396 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
397}
398
399// mrDeleteComment passes the note id to the author-checked use case.
400func (s *Server) mrDeleteComment(ctx context.Context, r *http.Request) (string, string, error) {
401 n, err := number(r)
402 if err != nil {
403 return "", "", err
404 }
405 back := withFrom(mrHref(r.PathValue("repo"), n), formFrom(r))
406 name, err := domain.ParseRepoName(r.PathValue("repo"))
407 if err != nil {
408 return "", back, err
409 }
410 c, err := s.cfg.MergeRequests.DeleteComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")))
411 return back + "#c" + strconv.FormatInt(c.ID, 10), back, err
412}