internal/httpd/account.go

a32f7f2001c1c0bf38ba8c3360e6bc7ca3982fee
gitbay/internal/httpd/account.go history · blame · raw

271 lines · 8227 bytes

  1package httpd
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"net/url"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/control"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14)
 15
 16// accountKey is one SSH key as the settings page shows it: enough to
 17// recognise which key this is without printing the whole blob.
 18type accountKey struct {
 19	Fingerprint string
 20	Algo        string
 21	Scope       string
 22	Label       string
 23	Confirm     string // the 8 characters after SHA256: — a label can be empty
 24}
 25
 26type accountPGP struct {
 27	Fingerprint string
 28	UIDs        []string
 29	Expired     bool
 30	Revoked     bool
 31	Confirm     string // the fingerprint's first 8 characters
 32}
 33
 34// accountForm renders the account's own settings: keys, addresses, and the
 35// commands for everything that stays on SSH.
 36func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
 37	s.accountPage(w, r, u, nil)
 38}
 39
 40// accountPage renders the settings page. d is non-nil when the profile
 41// form asked to see its about text rather than save it (#235).
 42func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User, d *draft) {
 43	var keys []accountKey
 44	if list, err := s.st.ListSSHKeys(u.ID); err == nil {
 45		for _, k := range list {
 46			confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:"))
 47			keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
 48		}
 49	}
 50	var pgp []accountPGP
 51	if list, err := s.st.ListPGPKeys(u.ID); err == nil {
 52		for _, k := range list {
 53			var uids []string
 54			json.Unmarshal([]byte(k.UIDsJSON), &uids)
 55			confirm := prefix8(k.Fingerprint)
 56			pgp = append(pgp, accountPGP{
 57				Fingerprint: k.Fingerprint, UIDs: uids,
 58				Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
 59			})
 60		}
 61	}
 62	emails, _ := s.st.ListEmails(u.ID)
 63
 64	var profile control.ProfileOut
 65	s.runControlInto(u, []string{"profile", "show"}, &profile)
 66	mailOn, _ := s.st.MailEnabled(u.ID)
 67	watchOn, _ := s.st.WatchEnabled(u.ID)
 68	theme, _ := s.st.Theme(u.ID)
 69
 70	s.render(w, "account.html", struct {
 71		basePage
 72		Tab          string // marks the rail's Settings row as current
 73		Keys         []accountKey
 74		PGP          []accountPGP
 75		Emails       []store.Email
 76		Profile      control.ProfileOut
 77		LinksText    string
 78		Host         string
 79		Notice       string
 80		Message      string
 81		MailOn       bool
 82		WatchOn      bool
 83		ThemeSetting string // system, light or dark: the form's selected option
 84		Draft        *draft
 85	}{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
 86		s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme, d})
 87}
 88
 89// accountExport hands the browser the same bundle `account export`
 90// writes. The command is ReadOnly, so a GET is enough; the response is an
 91// attachment rather than a page because the bundle is a file to keep.
 92func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
 93	out, msg, code := s.runControlCode(u, []string{"account", "export"})
 94	if code != protocol.ExitOK {
 95		s.setFlash(w, msg)
 96		http.Redirect(w, r, "/settings", http.StatusSeeOther)
 97		return
 98	}
 99	w.Header().Set("Content-Type", "application/json")
100	w.Header().Set("X-Content-Type-Options", "nosniff")
101	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
102	io.WriteString(w, out)
103}
104
105// profileLinksText turns a profile's links into the form the textarea
106// shows and reads back: one per line, "label|url" when there is a label
107// and the bare url otherwise.
108func profileLinksText(links []store.ProfileLink) string {
109	lines := make([]string, len(links))
110	for i, l := range links {
111		if l.Label != "" {
112			lines[i] = l.Label + "|" + l.URL
113		} else {
114			lines[i] = l.URL
115		}
116	}
117	return strings.Join(lines, "\n")
118}
119
120// profileLinkArgs turns the textarea back into the --link values profile
121// set expects: one per non-blank line, or a single empty one to clear the
122// list when the field was emptied.
123func profileLinkArgs(raw string) []string {
124	var links []string
125	for _, line := range strings.Split(raw, "\n") {
126		if line = strings.TrimSpace(line); line != "" {
127			links = append(links, line)
128		}
129	}
130	if links == nil {
131		return []string{""}
132	}
133	return links
134}
135
136// accountSubmit routes the account forms to their commands. Keys,
137// addresses and the profile are the whole surface — no secret is accepted
138// over the web.
139func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
140	back := func(msg, note string) {
141		q := ""
142		if note != "" {
143			q = "?m=" + url.QueryEscape(note)
144		}
145		s.setFlash(w, msg)
146		http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
147	}
148
149	switch r.FormValue("field") {
150	case "key-add":
151		body := strings.TrimSpace(r.FormValue("key"))
152		if body == "" {
153			back("paste a public key in authorized_keys format", "")
154			return
155		}
156		argv := []string{"keys", "add"}
157		if scope := r.FormValue("scope"); scope == "git" {
158			argv = append(argv, "--scope", "git")
159		}
160		if label := strings.TrimSpace(r.FormValue("label")); label != "" {
161			argv = append(argv, "--label", label)
162		}
163		if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
164			back(msg, "")
165			return
166		}
167		back("", "key registered")
168	case "key-remove":
169		want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
170		if ok, msg := confirmed(r, want); !ok {
171			back(msg, "")
172			return
173		}
174		if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
175			back(msg, "")
176			return
177		}
178		back("", "key removed")
179	case "pgp-add":
180		body := strings.TrimSpace(r.FormValue("key"))
181		if body == "" {
182			back("paste an armored OpenPGP public key", "")
183			return
184		}
185		if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
186			back(msg, "")
187			return
188		}
189		back("", "PGP key registered")
190	case "pgp-remove":
191		fp := r.FormValue("fingerprint")
192		want := prefix8(fp)
193		if ok, msg := confirmed(r, want); !ok {
194			back(msg, "")
195			return
196		}
197		if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
198			back(msg, "")
199			return
200		}
201		back("", "PGP key removed")
202	case "email-add":
203		if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
204			back(msg, "")
205			return
206		}
207		back("", "check that inbox for a verification code")
208	case "email-verify":
209		if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
210			back(msg, "")
211			return
212		}
213		back("", "address verified")
214	case "email-remove":
215		address := r.FormValue("address")
216		if ok, msg := confirmed(r, address); !ok {
217			back(msg, "")
218			return
219		}
220		if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
221			back(msg, "")
222			return
223		}
224		back("", "address removed")
225	case "email-primary":
226		if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
227			back(msg, "")
228			return
229		}
230		back("", "primary address changed")
231	case "theme":
232		if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
233			back(msg, "")
234			return
235		}
236		back("", "colour scheme saved")
237	case "notify-mail", "notify-watch":
238		pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
239		state := "off"
240		if r.FormValue(pref) == "on" {
241			state = "on"
242		}
243		if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
244			back(msg, "")
245			return
246		}
247		back("", "notification preferences saved")
248	case "profile":
249		format := bodyFormat(r)
250		if wantsPreview(r) {
251			s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML))
252			return
253		}
254		argv := []string{"profile", "set",
255			"--description", r.FormValue("description"),
256			"--website", r.FormValue("website"),
257			"--about-format", format,
258			"--file", "-",
259		}
260		for _, link := range profileLinkArgs(r.FormValue("links")) {
261			argv = append(argv, "--link", link)
262		}
263		if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
264			back(msg, "")
265			return
266		}
267		back("", "profile updated")
268	default:
269		back("unknown form", "")
270	}
271}