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