package httpd
import (
"fmt"
"html/template"
"log"
"net/http"
"path"
"slices"
"strconv"
"strings"
"time"
gossh "golang.org/x/crypto/ssh"
"gitbay.org/gitbay/internal/control"
"gitbay.org/gitbay/internal/gitutil"
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/protocol"
"gitbay.org/gitbay/internal/store"
)
const sessionCookie = "gitbay_session"
// sessionSameSite is Lax so a login link followed from a mail client keeps
// its session through the redirect. Cross-site POSTs are refused by
// checkOrigin and carry no Lax cookie anyway.
const sessionSameSite = http.SameSiteLaxMode
// badLoginToken is what every refused /login?token= gets, whatever the
// reason. The reasons differ in whether the account exists.
const badLoginToken = "that login link is invalid, expired, or already used — mint a new one"
// viewer returns the logged-in user, or a zero User for anonymous visitors.
// Only meaningful in accounts mode; in view_only no session route exists so
// every request is anonymous.
func (s *Server) viewer(r *http.Request) store.User {
ck, err := r.Cookie(sessionCookie)
if err != nil {
return store.User{}
}
u, err := s.st.WebSessionUser(store.HashToken(ck.Value))
if err != nil {
return store.User{}
}
return u
}
// requireUser wraps a handler that needs a session.
func (s *Server) requireUser(h func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u := s.viewer(r)
if u.ID == 0 {
if r.Method == http.MethodGet {
s.setNext(w, r.URL.RequestURI())
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
h(w, r, u)
}
}
// checkOrigin rejects cross-site POSTs. It is the primary CSRF defense:
// sessions use SameSite=Lax, which withholds the cookie from a cross-site
// POST but not from a cross-site top-level GET.
func (s *Server) checkOrigin(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && origin != "null" {
host := strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://")
if host != r.Host {
http.Error(w, "cross-origin request refused", http.StatusForbidden)
return
}
}
h(w, r)
}
}
// renderLogin draws the login page. Mode carries the registration mode so
// the page can tell a brand-new visitor how to get an account. EmailLogin
// says whether this instance can mail a link; Sent switches the page to the
// confirmation that follows a request.
func (s *Server) renderLogin(w http.ResponseWriter, errMsg string, sent bool, next string) {
s.render(w, "login.html", struct {
basePage
Mode string // closed | invite | open
Error string
EmailLogin bool
Sent bool
Next string
}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()},
s.cfg.Registration.Mode, errMsg, s.emailLoginEnabled(), sent, next})
}
// emailLoginEnabled reports whether a link can be mailed at all. There is no
// separate switch: the capability is exactly the SMTP the instance already
// configured for verification and notification mail.
func (s *Server) emailLoginEnabled() bool {
return s.cfg.Web.Mode == "accounts" && s.cfg.Mail.SMTPHost != ""
}
// loginSubmit mails a one-time login link. The response is the same page
// whatever happened, including when nothing happened.
func (s *Server) loginSubmit(w http.ResponseWriter, r *http.Request) {
if !s.emailLoginEnabled() {
s.notFound(w, r)
return
}
// The per-account bound lives in the store and survives a restart; this
// one stops a single source from spending every account's budget.
if allowed, wait := s.apiLimit.allow("login"+s.clientIP(r), true); !allowed {
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1))
http.Error(w, "too many login requests; wait a moment", http.StatusTooManyRequests)
return
}
if err := control.RequestLoginLink(s.cfg, s.st, r.FormValue("identifier")); err != nil {
log.Printf("login link: %v", err)
}
s.renderLogin(w, "", true, "")
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
// token, when present, is a single-use secret in the query string —
// the documented exception to "never in a URL" (Threat-Model). No
// cache may keep a copy of this response.
w.Header().Set("Cache-Control", "no-store")
token := r.URL.Query().Get("token")
if token == "" {
s.renderLogin(w, "", false, s.peekNext(r))
return
}
userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
if err != nil {
s.renderLogin(w, badLoginToken, false, "")
return
}
// A token minted before the account was suspended is still consumable,
// and the session it would create renders every page the account can
// read. Checking here covers every mint path. The message is the one a
// bad token gets: a distinct one would confirm the account exists.
if u, err := s.st.UserByID(userID); err != nil || u.Disabled {
s.renderLogin(w, badLoginToken, false, "")
return
}
sessTok, sessHash, err := store.NewToken()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Seven days is the cap; the store ends it sooner after
// store.WebSessionIdle without a request.
if err := s.st.CreateWebSession(sessHash, userID, 7*24*time.Hour); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, s.sessionCookieFor(sessTok))
dest := s.takeNext(w, r)
if dest == "" {
dest = "/"
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// sessionCookieFor is the cookie a new session ships in. Secure follows TLS
// the way clearCookie does, so a plain-HTTP deployment still works.
func (s *Server) sessionCookieFor(tok string) *http.Cookie {
return &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/",
HttpOnly: true, SameSite: sessionSameSite,
Secure: s.cfg.HTTP.TLS != "off",
MaxAge: 7 * 24 * 3600,
}
}
// logoutForm is GET /logout: the confirmation the rail's signout square
// and the More menu link to, so the session does not end on one stray
// click. The button posts to the same path.
func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request, u store.User) {
s.render(w, "logout.html", struct {
basePage
}{s.baseFor(u)})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(sessionCookie); err == nil {
s.st.DeleteWebSession(store.HashToken(ck.Value))
}
http.SetCookie(w, s.clearCookie(sessionCookie, sessionSameSite))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// adminOrgs lists organizations the user administers, for owner pickers.
func (s *Server) adminOrgs(u store.User) []string {
var out []string
if orgs, err := s.st.ListOrgsForUser(u.ID); err == nil {
for _, o := range orgs {
if o.Role == "admin" {
out = append(out, o.Username)
}
}
}
return out
}
func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
s.render(w, "new.html", struct {
basePage
Orgs []string
Error string
}{s.baseFor(u), s.adminOrgs(u), errMsg})
}
func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
s.renderNewRepo(w, u, "")
}
// newSubmit creates a repository or an organization: /new carries both
// forms, told apart by the org form's field. An organization's page is
// the redirect, the same as org-create from anywhere else.
func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
if r.FormValue("field") == "org-create" {
name := strings.TrimSpace(r.FormValue("name"))
if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
s.renderNewRepo(w, u, msg)
return
}
http.Redirect(w, r, "/"+name, http.StatusSeeOther)
return
}
owner := r.FormValue("owner")
if owner == "" {
owner = u.Username
}
name := r.FormValue("name")
argv := []string{"repo", "create", owner + "/" + name}
if r.FormValue("visibility") == "private" {
argv = append(argv, "--private")
}
if _, msg, ok := s.runControl(u, argv); !ok {
s.renderNewRepo(w, u, msg)
return
}
http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther)
}
// pinToggle pins or unpins the repo for the logged-in viewer, through
// repo pin/repo unpin — the same commands the CLI runs — rather than
// writing the store directly (#261).
func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanRead)
if !ok {
return
}
verb := "pin"
if s.st.IsPinned(u.ID, repo.ID) {
verb = "unpin"
}
if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
s.setFlash(w, msg)
}
http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
}
// bookmarkToggle saves or unsaves a repository for the viewer. Read
// access is all a bookmark needs — it is something you do to someone
// else's repository — and repoForUser 404s a private one either way.
func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanRead)
if !ok {
return
}
verb := "bookmark"
if s.st.IsBookmarked(u.ID, repo.ID) {
verb = "unbookmark"
}
if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
s.setFlash(w, msg)
}
http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
}
// bookmarksPage lists what the viewer has saved.
// bookmarksPage keeps /bookmarks working: the list is a tab on the
// viewer's own profile now, so there is one page of it rather than two
// showing the same rows.
func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther)
}
// renderFork draws the fork form: where the copy lands and what it is
// called. owner and name are what the field should hold, which after a
// refusal is what was submitted.
func (s *Server) renderFork(w http.ResponseWriter, u store.User, repo store.Repo, owner, name, errMsg string) {
s.render(w, "fork.html", struct {
basePage
Repo store.Repo
Orgs []string
Owner string
Name string
Error string
}{s.baseFor(u), repo, s.adminOrgs(u), owner, name, errMsg})
}
func (s *Server) forkForm(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanRead)
if !ok {
return
}
s.renderFork(w, u, repo, u.Username, repo.Name, "")
}
// forkSubmit forks the repository to the owner the form picked and sends
// them to it. The command decides everything that matters — read access,
// the right to create under that owner, quota, name collisions — so a
// refusal comes back as its own message on the form (#174).
func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanRead)
if !ok {
return
}
owner, name := r.FormValue("owner"), r.FormValue("name")
if owner == "" {
owner = u.Username
}
if name == "" {
name = repo.Name
}
var fork control.ForkOut
argv := []string{"repo", "fork", repo.Path(), "--owner", owner, "--name", name}
if msg, ok := s.runControlInto(u, argv, &fork); !ok {
s.renderFork(w, u, repo, owner, name, msg)
return
}
http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
}
// repoForUser is repoFor with a write/read permission requirement for a
// logged-in user.
func (s *Server) repoForUser(w http.ResponseWriter, r *http.Request, u store.User,
perm func(store.User, store.Repo, string) bool) (store.Repo, bool) {
repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
if err != nil {
http.NotFound(w, r)
return store.Repo{}, false
}
grant, err := s.st.AccessRole(repo.ID, u.ID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return store.Repo{}, false
}
if !policy.CanRead(u, repo, grant) {
http.NotFound(w, r) // invisible: same as nonexistent
return store.Repo{}, false
}
if !perm(u, repo, grant) {
http.Error(w, "permission denied", http.StatusForbidden)
return store.Repo{}, false
}
return repo, true
}
// signupForm and signupSubmit front the SSH registration path for open
// and invite instances: same store transactions, same rules, a pasted
// public key instead of the connecting one.
func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
s.renderSignup(w, "", "")
}
func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
s.render(w, "register.html", struct {
basePage
Host string
Mode string // open | invite
Error string
Username string
}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
}
func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.FormValue("username"))
keyText := strings.TrimSpace(r.FormValue("key"))
pub, _, _, _, err := gossh.ParseAuthorizedKey([]byte(keyText))
if err != nil {
s.renderSignup(w, "that does not parse as an SSH public key (expected e.g. \"ssh-ed25519 AAAA... comment\")", username)
return
}
msg, errMsg, code := control.RegisterAccount(s.cfg, s.st, pub, username,
strings.TrimSpace(r.FormValue("email")), strings.TrimSpace(r.FormValue("invite")))
if code != 0 {
s.renderSignup(w, errMsg, username)
return
}
s.render(w, "registered.html", struct {
basePage
Username string
Message string
Host string
}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, username, msg, s.cfg.SiteHost()})
}
// issueNewPage is what the new-issue form renders with, whether that is a
// fresh form, a Preview round trip, or a refused create — each keeps
// whatever the visitor typed (#271).
type issueNewPage struct {
repoPage
Body string
Format string
Title string
Labels string
Milestone string
Assignee string
Template string
Templates []control.IssueTemplate
Draft *draft
CanWrite bool
Notice string
}
// issueCreateForm renders the new-issue form, prefilled from the repo's
// default issue template when one exists. A Preview submit comes back
// here with the draft in the form, so the page returns with everything
// still typed and the rendering above the textarea (#235).
func (s *Server) issueCreateForm(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := s.repoFor(w, r, "")
if !ok {
return
}
p.Tab = "issues"
if wantsPreview(r) {
d := s.draftFor(r, p.Repo, "body", "body", bodyFormat(r))
s.render(w, "issuenew.html", issueNewPage{
repoPage: p, Body: d.Body, Format: d.Format, Title: r.FormValue("title"),
Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), Draft: d, CanWrite: s.canWriteRepoAs(u, p.Repo),
})
return
}
templates := control.IssueTemplates(p.Dir, p.Repo.DefaultBranch)
body, tplName := "", ""
if want := r.URL.Query().Get("template"); want != "" {
for _, t := range templates {
if t.Name == want {
body, tplName = t.Body, t.Name
}
}
} else {
for _, t := range templates {
if t.Name == "issue-template.md" || body == "" {
body, tplName = t.Body, t.Name
}
if t.Name == "issue-template.md" {
break
}
}
}
format := r.URL.Query().Get("format")
if format != "org" {
format = "md"
}
s.render(w, "issuenew.html", issueNewPage{
repoPage: p, Body: body, Format: format, Template: tplName, Templates: templates,
CanWrite: s.canWriteRepoAs(u, p.Repo),
})
}
// Issue and merge request writes run the command the CLI runs, so the
// archived check, notifications, body format and the audit entry have one
// implementation. Bodies travel on stdin, the way --file - does.
func (s *Server) issueCreateSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
p, ok := s.repoFor(w, r, "")
if !ok {
return
}
repoPath := p.Repo.Path()
title := strings.TrimSpace(r.FormValue("title"))
format := bodyFormat(r)
if wantsPreview(r) {
s.issueCreateForm(w, r, u)
return
}
canWrite := s.canWriteRepoAs(u, p.Repo)
var created control.Created
argv := []string{"issue", "create", repoPath, "--title", title, "--format", format, "--file", "-"}
// Labels, milestone and assignee go on the same dispatch issue create
// itself resolves and applies: a typo in any of them creates nothing,
// and the label/milestone/assign code paths run so notifications and
// events happen (#271). issue create refuses the whole create when any
// of them is set without write access, so a reader's hand-crafted POST
// carrying one is dropped here rather than failing the create.
if canWrite {
argv = append(argv, fieldArgs("--label", r.FormValue("labels"))...)
if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
argv = append(argv, "--milestone", milestone)
}
argv = append(argv, fieldArgs("--assignee", r.FormValue("assignee"))...)
}
code, msg := s.dispatchIntoStdin(u, argv, r.FormValue("body"), &created)
if code != protocol.ExitOK {
p.Tab = "issues"
s.render(w, "issuenew.html", issueNewPage{
repoPage: p, Body: r.FormValue("body"), Format: format, Title: title,
Labels: r.FormValue("labels"), Milestone: r.FormValue("milestone"), Assignee: r.FormValue("assignee"),
Templates: control.IssueTemplates(p.Dir, p.Repo.DefaultBranch), CanWrite: canWrite, Notice: msg,
})
return
}
n := created.Number
http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
}
// issueEditSubmit edits title/body (author or write) and, with write
// access, replaces the label set.
func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
n := r.PathValue("n")
if wantsPreview(r) {
s.issuePage(w, r, "edit")
return
}
title := strings.TrimSpace(r.FormValue("title"))
code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
if code != protocol.ExitOK {
http.Error(w, msg, statusForExit(code))
return
}
var cur struct {
Labels []string `json:"labels"`
}
if _, ok := s.runControlInto(u, []string{"issue", "show", repoPath, n}, &cur); ok {
want := strings.Fields(r.FormValue("labels"))
var args []string
for _, l := range cur.Labels {
if !slices.Contains(want, l) {
args = append(args, "--remove", l)
}
}
for _, l := range want {
if !slices.Contains(cur.Labels, l) {
args = append(args, "--add", l)
}
}
if len(args) > 0 {
s.runControl(u, append([]string{"issue", "label", repoPath, n}, args...))
}
}
http.Redirect(w, r, fmt.Sprintf("/%s/issues/%s", repoPath, n), http.StatusSeeOther)
}
// mrEditSubmit edits an MR's title/body (author or write).
func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
n := r.PathValue("n")
if wantsPreview(r) {
s.mrPage(w, r, "edit")
return
}
title := strings.TrimSpace(r.FormValue("title"))
code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
if code != protocol.ExitOK {
http.Error(w, msg, statusForExit(code))
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/mrs/%s", repoPath, n), http.StatusSeeOther)
}
func (s *Server) issueCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
if wantsPreview(r) {
s.issuePage(w, r, "comment")
return
}
s.commentSubmit(w, r, u, "issue", "issues")
}
func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
if wantsPreview(r) {
s.mrPage(w, r, "comment")
return
}
s.commentSubmit(w, r, u, "mr", "mrs")
}
func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
n := r.PathValue("n")
code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
if code != protocol.ExitOK {
http.Error(w, msg, statusForExit(code))
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/%s/%s", repoPath, segment, n), http.StatusSeeOther)
}
type editPage struct {
basePage
Repo store.Repo
Ref string
Path string
Content string
Error string
Blocked string
// Creating marks a path the branch does not have yet.
Creating bool
// Markup is set for a path the forge renders, which is where a
// Preview button makes sense; Draft holds one when asked for (#235).
Markup bool
Draft *draft
Nav fileNav
}
func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
if !ok {
return
}
ref := r.PathValue("ref")
filePath := strings.Trim(r.PathValue("path"), "/")
blocked := ""
switch {
case repo.Settings.RequireSignedCommits:
blocked = repo.Path() + " requires signed commits and the web editor cannot sign; edit locally and push a signed commit."
case repo.Settings.RequireMR && slices.Contains(repo.Settings.ProtectedBranches, ref):
blocked = "branch " + ref + " accepts changes through merge requests only; edit on another branch and open one."
}
dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
// A branch that does not exist has nothing to edit. A path that does
// not exist on a real branch is a new file: commit-file creates it.
if _, err := gitutil.ResolveRef(dir, "refs/heads/"+ref); err != nil {
s.notFound(w, r)
return
}
content, err := gitutil.ReadBlob(dir, "refs/heads/"+ref, filePath, maxRenderBytes)
creating := err != nil
if creating {
content = nil
}
if gitutil.IsBinary(content) {
http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
return
}
navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
s.render(w, "edit.html", editPage{
basePage: s.baseFor(u), Repo: repo,
Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
Markup: markupFile(filePath),
Nav: nav,
})
}
func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
repo, ok := s.repoForUser(w, r, u, policy.CanWrite)
if !ok {
return
}
ref := r.PathValue("ref")
filePath := strings.Trim(r.PathValue("path"), "/")
// Preview: the file as the blob page will render it, above the
// editor, with nothing committed. Only for paths the forge renders.
if wantsPreview(r) && markupFile(filePath) {
content := r.FormValue("content")
d := s.draftWith(r, "content", "", content, func(raw, _ string) template.HTML {
return renderReadme(path.Base(filePath), []byte(raw))
})
s.render(w, "edit.html", editPage{
basePage: s.baseFor(u), Repo: repo,
Ref: ref, Path: filePath, Content: content, Markup: true, Draft: d,
})
return
}
// Editing is a control command; the web supplies the form and lets
// the registry enforce the rules — signed-commit policy, verified
// identity, archived repositories — so every surface agrees on them.
argv := []string{"repo", "commit-file", repo.Path(), filePath, "--ref", ref, "--file", "-"}
if message := strings.TrimSpace(r.FormValue("message")); message != "" {
argv = append(argv, "--message", message)
}
if msg, ok := s.runControlStdin(u, argv, r.FormValue("content")); !ok {
s.render(w, "edit.html", editPage{
basePage: s.baseFor(u), Repo: repo,
Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
Markup: markupFile(filePath),
})
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", repo.Path(), ref, filePath), http.StatusSeeOther)
}