internal/httpd/accounts.go
692 lines · 23896 bytes
1package httpd
2
3import (
4 "fmt"
5 "html/template"
6 "log"
7 "net/http"
8 "path"
9 "slices"
10 "strconv"
11 "strings"
12 "time"
13
14 gossh "golang.org/x/crypto/ssh"
15
16 "gitbay.org/gitbay/internal/control"
17 "gitbay.org/gitbay/internal/gitutil"
18 "gitbay.org/gitbay/internal/policy"
19 "gitbay.org/gitbay/internal/protocol"
20 "gitbay.org/gitbay/internal/store"
21)
22
23const sessionCookie = "gitbay_session"
24
25// sessionSameSite is Lax so a login link followed from a mail client keeps
26// its session through the redirect. Cross-site POSTs are refused by
27// checkOrigin and carry no Lax cookie anyway.
28const sessionSameSite = http.SameSiteLaxMode
29
30// badLoginToken is what every refused /login?token= gets, whatever the
31// reason. The reasons differ in whether the account exists.
32const badLoginToken = "that login link is invalid, expired, or already used — mint a new one"
33
34// viewer returns the logged-in user, or a zero User for anonymous visitors.
35// Only meaningful in accounts mode; in view_only no session route exists so
36// every request is anonymous.
37func (s *Server) viewer(r *http.Request) store.User {
38 ck, err := r.Cookie(sessionCookie)
39 if err != nil {
40 return store.User{}
41 }
42 u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
43 if err != nil {
44 return store.User{}
45 }
46 return u
47}
48
49// requireUser wraps a handler that needs a session.
50func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
51 return func(w http.ResponseWriter, r *http.Request) {
52 u := s.viewer(r)
53 if u.ID == 0 {
54 if r.Method == http.MethodGet {
55 s.setNext(w, r.URL.RequestURI())
56 }
57 http.Redirect(w, r, "/login", http.StatusSeeOther)
58 return
59 }
60 h(w, r, u)
61 }
62}
63
64// checkOrigin rejects cross-site POSTs. It is the primary CSRF defense:
65// sessions use SameSite=Lax, which withholds the cookie from a cross-site
66// POST but not from a cross-site top-level GET.
67func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
68 return func(w http.ResponseWriter, r *http.Request) {
69 if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
70 host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
71 if host != r.Host {
72 http.Error(w, "cross-origin request refused", http.StatusForbidden)
73 return
74 }
75 }
76 h(w, r)
77 }
78}
79
80// renderLogin draws the login page. Mode carries the registration mode so
81// the page can tell a brand-new visitor how to get an account. EmailLogin
82// says whether this instance can mail a link; Sent switches the page to the
83// confirmation that follows a request.
84func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) {
85 s.render(w, "login.html", struct {
86 basePage
87 Mode string // closed | invite | open
88 Error string
89 EmailLogin bool
90 Sent bool
91 Next string
92 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
93 s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent, next})
94}
95
96// emailLoginEnabled reports whether a link can be mailed at all. There is no
97// separate switch: the capability is exactly the SMTP the instance already
98// configured for verification and notification mail.
99func (s *Server) emailLoginEnabled() bool {
100 return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
101}
102
103// loginSubmit mails a one-time login link. The response is the same page
104// whatever happened, including when nothing happened.
105func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
106 if !s.emailLoginEnabled() {
107 s.notFound(w, r)
108 return
109 }
110 // The per-account bound lives in the store and survives a restart; this
111 // one stops a single source from spending every account's budget.
112 if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
113 w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
114 http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
115 return
116 }
117 if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
118 log.Printf("login link: %v", err)
119 }
120 s.renderLogin(w, "", true, "")
121}
122
123func (s *Server) login(w http.ResponseWriter, r *http.Request) {
124 // token, when present, is a single-use secret in the query string —
125 // the documented exception to "never in a URL" (Threat-Model). No
126 // cache may keep a copy of this response.
127 w.Header().Set("Cache-Control", "no-store")
128 token := r.URL.Query().Get("token")
129 if token == "" {
130 s.renderLogin(w, "", false, s.peekNext(r))
131 return
132 }
133 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
134 if err != nil {
135 s.renderLogin(w, badLoginToken, false, "")
136 return
137 }
138 // A token minted before the account was suspended is still consumable,
139 // and the session it would create renders every page the account can
140 // read. Checking here covers every mint path. The message is the one a
141 // bad token gets: a distinct one would confirm the account exists.
142 if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
143 s.renderLogin(w, badLoginToken, false, "")
144 return
145 }
146 sessTok, sessHash, err := store.NewToken()
147 if err != nil {
148 http.Error(w, "internal error", http.StatusInternalServerError)
149 return
150 }
151 // Seven days is the cap; the store ends it sooner after
152 // store.WebSessionIdle without a request.
153 if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
154 http.Error(w, "internal error", http.StatusInternalServerError)
155 return
156 }
157 http.SetCookie(w, s.sessionCookieFor(sessTok))
158 dest := s.takeNext(w, r)
159 if dest == "" {
160 dest = "/"
161 }
162 http.Redirect(w, r, dest, http.StatusSeeOther)
163}
164
165// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
166// the way clearCookie does, so a plain-HTTP deployment still works.
167func (s *Server) sessionCookieFor(tok string) *http.Cookie {
168 return &http.Cookie{
169 Name: sessionCookie, Value: tok, Path: "/",
170 HttpOnly: true, SameSite: sessionSameSite,
171 Secure: s.cfg.HTTP.TLS != "off",
172 MaxAge: 7 * 24 * 3600,
173 }
174}
175
176// logoutForm is GET /logout: the confirmation the rail's signout square
177// and the More menu link to, so the session does not end on one stray
178// click. The button posts to the same path.
179func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request, u store.User) {
180 s.render(w, "logout.html", struct {
181 basePage
182 }{s.baseFor(u)})
183}
184
185func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
186 if ck, err := r.Cookie(sessionCookie); err == nil {
187 s.st.DeleteWebSession(store.HashToken(ck.Value))
188 }
189 http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
190 http.Redirect(w, r, "/", http.StatusSeeOther)
191}
192
193// adminOrgs lists organizations the user administers, for owner pickers.
194func (s *Server) adminOrgs(u store.User) []string {
195 var out []string
196 if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
197 for _, o := range orgs {
198 if o.Role == "admin" {
199 out = append(out, o.Username)
200 }
201 }
202 }
203 return out
204}
205
206func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
207 s.render(w, "new.html", struct {
208 basePage
209 Orgs []string
210 Error string
211 }{s.baseFor(u), s.adminOrgs(u), errMsg})
212}
213
214func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
215 s.renderNewRepo(w, u, "")
216}
217
218// newSubmit creates a repository or an organization: /new carries both
219// forms, told apart by the org form's field. An organization's page is
220// the redirect, the same as org-create from anywhere else.
221func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
222 if r.FormValue("field") == "org-create" {
223 name := strings.TrimSpace(r.FormValue("name"))
224 if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
225 s.renderNewRepo(w, u, msg)
226 return
227 }
228 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
229 return
230 }
231 owner := r.FormValue("owner")
232 if owner == "" {
233 owner = u.Username
234 }
235 name := r.FormValue("name")
236 argv := []string{"repo", "create", owner + "/" + name}
237 if r.FormValue("visibility") == "private" {
238 argv = append(argv, "--private")
239 }
240 if _, msg, ok := s.runControl(u, argv); !ok {
241 s.renderNewRepo(w, u, msg)
242 return
243 }
244 http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
245}
246
247// pinToggle pins or unpins the repo for the logged-in viewer, through
248// repo pin/repo unpin — the same commands the CLI runs — rather than
249// writing the store directly (#261).
250func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
251 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
252 if !ok {
253 return
254 }
255 verb := "pin"
256 if s.st.IsPinned(u.ID, repo.ID) {
257 verb = "unpin"
258 }
259 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
260 s.setFlash(w, msg)
261 }
262 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
263}
264
265// bookmarkToggle saves or unsaves a repository for the viewer. Read
266// access is all a bookmark needs — it is something you do to someone
267// else's repository — and repoForUser 404s a private one either way.
268func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
269 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
270 if !ok {
271 return
272 }
273 verb := "bookmark"
274 if s.st.IsBookmarked(u.ID, repo.ID) {
275 verb = "unbookmark"
276 }
277 if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
278 s.setFlash(w, msg)
279 }
280 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
281}
282
283// bookmarksPage lists what the viewer has saved.
284// bookmarksPage keeps /bookmarks working: the list is a tab on the
285// viewer's own profile now, so there is one page of it rather than two
286// showing the same rows.
287func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
288 http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther)
289}
290
291// renderFork draws the fork form: where the copy lands and what it is
292// called. owner and name are what the field should hold, which after a
293// refusal is what was submitted.
294func (s *Server) renderFork(w http.ResponseWriter, u store.User, repo store.Repo, owner, name, errMsg string) {
295 s.render(w, "fork.html", struct {
296 basePage
297 Repo store.Repo
298 Orgs []string
299 Owner string
300 Name string
301 Error string
302 }{s.baseFor(u), repo, s.adminOrgs(u), owner, name, errMsg})
303}
304
305func (s *Server) forkForm(w http.ResponseWriter, r *http.Request, u store.User) {
306 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
307 if !ok {
308 return
309 }
310 s.renderFork(w, u, repo, u.Username, repo.Name, "")
311}
312
313// forkSubmit forks the repository to the owner the form picked and sends
314// them to it. The command decides everything that matters — read access,
315// the right to create under that owner, quota, name collisions — so a
316// refusal comes back as its own message on the form (#174).
317func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
318 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
319 if !ok {
320 return
321 }
322 owner, name := r.FormValue("owner"), r.FormValue("name")
323 if owner == "" {
324 owner = u.Username
325 }
326 if name == "" {
327 name = repo.Name
328 }
329 var fork control.ForkOut
330 argv := []string{"repo", "fork", repo.Path(), "--owner", owner, "--name", name}
331 if msg, ok := s.runControlInto(u, argv, &fork); !ok {
332 s.renderFork(w, u, repo, owner, name, msg)
333 return
334 }
335 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
336}
337
338// repoForUser is repoFor with a write/read permission requirement for a
339// logged-in user.
340func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
341 perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
342 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
343 if err != nil {
344 http.NotFound(w, r)
345 return store.Repo{}, false
346 }
347 grant, err := s.st.AccessRole(repo.ID, u.ID)
348 if err != nil {
349 http.Error(w, "internal error", http.StatusInternalServerError)
350 return store.Repo{}, false
351 }
352 if !policy.CanRead(u, repo, grant) {
353 http.NotFound(w, r) // invisible: same as nonexistent
354 return store.Repo{}, false
355 }
356 if !perm(u, repo, grant) {
357 http.Error(w, "permission denied", http.StatusForbidden)
358 return store.Repo{}, false
359 }
360 return repo, true
361}
362
363// signupForm and signupSubmit front the SSH registration path for open
364// and invite instances: same store transactions, same rules, a pasted
365// public key instead of the connecting one.
366func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
367 s.renderSignup(w, "", "")
368}
369
370func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
371 s.render(w, "register.html", struct {
372 basePage
373 Host string
374 Mode string // open | invite
375 Error string
376 Username string
377 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
378}
379
380func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
381 username := strings.TrimSpace(r.FormValue("username"))
382 keyText := strings.TrimSpace(r.FormValue("key"))
383 pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
384 if err != nil {
385 s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
386 return
387 }
388 msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
389 strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
390 if code != 0 {
391 s.renderSignup(w, errMsg, username)
392 return
393 }
394 s.render(w, "registered.html", struct {
395 basePage
396 Username string
397 Message string
398 Host string
399 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
400}
401
402// issueNewPage is what the new-issue form renders with, whether that is a
403// fresh form, a Preview round trip, or a refused create — each keeps
404// whatever the visitor typed (#271).
405type issueNewPage struct {
406 repoPage
407 Body string
408 Format string
409 Title string
410 Labels string
411 Milestone string
412 Assignee string
413 Template string
414 Templates []control.IssueTemplate
415 Draft *draft
416 CanWrite bool
417 Notice string
418}
419
420// issueCreateForm renders the new-issue form, prefilled from the repo's
421// default issue template when one exists. A Preview submit comes back
422// here with the draft in the form, so the page returns with everything
423// still typed and the rendering above the textarea (#235).
424func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
425 p, ok := s.repoFor(w, r, "")
426 if !ok {
427 return
428 }
429 p.Tab = "issues"
430 if wantsPreview(r) {
431 d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
432 s.render(w, "issuenew.html", issueNewPage{
433 repoPage: p, Body: d.Body, Format: d.Format, Title: r.FormValue("title"),
434 Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
435 Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), Draft: d, CanWrite: s.canWriteRepoAs(u, p.Repo),
436 })
437 return
438 }
439 templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
440 body, tplName := "", ""
441 if want := r.URL.Query().Get("template"); want != "" {
442 for _, t := range templates {
443 if t.Name == want {
444 body, tplName = t.Body, t.Name
445 }
446 }
447 } else {
448 for _, t := range templates {
449 if t.Name == "issue-template.md" || body == "" {
450 body, tplName = t.Body, t.Name
451 }
452 if t.Name == "issue-template.md" {
453 break
454 }
455 }
456 }
457 format := r.URL.Query().Get("format")
458 if format != "org" {
459 format = "md"
460 }
461 s.render(w, "issuenew.html", issueNewPage{
462 repoPage: p, Body: body, Format: format, Template: tplName, Templates: templates,
463 CanWrite: s.canWriteRepoAs(u, p.Repo),
464 })
465}
466
467// Issue and merge request writes run the command the CLI runs, so the
468// archived check, notifications, body format and the audit entry have one
469// implementation. Bodies travel on stdin, the way --file - does.
470
471func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
472 p, ok := s.repoFor(w, r, "")
473 if !ok {
474 return
475 }
476 repoPath := p.Repo.Path()
477 title := strings.TrimSpace(r.FormValue("title"))
478 format := bodyFormat(r)
479 if wantsPreview(r) {
480 s.issueCreateForm(w, r, u)
481 return
482 }
483 canWrite := s.canWriteRepoAs(u, p.Repo)
484 var created control.Created
485 argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
486 // Labels, milestone and assignee go on the same dispatch issue create
487 // itself resolves and applies: a typo in any of them creates nothing,
488 // and the label/milestone/assign code paths run so notifications and
489 // events happen (#271). issue create refuses the whole create when any
490 // of them is set without write access, so a reader's hand-crafted POST
491 // carrying one is dropped here rather than failing the create.
492 if canWrite {
493 argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
494 if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
495 argv = append(argv, "--milestone", milestone)
496 }
497 argv = append(argv, fieldArgs("--assignee", r.FormValue("assignee"))...)
498 }
499 code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
500 if code != protocol.ExitOK {
501 p.Tab = "issues"
502 s.render(w, "issuenew.html", issueNewPage{
503 repoPage: p, Body: r.FormValue("body"), Format: format, Title: title,
504 Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
505 Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), CanWrite: canWrite, Notice: msg,
506 })
507 return
508 }
509 n := created.Number
510 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
511}
512
513// issueEditSubmit edits title/body (author or write) and, with write
514// access, replaces the label set.
515func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
516 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
517 n := r.PathValue("n")
518 if wantsPreview(r) {
519 s.issuePage(w, r, "edit")
520 return
521 }
522 title := strings.TrimSpace(r.FormValue("title"))
523 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
524 if code != protocol.ExitOK {
525 http.Error(w, msg, statusForExit(code))
526 return
527 }
528 var cur struct {
529 Labels []string `json:"labels"`
530 }
531 if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
532 want := strings.Fields(r.FormValue("labels"))
533 var args []string
534 for _, l := range cur.Labels {
535 if !slices.Contains(want, l) {
536 args = append(args, "--remove", l)
537 }
538 }
539 for _, l := range want {
540 if !slices.Contains(cur.Labels, l) {
541 args = append(args, "--add", l)
542 }
543 }
544 if len(args) > 0 {
545 s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
546 }
547 }
548 http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
549}
550
551// mrEditSubmit edits an MR's title/body (author or write).
552func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
553 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
554 n := r.PathValue("n")
555 if wantsPreview(r) {
556 s.mrPage(w, r, "edit")
557 return
558 }
559 title := strings.TrimSpace(r.FormValue("title"))
560 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
561 if code != protocol.ExitOK {
562 http.Error(w, msg, statusForExit(code))
563 return
564 }
565 http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
566}
567
568func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
569 if wantsPreview(r) {
570 s.issuePage(w, r, "comment")
571 return
572 }
573 s.commentSubmit(w, r, u, "issue", "issues")
574}
575
576func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
577 if wantsPreview(r) {
578 s.mrPage(w, r, "comment")
579 return
580 }
581 s.commentSubmit(w, r, u, "mr", "mrs")
582}
583
584func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
585 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
586 n := r.PathValue("n")
587 code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
588 if code != protocol.ExitOK {
589 http.Error(w, msg, statusForExit(code))
590 return
591 }
592 http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
593}
594
595type editPage struct {
596 basePage
597 Repo store.Repo
598 Ref string
599 Path string
600 Content string
601 Error string
602 Blocked string
603 // Creating marks a path the branch does not have yet.
604 Creating bool
605 // Markup is set for a path the forge renders, which is where a
606 // Preview button makes sense; Draft holds one when asked for (#235).
607 Markup bool
608 Draft *draft
609 Nav fileNav
610}
611
612func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
613 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
614 if !ok {
615 return
616 }
617 ref := r.PathValue("ref")
618 filePath := strings.Trim(r.PathValue("path"), "/")
619
620 blocked := ""
621 switch {
622 case repo.Settings.RequireSignedCommits:
623 blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
624 case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
625 blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
626 }
627
628 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
629 // A branch that does not exist has nothing to edit. A path that does
630 // not exist on a real branch is a new file: commit-file creates it.
631 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
632 s.notFound(w, r)
633 return
634 }
635 content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
636 creating := err != nil
637 if creating {
638 content = nil
639 }
640 if gitutil.IsBinary(content) {
641 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
642 return
643 }
644 navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
645 nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
646 s.render(w, "edit.html", editPage{
647 basePage: s.baseFor(u), Repo: repo,
648 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
649 Markup: markupFile(filePath),
650 Nav: nav,
651 })
652}
653
654func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
655 repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
656 if !ok {
657 return
658 }
659 ref := r.PathValue("ref")
660 filePath := strings.Trim(r.PathValue("path"), "/")
661
662 // Preview: the file as the blob page will render it, above the
663 // editor, with nothing committed. Only for paths the forge renders.
664 if wantsPreview(r) && markupFile(filePath) {
665 content := r.FormValue("content")
666 d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
667 return renderReadme(path.Base(filePath), []byte(raw))
668 })
669 s.render(w, "edit.html", editPage{
670 basePage: s.baseFor(u), Repo: repo,
671 Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
672 })
673 return
674 }
675
676 // Editing is a control command; the web supplies the form and lets
677 // the registry enforce the rules — signed-commit policy, verified
678 // identity, archived repositories — so every surface agrees on them.
679 argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
680 if message := strings.TrimSpace(r.FormValue("message")); message != "" {
681 argv = append(argv, "--message", message)
682 }
683 if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
684 s.render(w, "edit.html", editPage{
685 basePage: s.baseFor(u), Repo: repo,
686 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
687 Markup: markupFile(filePath),
688 })
689 return
690 }
691 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
692}