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