package httpd import ( "fmt" "html/template" "log" "net/http" "net/url" "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 }{s.anonBase(), 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. // A login is how the owner of an account scheduled for deletion // cancels it. u, err := s.st.UserByID(userID) if err == nil { control.CancelScheduledDeletion(s.st, &u, "web") } if 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, submitted url.Values) { // A refused import keeps what was typed, except the token. subm := map[string]string{ "owner": submitted.Get("owner"), "name": submitted.Get("name"), "from": submitted.Get("from"), "visibility": submitted.Get("visibility"), } s.render(w, "new.html", struct { basePage Orgs []string Error string Submitted map[string]string }{s.baseFor(u), s.adminOrgs(u), errMsg, subm}) } func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { s.renderNewRepo(w, u, "", nil) } // 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, nil) return } http.Redirect(w, r, "/"+name, http.StatusSeeOther) return } owner := r.FormValue("owner") if owner == "" { owner = u.Username } name := r.FormValue("name") if r.FormValue("field") == "import" { // The token, if any, reaches the command on stdin only. argv := []string{"repo", "import", owner + "/" + name, "--from", strings.TrimSpace(r.FormValue("from"))} if r.FormValue("visibility") == "private" { argv = append(argv, "--private") } var stdin string if tok := strings.TrimSpace(r.FormValue("token")); tok != "" { argv = append(argv, "--token-stdin") stdin = tok + "\n" } if msg, ok := s.runControlStdin(u, argv, stdin); !ok { s.renderNewRepo(w, u, msg, r.Form) return } http.Redirect(w, r, "/"+owner+"/"+name, http.StatusSeeOther) return } 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, nil) 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 }{s.anonBase(), 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 }{s.anonBase(), 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) }