internal/httpd/accounts.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/internal/httpd/accounts.go history · blame · raw

723 lines · 24880 bytes

38 symbols in this file
  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	}{s.anonBase(),
 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	// A login is how the owner of an account scheduled for deletion
144	// cancels it.
145	u, err := s.st.UserByID(userID)
146	if err == nil {
147		control.CancelScheduledDeletion(s.st, &u, "web")
148	}
149	if err != nil || u.Disabled {
150		s.renderLogin(w, badLoginToken, false, "")
151		return
152	}
153	sessTok, sessHash, err := store.NewToken()
154	if err != nil {
155		http.Error(w, "internal error", http.StatusInternalServerError)
156		return
157	}
158	// Seven days is the cap; the store ends it sooner after
159	// store.WebSessionIdle without a request.
160	if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
161		http.Error(w, "internal error", http.StatusInternalServerError)
162		return
163	}
164	http.SetCookie(w, s.sessionCookieFor(sessTok))
165	dest := s.takeNext(w, r)
166	if dest == "" {
167		dest = "/"
168	}
169	http.Redirect(w, r, dest, http.StatusSeeOther)
170}
171
172// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
173// the way clearCookie does, so a plain-HTTP deployment still works.
174func (s *Server) sessionCookieFor(tok string) *http.Cookie {
175	return &http.Cookie{
176		Name: sessionCookie, Value: tok, Path: "/",
177		HttpOnly: true, SameSite: sessionSameSite,
178		Secure: s.cfg.HTTP.TLS != "off",
179		MaxAge: 7 * 24 * 3600,
180	}
181}
182
183// logoutForm is GET /logout: the confirmation the rail's signout square
184// and the More menu link to, so the session does not end on one stray
185// click. The button posts to the same path.
186func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request, u store.User) {
187	s.render(w, "logout.html", struct {
188		basePage
189	}{s.baseFor(u)})
190}
191
192func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
193	if ck, err := r.Cookie(sessionCookie); err == nil {
194		s.st.DeleteWebSession(store.HashToken(ck.Value))
195	}
196	http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
197	http.Redirect(w, r, "/", http.StatusSeeOther)
198}
199
200// adminOrgs lists organizations the user administers, for owner pickers.
201func (s *Server) adminOrgs(u store.User) []string {
202	var out []string
203	if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
204		for _, o := range orgs {
205			if o.Role == "admin" {
206				out = append(out, o.Username)
207			}
208		}
209	}
210	return out
211}
212
213func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string, submitted url.Values) {
214	// A refused import keeps what was typed, except the token.
215	subm := map[string]string{
216		"owner": submitted.Get("owner"), "name": submitted.Get("name"),
217		"from": submitted.Get("from"), "visibility": submitted.Get("visibility"),
218	}
219	s.render(w, "new.html", struct {
220		basePage
221		Orgs      []string
222		Error     string
223		Submitted map[string]string
224	}{s.baseFor(u), s.adminOrgs(u), errMsg, subm})
225}
226
227func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
228	s.renderNewRepo(w, u, "", nil)
229}
230
231// newSubmit creates a repository or an organization: /new carries both
232// forms, told apart by the org form's field. An organization's page is
233// the redirect, the same as org-create from anywhere else.
234func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
235	if r.FormValue("field") == "org-create" {
236		name := strings.TrimSpace(r.FormValue("name"))
237		if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
238			s.renderNewRepo(w, u, msg, nil)
239			return
240		}
241		http.Redirect(w, r, "/"+name, http.StatusSeeOther)
242		return
243	}
244	owner := r.FormValue("owner")
245	if owner == "" {
246		owner = u.Username
247	}
248	name := r.FormValue("name")
249	if r.FormValue("field") == "import" {
250		// The token, if any, reaches the command on stdin only.
251		argv := []string{"repo", "import", owner + "/" + name, "--from", strings.TrimSpace(r.FormValue("from"))}
252		if r.FormValue("visibility") == "private" {
253			argv = append(argv, "--private")
254		}
255		var stdin string
256		if tok := strings.TrimSpace(r.FormValue("token")); tok != "" {
257			argv = append(argv, "--token-stdin")
258			stdin = tok + "\n"
259		}
260		if msg, ok := s.runControlStdin(u, argv, stdin); !ok {
261			s.renderNewRepo(w, u, msg, r.Form)
262			return
263		}
264		http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
265		return
266	}
267	argv := []string{"repo", "create", owner + "/" + name}
268	if r.FormValue("visibility") == "private" {
269		argv = append(argv, "--private")
270	}
271	if _, msg, ok := s.runControl(u, argv); !ok {
272		s.renderNewRepo(w, u, msg, nil)
273		return
274	}
275	http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
276}
277
278// pinToggle pins or unpins the repo for the logged-in viewer, through
279// repo pin/repo unpin — the same commands the CLI runs — rather than
280// writing the store directly (#261).
281func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
282	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
283	if !ok {
284		return
285	}
286	verb := "pin"
287	if s.st.IsPinned(u.ID, repo.ID) {
288		verb = "unpin"
289	}
290	if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
291		s.setFlash(w, msg)
292	}
293	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
294}
295
296// bookmarkToggle saves or unsaves a repository for the viewer. Read
297// access is all a bookmark needs — it is something you do to someone
298// else's repository — and repoForUser 404s a private one either way.
299func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
300	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
301	if !ok {
302		return
303	}
304	verb := "bookmark"
305	if s.st.IsBookmarked(u.ID, repo.ID) {
306		verb = "unbookmark"
307	}
308	if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
309		s.setFlash(w, msg)
310	}
311	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
312}
313
314// bookmarksPage lists what the viewer has saved.
315// bookmarksPage keeps /bookmarks working: the list is a tab on the
316// viewer's own profile now, so there is one page of it rather than two
317// showing the same rows.
318func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
319	http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther)
320}
321
322// renderFork draws the fork form: where the copy lands and what it is
323// called. owner and name are what the field should hold, which after a
324// refusal is what was submitted.
325func (s *Server) renderFork(w http.ResponseWriter, u store.User, repo store.Repo, owner, name, errMsg string) {
326	s.render(w, "fork.html", struct {
327		basePage
328		Repo  store.Repo
329		Orgs  []string
330		Owner string
331		Name  string
332		Error string
333	}{s.baseFor(u), repo, s.adminOrgs(u), owner, name, errMsg})
334}
335
336func (s *Server) forkForm(w http.ResponseWriter, r *http.Request, u store.User) {
337	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
338	if !ok {
339		return
340	}
341	s.renderFork(w, u, repo, u.Username, repo.Name, "")
342}
343
344// forkSubmit forks the repository to the owner the form picked and sends
345// them to it. The command decides everything that matters — read access,
346// the right to create under that owner, quota, name collisions — so a
347// refusal comes back as its own message on the form (#174).
348func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
349	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
350	if !ok {
351		return
352	}
353	owner, name := r.FormValue("owner"), r.FormValue("name")
354	if owner == "" {
355		owner = u.Username
356	}
357	if name == "" {
358		name = repo.Name
359	}
360	var fork control.ForkOut
361	argv := []string{"repo", "fork", repo.Path(), "--owner", owner, "--name", name}
362	if msg, ok := s.runControlInto(u, argv, &fork); !ok {
363		s.renderFork(w, u, repo, owner, name, msg)
364		return
365	}
366	http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
367}
368
369// repoForUser is repoFor with a write/read permission requirement for a
370// logged-in user.
371func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
372	perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
373	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
374	if err != nil {
375		http.NotFound(w, r)
376		return store.Repo{}, false
377	}
378	grant, err := s.st.AccessRole(repo.ID, u.ID)
379	if err != nil {
380		http.Error(w, "internal error", http.StatusInternalServerError)
381		return store.Repo{}, false
382	}
383	if !policy.CanRead(u, repo, grant) {
384		http.NotFound(w, r) // invisible: same as nonexistent
385		return store.Repo{}, false
386	}
387	if !perm(u, repo, grant) {
388		http.Error(w, "permission denied", http.StatusForbidden)
389		return store.Repo{}, false
390	}
391	return repo, true
392}
393
394// signupForm and signupSubmit front the SSH registration path for open
395// and invite instances: same store transactions, same rules, a pasted
396// public key instead of the connecting one.
397func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
398	s.renderSignup(w, "", "")
399}
400
401func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
402	s.render(w, "register.html", struct {
403		basePage
404		Host     string
405		Mode     string // open | invite
406		Error    string
407		Username string
408	}{s.anonBase(), s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
409}
410
411func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
412	username := strings.TrimSpace(r.FormValue("username"))
413	keyText := strings.TrimSpace(r.FormValue("key"))
414	pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
415	if err != nil {
416		s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
417		return
418	}
419	msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
420		strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
421	if code != 0 {
422		s.renderSignup(w, errMsg, username)
423		return
424	}
425	s.render(w, "registered.html", struct {
426		basePage
427		Username string
428		Message  string
429		Host     string
430	}{s.anonBase(), username, msg, s.cfg.SiteHost()})
431}
432
433// issueNewPage is what the new-issue form renders with, whether that is a
434// fresh form, a Preview round trip, or a refused create — each keeps
435// whatever the visitor typed (#271).
436type issueNewPage struct {
437	repoPage
438	Body      string
439	Format    string
440	Title     string
441	Labels    string
442	Milestone string
443	Assignee  string
444	Template  string
445	Templates []control.IssueTemplate
446	Draft     *draft
447	CanWrite  bool
448	Notice    string
449}
450
451// issueCreateForm renders the new-issue form, prefilled from the repo's
452// default issue template when one exists. A Preview submit comes back
453// here with the draft in the form, so the page returns with everything
454// still typed and the rendering above the textarea (#235).
455func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
456	p, ok := s.repoFor(w, r, "")
457	if !ok {
458		return
459	}
460	p.Tab = "issues"
461	if wantsPreview(r) {
462		d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
463		s.render(w, "issuenew.html", issueNewPage{
464			repoPage: p, Body: d.Body, Format: d.Format, Title: r.FormValue("title"),
465			Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
466			Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), Draft: d, CanWrite: s.canWriteRepoAs(u, p.Repo),
467		})
468		return
469	}
470	templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
471	body, tplName := "", ""
472	if want := r.URL.Query().Get("template"); want != "" {
473		for _, t := range templates {
474			if t.Name == want {
475				body, tplName = t.Body, t.Name
476			}
477		}
478	} else {
479		for _, t := range templates {
480			if t.Name == "issue-template.md" || body == "" {
481				body, tplName = t.Body, t.Name
482			}
483			if t.Name == "issue-template.md" {
484				break
485			}
486		}
487	}
488	format := r.URL.Query().Get("format")
489	if format != "org" {
490		format = "md"
491	}
492	s.render(w, "issuenew.html", issueNewPage{
493		repoPage: p, Body: body, Format: format, Template: tplName, Templates: templates,
494		CanWrite: s.canWriteRepoAs(u, p.Repo),
495	})
496}
497
498// Issue and merge request writes run the command the CLI runs, so the
499// archived check, notifications, body format and the audit entry have one
500// implementation. Bodies travel on stdin, the way --file - does.
501
502func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
503	p, ok := s.repoFor(w, r, "")
504	if !ok {
505		return
506	}
507	repoPath := p.Repo.Path()
508	title := strings.TrimSpace(r.FormValue("title"))
509	format := bodyFormat(r)
510	if wantsPreview(r) {
511		s.issueCreateForm(w, r, u)
512		return
513	}
514	canWrite := s.canWriteRepoAs(u, p.Repo)
515	var created control.Created
516	argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
517	// Labels, milestone and assignee go on the same dispatch issue create
518	// itself resolves and applies: a typo in any of them creates nothing,
519	// and the label/milestone/assign code paths run so notifications and
520	// events happen (#271). issue create refuses the whole create when any
521	// of them is set without write access, so a reader's hand-crafted POST
522	// carrying one is dropped here rather than failing the create.
523	if canWrite {
524		argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
525		if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
526			argv = append(argv, "--milestone", milestone)
527		}
528		argv = append(argv, fieldArgs("--assignee", r.FormValue("assignee"))...)
529	}
530	code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
531	if code != protocol.ExitOK {
532		p.Tab = "issues"
533		s.render(w, "issuenew.html", issueNewPage{
534			repoPage: p, Body: r.FormValue("body"), Format: format, Title: title,
535			Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
536			Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), CanWrite: canWrite, Notice: msg,
537		})
538		return
539	}
540	n := created.Number
541	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
542}
543
544// issueEditSubmit edits title/body (author or write) and, with write
545// access, replaces the label set.
546func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
547	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
548	n := r.PathValue("n")
549	if wantsPreview(r) {
550		s.issuePage(w, r, "edit")
551		return
552	}
553	title := strings.TrimSpace(r.FormValue("title"))
554	code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
555	if code != protocol.ExitOK {
556		http.Error(w, msg, statusForExit(code))
557		return
558	}
559	var cur struct {
560		Labels []string `json:"labels"`
561	}
562	if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
563		want := strings.Fields(r.FormValue("labels"))
564		var args []string
565		for _, l := range cur.Labels {
566			if !slices.Contains(want, l) {
567				args = append(args, "--remove", l)
568			}
569		}
570		for _, l := range want {
571			if !slices.Contains(cur.Labels, l) {
572				args = append(args, "--add", l)
573			}
574		}
575		if len(args) > 0 {
576			s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
577		}
578	}
579	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
580}
581
582// mrEditSubmit edits an MR's title/body (author or write).
583func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
584	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
585	n := r.PathValue("n")
586	if wantsPreview(r) {
587		s.mrPage(w, r, "edit")
588		return
589	}
590	title := strings.TrimSpace(r.FormValue("title"))
591	code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
592	if code != protocol.ExitOK {
593		http.Error(w, msg, statusForExit(code))
594		return
595	}
596	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
597}
598
599func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
600	if wantsPreview(r) {
601		s.issuePage(w, r, "comment")
602		return
603	}
604	s.commentSubmit(w, r, u, "issue", "issues")
605}
606
607func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
608	if wantsPreview(r) {
609		s.mrPage(w, r, "comment")
610		return
611	}
612	s.commentSubmit(w, r, u, "mr", "mrs")
613}
614
615func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
616	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
617	n := r.PathValue("n")
618	code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
619	if code != protocol.ExitOK {
620		http.Error(w, msg, statusForExit(code))
621		return
622	}
623	http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
624}
625
626type editPage struct {
627	basePage
628	Repo    store.Repo
629	Ref     string
630	Path    string
631	Content string
632	Error   string
633	Blocked string
634	// Creating marks a path the branch does not have yet.
635	Creating bool
636	// Markup is set for a path the forge renders, which is where a
637	// Preview button makes sense; Draft holds one when asked for (#235).
638	Markup bool
639	Draft  *draft
640	Nav    fileNav
641}
642
643func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
644	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
645	if !ok {
646		return
647	}
648	ref := r.PathValue("ref")
649	filePath := strings.Trim(r.PathValue("path"), "/")
650
651	blocked := ""
652	switch {
653	case repo.Settings.RequireSignedCommits:
654		blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
655	case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
656		blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
657	}
658
659	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
660	// A branch that does not exist has nothing to edit. A path that does
661	// not exist on a real branch is a new file: commit-file creates it.
662	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
663		s.notFound(w, r)
664		return
665	}
666	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
667	creating := err != nil
668	if creating {
669		content = nil
670	}
671	if gitutil.IsBinary(content) {
672		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
673		return
674	}
675	navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
676	nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
677	s.render(w, "edit.html", editPage{
678		basePage: s.baseFor(u), Repo: repo,
679		Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
680		Markup: markupFile(filePath),
681		Nav:    nav,
682	})
683}
684
685func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
686	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
687	if !ok {
688		return
689	}
690	ref := r.PathValue("ref")
691	filePath := strings.Trim(r.PathValue("path"), "/")
692
693	// Preview: the file as the blob page will render it, above the
694	// editor, with nothing committed. Only for paths the forge renders.
695	if wantsPreview(r) && markupFile(filePath) {
696		content := r.FormValue("content")
697		d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
698			return renderReadme(path.Base(filePath), []byte(raw))
699		})
700		s.render(w, "edit.html", editPage{
701			basePage: s.baseFor(u), Repo: repo,
702			Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
703		})
704		return
705	}
706
707	// Editing is a control command; the web supplies the form and lets
708	// the registry enforce the rules — signed-commit policy, verified
709	// identity, archived repositories — so every surface agrees on them.
710	argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
711	if message := strings.TrimSpace(r.FormValue("message")); message != "" {
712		argv = append(argv, "--message", message)
713	}
714	if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
715		s.render(w, "edit.html", editPage{
716			basePage: s.baseFor(u), Repo: repo,
717			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
718			Markup: markupFile(filePath),
719		})
720		return
721	}
722	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
723}