internal/httpd/accounts.go

3bcdce33fb9a2309312854331359d376171c7368
gitbay/internal/httpd/accounts.go history · blame · raw

668 lines · 22730 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// issueCreateForm renders the new-issue form, prefilled from the repo's
403// default issue template when one exists. A Preview submit comes back
404// here with the draft in the form, so the page returns with everything
405// still typed and the rendering above the textarea (#235).
406func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
407	p, ok := s.repoFor(w, r, "")
408	if !ok {
409		return
410	}
411	p.Tab = "issues"
412	if wantsPreview(r) {
413		d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
414		s.render(w, "issuenew.html", struct {
415			repoPage
416			Body      string
417			Format    string
418			Title     string
419			Labels    string
420			Template  string
421			Templates []control.IssueTemplate
422			Draft     *draft
423		}{p, d.Body, d.Format, r.FormValue("title"), r.FormValue("labels"),
424			"", control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), d})
425		return
426	}
427	templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
428	body, tplName := "", ""
429	if want := r.URL.Query().Get("template"); want != "" {
430		for _, t := range templates {
431			if t.Name == want {
432				body, tplName = t.Body, t.Name
433			}
434		}
435	} else {
436		for _, t := range templates {
437			if t.Name == "issue-template.md" || body == "" {
438				body, tplName = t.Body, t.Name
439			}
440			if t.Name == "issue-template.md" {
441				break
442			}
443		}
444	}
445	format := r.URL.Query().Get("format")
446	if format != "org" {
447		format = "md"
448	}
449	s.render(w, "issuenew.html", struct {
450		repoPage
451		Body      string
452		Format    string
453		Title     string
454		Labels    string
455		Template  string
456		Templates []control.IssueTemplate
457		Draft     *draft
458	}{p, body, format, "", "", tplName, templates, nil})
459}
460
461// Issue and merge request writes run the command the CLI runs, so the
462// archived check, notifications, body format and the audit entry have one
463// implementation. Bodies travel on stdin, the way --file - does.
464
465func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
466	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
467	title := strings.TrimSpace(r.FormValue("title"))
468	format := bodyFormat(r)
469	if wantsPreview(r) {
470		s.issueCreateForm(w, r, u)
471		return
472	}
473	var created control.Created
474	argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
475	code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
476	if code != protocol.ExitOK {
477		http.Error(w, msg, statusForExit(code))
478		return
479	}
480	n := created.Number
481	// Labels need write access, matching the SSH rule; the command refuses
482	// otherwise and the issue stands without them.
483	if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
484		s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
485	}
486	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
487}
488
489// issueEditSubmit edits title/body (author or write) and, with write
490// access, replaces the label set.
491func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
492	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
493	n := r.PathValue("n")
494	if wantsPreview(r) {
495		s.issuePage(w, r, "edit")
496		return
497	}
498	title := strings.TrimSpace(r.FormValue("title"))
499	code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
500	if code != protocol.ExitOK {
501		http.Error(w, msg, statusForExit(code))
502		return
503	}
504	var cur struct {
505		Labels []string `json:"labels"`
506	}
507	if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
508		want := strings.Fields(r.FormValue("labels"))
509		var args []string
510		for _, l := range cur.Labels {
511			if !slices.Contains(want, l) {
512				args = append(args, "--remove", l)
513			}
514		}
515		for _, l := range want {
516			if !slices.Contains(cur.Labels, l) {
517				args = append(args, "--add", l)
518			}
519		}
520		if len(args) > 0 {
521			s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
522		}
523	}
524	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
525}
526
527// mrEditSubmit edits an MR's title/body (author or write).
528func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
529	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
530	n := r.PathValue("n")
531	if wantsPreview(r) {
532		s.mrPage(w, r, "edit")
533		return
534	}
535	title := strings.TrimSpace(r.FormValue("title"))
536	code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
537	if code != protocol.ExitOK {
538		http.Error(w, msg, statusForExit(code))
539		return
540	}
541	http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
542}
543
544func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
545	if wantsPreview(r) {
546		s.issuePage(w, r, "comment")
547		return
548	}
549	s.commentSubmit(w, r, u, "issue", "issues")
550}
551
552func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
553	if wantsPreview(r) {
554		s.mrPage(w, r, "comment")
555		return
556	}
557	s.commentSubmit(w, r, u, "mr", "mrs")
558}
559
560func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
561	repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
562	n := r.PathValue("n")
563	code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
564	if code != protocol.ExitOK {
565		http.Error(w, msg, statusForExit(code))
566		return
567	}
568	http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
569}
570
571type editPage struct {
572	basePage
573	Repo    store.Repo
574	Ref     string
575	Path    string
576	Content string
577	Error   string
578	Blocked string
579	// Creating marks a path the branch does not have yet.
580	Creating bool
581	// Markup is set for a path the forge renders, which is where a
582	// Preview button makes sense; Draft holds one when asked for (#235).
583	Markup bool
584	Draft  *draft
585	Nav    fileNav
586}
587
588func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
589	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
590	if !ok {
591		return
592	}
593	ref := r.PathValue("ref")
594	filePath := strings.Trim(r.PathValue("path"), "/")
595
596	blocked := ""
597	switch {
598	case repo.Settings.RequireSignedCommits:
599		blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
600	case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
601		blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
602	}
603
604	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
605	// A branch that does not exist has nothing to edit. A path that does
606	// not exist on a real branch is a new file: commit-file creates it.
607	if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
608		s.notFound(w, r)
609		return
610	}
611	content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
612	creating := err != nil
613	if creating {
614		content = nil
615	}
616	if gitutil.IsBinary(content) {
617		http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
618		return
619	}
620	navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
621	nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
622	s.render(w, "edit.html", editPage{
623		basePage: s.baseFor(u), Repo: repo,
624		Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
625		Markup: markupFile(filePath),
626		Nav:    nav,
627	})
628}
629
630func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
631	repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
632	if !ok {
633		return
634	}
635	ref := r.PathValue("ref")
636	filePath := strings.Trim(r.PathValue("path"), "/")
637
638	// Preview: the file as the blob page will render it, above the
639	// editor, with nothing committed. Only for paths the forge renders.
640	if wantsPreview(r) && markupFile(filePath) {
641		content := r.FormValue("content")
642		d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
643			return renderReadme(path.Base(filePath), []byte(raw))
644		})
645		s.render(w, "edit.html", editPage{
646			basePage: s.baseFor(u), Repo: repo,
647			Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
648		})
649		return
650	}
651
652	// Editing is a control command; the web supplies the form and lets
653	// the registry enforce the rules — signed-commit policy, verified
654	// identity, archived repositories — so every surface agrees on them.
655	argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
656	if message := strings.TrimSpace(r.FormValue("message")); message != "" {
657		argv = append(argv, "--message", message)
658	}
659	if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
660		s.render(w, "edit.html", editPage{
661			basePage: s.baseFor(u), Repo: repo,
662			Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
663			Markup: markupFile(filePath),
664		})
665		return
666	}
667	http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
668}