package httpd import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "gitbay.org/gitbay/internal/control" "gitbay.org/gitbay/internal/protocol" "gitbay.org/gitbay/internal/store" ) // accountKey is one SSH key as the settings page shows it: enough to // recognise which key this is without printing the whole blob. type accountKey struct { Fingerprint string Algo string Scope string Label string Confirm string // the 8 characters after SHA256: — a label can be empty } type accountPGP struct { Fingerprint string UIDs []string Expired bool Revoked bool Confirm string // the fingerprint's first 8 characters } // accountDevice is one registered APNs device as the settings page shows // it. No form of the token reaches the page but the masked column: // removal confirms on the id, which is not device-identifying. type accountDevice struct { ID int64 Label string // Token is rendered by control.ShortToken, the same renderer // notifications device list uses. Token string LastSeenAt string Confirm string // the id as text, typed back to confirm removal } // accountToken is one API token as the settings page shows it: never // the token itself, only what identifies and describes it. type accountToken struct { Name string Scope string Created string Expires string // "never" or a formatted timestamp LastUsed string // "never" or a formatted timestamp } // accountForm renders the account's own settings: keys, addresses, and the // commands for everything that stays on SSH. func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) { s.accountPage(w, r, u) } // accountPage renders the settings page. func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) { s.renderAccount(w, r, u, "") } // renderAccount draws the settings page. tokenShown is a token minted // by the request being answered; it is shown in this response only. func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.User, tokenShown string) { var keys []accountKey if list, err := s.st.ListSSHKeys(u.ID); err == nil { for _, k := range list { confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:")) keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm}) } } var pgp []accountPGP if list, err := s.st.ListPGPKeys(u.ID); err == nil { for _, k := range list { var uids []string json.Unmarshal([]byte(k.UIDsJSON), &uids) confirm := prefix8(k.Fingerprint) pgp = append(pgp, accountPGP{ Fingerprint: k.Fingerprint, UIDs: uids, Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm, }) } } emails, _ := s.st.ListEmails(u.ID) var profile control.ProfileOut s.runControlInto(u, []string{"profile", "show"}, &profile) mailOn, _ := s.st.MailEnabled(u.ID) watchOn, _ := s.st.WatchEnabled(u.ID) pushOn, _ := s.st.PushEnabled(u.ID) replyOn, _ := s.st.ReplyEnabled(u.ID) theme, _ := s.st.Theme(u.ID) diffPref, _ := s.st.DiffLayout(u.ID) var devices []accountDevice if list, err := s.st.PushDevices(u.ID); err == nil { for _, d := range list { devices = append(devices, accountDevice{ID: d.ID, Label: d.Label, Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt, Confirm: strconv.FormatInt(d.ID, 10)}) } } var tokens []accountToken if list, err := s.st.ListAPITokens(u.ID); err == nil { for _, tk := range list { expires, lastUsed := "never", "never" if tk.ExpiresAt != nil { expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC") } if tk.LastUsedAt != nil { lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC") } tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed}) } } // The about text is a file. The page points at it rather than editing // it: the repository's own editor already does that job. aboutRepo := u.Username + "/" + control.ProfileRepoName aboutEdit := "" if profile.AboutPath != "" { aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath } notice := s.takeFlash(w, r) reauth := s.reauthNotice(w, notice, "/settings") s.render(w, "account.html", struct { basePage Tab string // marks the rail's Settings row as current Keys []accountKey PGP []accountPGP Emails []store.Email Profile control.ProfileOut LinksText string AboutRepo string // /.gitbay, which holds the about text AboutEdit string // the file editor's URL, empty when there is no file yet Host string Notice string Message string MailOn bool WatchOn bool PushOn bool ReplyOn bool ReplyOffered bool // the instance reads replies to its mail Devices []accountDevice ThemeSetting string // system, light or dark: the form's selected option DiffSetting string // unified or split: the form's selected option Tokens []accountToken TokenShown string // a token minted by this request, shown once Reauth bool // Notice is the stale-session refusal: link to sign in }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), aboutRepo, aboutEdit, s.cfg.SiteHost(), notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, replyOn, s.cfg.Mail.Inbound.Enabled, devices, theme, diffPref, tokens, tokenShown, reauth}) } // accountExport hands the browser the same bundle `account export` // writes. The command is ReadOnly, so a GET is enough; the response is an // attachment rather than a page because the bundle is a file to keep. func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) { out, msg, code := s.runControlCode(u, []string{"account", "export"}) if code != protocol.ExitOK { s.setFlash(w, msg) http.Redirect(w, r, "/settings", http.StatusSeeOther) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle")) io.WriteString(w, out) } // profileLinksText turns a profile's links into the form the textarea // shows and reads back: one per line, "label|url" when there is a label // and the bare url otherwise. func profileLinksText(links []store.ProfileLink) string { lines := make([]string, len(links)) for i, l := range links { if l.Label != "" { lines[i] = l.Label + "|" + l.URL } else { lines[i] = l.URL } } return strings.Join(lines, "\n") } // profileLinkArgs turns the textarea back into the --link values profile // set expects: one per non-blank line, or a single empty one to clear the // list when the field was emptied. func profileLinkArgs(raw string) []string { var links []string for _, line := range strings.Split(raw, "\n") { if line = strings.TrimSpace(line); line != "" { links = append(links, line) } } if links == nil { return []string{""} } return links } // accountSubmit routes the account forms to their commands. Keys, // addresses and the profile are the whole surface — no secret is accepted // over the web. func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) { back := func(msg, note string) { q := "" if note != "" { q = "?m=" + url.QueryEscape(note) } s.setFlash(w, msg) http.Redirect(w, r, "/settings"+q, http.StatusSeeOther) } switch r.FormValue("field") { case "key-add": body := strings.TrimSpace(r.FormValue("key")) if body == "" { back("paste a public key in authorized_keys format", "") return } argv := []string{"keys", "add"} if scope := r.FormValue("scope"); scope == "git" { argv = append(argv, "--scope", "git") } if label := strings.TrimSpace(r.FormValue("label")); label != "" { argv = append(argv, "--label", label) } if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok { back(msg, "") return } back("", "key registered") case "account-delete": if ok, msg := confirmed(r, u.Username); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"account", "delete", "--confirm", u.Username}); !ok { back(msg, "") return } back("", "a deletion link was mailed to your primary address; nothing changes until it is opened") case "key-remove": want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")) if ok, msg := confirmed(r, want); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok { back(msg, "") return } back("", "key removed") case "pgp-add": body := strings.TrimSpace(r.FormValue("key")) if body == "" { back("paste an armored OpenPGP public key", "") return } if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok { back(msg, "") return } back("", "PGP key registered") case "pgp-remove": fp := r.FormValue("fingerprint") want := prefix8(fp) if ok, msg := confirmed(r, want); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok { back(msg, "") return } back("", "PGP key removed") case "email-add": if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok { back(msg, "") return } back("", "check that inbox for a verification code") case "email-verify": if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok { back(msg, "") return } back("", "address verified") case "email-remove": address := r.FormValue("address") if ok, msg := confirmed(r, address); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok { back(msg, "") return } back("", "address removed") case "email-primary": if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok { back(msg, "") return } back("", "primary address changed") case "token-create": name := strings.TrimSpace(r.FormValue("name")) if name == "" { back("name the token", "") return } scope := r.FormValue("scope") if scope != "full" { scope = "read" } argv := []string{"token", "create", "--name", name, "--scope", scope} if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" { argv = append(argv, "--ttl", ttl) } var minted struct { Token string `json:"token"` } if msg, ok := s.runControlInto(u, argv, &minted); !ok { back(msg, "") return } // The token is shown in this response and nowhere else: not in a // redirect, a URL or a cookie, and never stored to be shown later. w.Header().Set("Cache-Control", "no-store") s.renderAccount(w, r, u, minted.Token) case "token-revoke": name := r.FormValue("name") if ok, msg := confirmed(r, name); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok { back(msg, "") return } back("", "token revoked") case "theme": if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok { back(msg, "") return } back("", "colour scheme saved") case "diff-layout": if _, msg, ok := s.runControl(u, []string{"web", "diff", "set", r.FormValue("layout")}); !ok { back(msg, "") return } back("", "diff layout saved") case "notify-mail", "notify-watch", "notify-push", "notify-reply": pref := strings.TrimPrefix(r.FormValue("field"), "notify-") state := "off" if r.FormValue(pref) == "on" { state = "on" } if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok { back(msg, "") return } back("", "notification preferences saved") case "device-remove": id := r.FormValue("id") if ok, msg := confirmed(r, id); !ok { back(msg, "") return } if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok { back(msg, "") return } back("", "device removed") case "profile": argv := []string{"profile", "set", "--description", r.FormValue("description"), "--website", r.FormValue("website"), } for _, link := range profileLinkArgs(r.FormValue("links")) { argv = append(argv, "--link", link) } if _, msg, ok := s.runControl(u, argv); !ok { back(msg, "") return } back("", "profile updated") case "profile-repo": // The about text is a file. Create the repository that holds it and // commit a starter README, so the file editor has a branch to open. path := u.Username + "/" + control.ProfileRepoName if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok { back(msg, "") return } starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n" if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path, control.AboutBase + ".md", "--ref", "main", "--message", "add profile about", "--file", "-"}, starter); !ok { back(msg, "") return } back("", "profile repository created") default: back("unknown form", "") } }