internal/httpd/account.go

421 lines · 13336 bytes

11 symbols in this file
  1package httpd
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"io"
  7	"net/http"
  8	"net/url"
  9	"strconv"
 10	"strings"
 11
 12	"gitbay.org/gitbay/internal/control"
 13	"gitbay.org/gitbay/internal/protocol"
 14	"gitbay.org/gitbay/internal/store"
 15)
 16
 17// accountKey is one SSH key as the settings page shows it: enough to
 18// recognise which key this is without printing the whole blob.
 19type accountKey struct {
 20	Fingerprint string
 21	Algo        string
 22	Scope       string
 23	Label       string
 24	Confirm     string // the 8 characters after SHA256: — a label can be empty
 25}
 26
 27type accountPGP struct {
 28	Fingerprint string
 29	UIDs        []string
 30	Expired     bool
 31	Revoked     bool
 32	Confirm     string // the fingerprint's first 8 characters
 33}
 34
 35// accountDevice is one registered APNs device as the settings page shows
 36// it. No form of the token reaches the page but the masked column:
 37// removal confirms on the id, which is not device-identifying.
 38type accountDevice struct {
 39	ID    int64
 40	Label string
 41	// Token is rendered by control.ShortToken, the same renderer
 42	// notifications device list uses.
 43	Token      string
 44	LastSeenAt string
 45	Confirm    string // the id as text, typed back to confirm removal
 46}
 47
 48// accountToken is one API token as the settings page shows it: never
 49// the token itself, only what identifies and describes it.
 50type accountToken struct {
 51	Name     string
 52	Scope    string
 53	Created  string
 54	Expires  string // "never" or a formatted timestamp
 55	LastUsed string // "never" or a formatted timestamp
 56}
 57
 58// accountForm renders the account's own settings: keys, addresses, and the
 59// commands for everything that stays on SSH.
 60func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
 61	s.accountPage(w, r, u)
 62}
 63
 64// accountPage renders the settings page.
 65func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
 66	s.renderAccount(w, r, u, "")
 67}
 68
 69// renderAccount draws the settings page. tokenShown is a token minted
 70// by the request being answered; it is shown in this response only.
 71func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.User, tokenShown string) {
 72	var keys []accountKey
 73	if list, err := s.st.ListSSHKeys(u.ID); err == nil {
 74		for _, k := range list {
 75			confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:"))
 76			keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
 77		}
 78	}
 79	var pgp []accountPGP
 80	if list, err := s.st.ListPGPKeys(u.ID); err == nil {
 81		for _, k := range list {
 82			var uids []string
 83			json.Unmarshal([]byte(k.UIDsJSON), &uids)
 84			confirm := prefix8(k.Fingerprint)
 85			pgp = append(pgp, accountPGP{
 86				Fingerprint: k.Fingerprint, UIDs: uids,
 87				Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
 88			})
 89		}
 90	}
 91	emails, _ := s.st.ListEmails(u.ID)
 92
 93	var profile control.ProfileOut
 94	s.runControlInto(u, []string{"profile", "show"}, &profile)
 95	mailOn, _ := s.st.MailEnabled(u.ID)
 96	watchOn, _ := s.st.WatchEnabled(u.ID)
 97	pushOn, _ := s.st.PushEnabled(u.ID)
 98	replyOn, _ := s.st.ReplyEnabled(u.ID)
 99	theme, _ := s.st.Theme(u.ID)
100	diffPref, _ := s.st.DiffLayout(u.ID)
101
102	var devices []accountDevice
103	if list, err := s.st.PushDevices(u.ID); err == nil {
104		for _, d := range list {
105			devices = append(devices, accountDevice{ID: d.ID, Label: d.Label,
106				Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt,
107				Confirm: strconv.FormatInt(d.ID, 10)})
108		}
109	}
110
111	var tokens []accountToken
112	if list, err := s.st.ListAPITokens(u.ID); err == nil {
113		for _, tk := range list {
114			expires, lastUsed := "never", "never"
115			if tk.ExpiresAt != nil {
116				expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
117			}
118			if tk.LastUsedAt != nil {
119				lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
120			}
121			tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
122		}
123	}
124
125	// The about text is a file. The page points at it rather than editing
126	// it: the repository's own editor already does that job.
127	aboutRepo := u.Username + "/" + control.ProfileRepoName
128	aboutEdit := ""
129	if profile.AboutPath != "" {
130		aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
131	}
132
133	notice := s.takeFlash(w, r)
134	reauth := s.reauthNotice(w, notice, "/settings")
135
136	s.render(w, "account.html", struct {
137		basePage
138		Tab          string // marks the rail's Settings row as current
139		Keys         []accountKey
140		PGP          []accountPGP
141		Emails       []store.Email
142		Profile      control.ProfileOut
143		LinksText    string
144		AboutRepo    string // <user>/.gitbay, which holds the about text
145		AboutEdit    string // the file editor's URL, empty when there is no file yet
146		Host         string
147		Notice       string
148		Message      string
149		MailOn       bool
150		WatchOn      bool
151		PushOn       bool
152		ReplyOn      bool
153		ReplyOffered bool // the instance reads replies to its mail
154		Devices      []accountDevice
155		ThemeSetting string // system, light or dark: the form's selected option
156		DiffSetting  string // unified or split: the form's selected option
157		Tokens       []accountToken
158		TokenShown   string // a token minted by this request, shown once
159		Reauth       bool   // Notice is the stale-session refusal: link to sign in
160	}{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
161		aboutRepo, aboutEdit, s.cfg.SiteHost(),
162		notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn,
163		replyOn, s.cfg.Mail.Inbound.Enabled, devices, theme, diffPref,
164		tokens, tokenShown, reauth})
165}
166
167// accountExport hands the browser the same bundle `account export`
168// writes. The command is ReadOnly, so a GET is enough; the response is an
169// attachment rather than a page because the bundle is a file to keep.
170func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
171	out, msg, code := s.runControlCode(u, []string{"account", "export"})
172	if code != protocol.ExitOK {
173		s.setFlash(w, msg)
174		http.Redirect(w, r, "/settings", http.StatusSeeOther)
175		return
176	}
177	w.Header().Set("Content-Type", "application/json")
178	w.Header().Set("X-Content-Type-Options", "nosniff")
179	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
180	io.WriteString(w, out)
181}
182
183// profileLinksText turns a profile's links into the form the textarea
184// shows and reads back: one per line, "label|url" when there is a label
185// and the bare url otherwise.
186func profileLinksText(links []store.ProfileLink) string {
187	lines := make([]string, len(links))
188	for i, l := range links {
189		if l.Label != "" {
190			lines[i] = l.Label + "|" + l.URL
191		} else {
192			lines[i] = l.URL
193		}
194	}
195	return strings.Join(lines, "\n")
196}
197
198// profileLinkArgs turns the textarea back into the --link values profile
199// set expects: one per non-blank line, or a single empty one to clear the
200// list when the field was emptied.
201func profileLinkArgs(raw string) []string {
202	var links []string
203	for _, line := range strings.Split(raw, "\n") {
204		if line = strings.TrimSpace(line); line != "" {
205			links = append(links, line)
206		}
207	}
208	if links == nil {
209		return []string{""}
210	}
211	return links
212}
213
214// accountSubmit routes the account forms to their commands. Keys,
215// addresses and the profile are the whole surface — no secret is accepted
216// over the web.
217func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
218	back := func(msg, note string) {
219		q := ""
220		if note != "" {
221			q = "?m=" + url.QueryEscape(note)
222		}
223		s.setFlash(w, msg)
224		http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
225	}
226
227	switch r.FormValue("field") {
228	case "key-add":
229		body := strings.TrimSpace(r.FormValue("key"))
230		if body == "" {
231			back("paste a public key in authorized_keys format", "")
232			return
233		}
234		argv := []string{"keys", "add"}
235		if scope := r.FormValue("scope"); scope == "git" {
236			argv = append(argv, "--scope", "git")
237		}
238		if label := strings.TrimSpace(r.FormValue("label")); label != "" {
239			argv = append(argv, "--label", label)
240		}
241		if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
242			back(msg, "")
243			return
244		}
245		back("", "key registered")
246	case "account-delete":
247		if ok, msg := confirmed(r, u.Username); !ok {
248			back(msg, "")
249			return
250		}
251		if _, msg, ok := s.runControl(u, []string{"account", "delete", "--confirm", u.Username}); !ok {
252			back(msg, "")
253			return
254		}
255		back("", "a deletion link was mailed to your primary address; nothing changes until it is opened")
256	case "key-remove":
257		want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
258		if ok, msg := confirmed(r, want); !ok {
259			back(msg, "")
260			return
261		}
262		if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
263			back(msg, "")
264			return
265		}
266		back("", "key removed")
267	case "pgp-add":
268		body := strings.TrimSpace(r.FormValue("key"))
269		if body == "" {
270			back("paste an armored OpenPGP public key", "")
271			return
272		}
273		if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
274			back(msg, "")
275			return
276		}
277		back("", "PGP key registered")
278	case "pgp-remove":
279		fp := r.FormValue("fingerprint")
280		want := prefix8(fp)
281		if ok, msg := confirmed(r, want); !ok {
282			back(msg, "")
283			return
284		}
285		if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
286			back(msg, "")
287			return
288		}
289		back("", "PGP key removed")
290	case "email-add":
291		if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
292			back(msg, "")
293			return
294		}
295		back("", "check that inbox for a verification code")
296	case "email-verify":
297		if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
298			back(msg, "")
299			return
300		}
301		back("", "address verified")
302	case "email-remove":
303		address := r.FormValue("address")
304		if ok, msg := confirmed(r, address); !ok {
305			back(msg, "")
306			return
307		}
308		if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
309			back(msg, "")
310			return
311		}
312		back("", "address removed")
313	case "email-primary":
314		if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
315			back(msg, "")
316			return
317		}
318		back("", "primary address changed")
319	case "token-create":
320		name := strings.TrimSpace(r.FormValue("name"))
321		if name == "" {
322			back("name the token", "")
323			return
324		}
325		scope := r.FormValue("scope")
326		if scope != "full" {
327			scope = "read"
328		}
329		argv := []string{"token", "create", "--name", name, "--scope", scope}
330		if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
331			argv = append(argv, "--ttl", ttl)
332		}
333		var minted struct {
334			Token string `json:"token"`
335		}
336		if msg, ok := s.runControlInto(u, argv, &minted); !ok {
337			back(msg, "")
338			return
339		}
340		// The token is shown in this response and nowhere else: not in a
341		// redirect, a URL or a cookie, and never stored to be shown later.
342		w.Header().Set("Cache-Control", "no-store")
343		s.renderAccount(w, r, u, minted.Token)
344	case "token-revoke":
345		name := r.FormValue("name")
346		if ok, msg := confirmed(r, name); !ok {
347			back(msg, "")
348			return
349		}
350		if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok {
351			back(msg, "")
352			return
353		}
354		back("", "token revoked")
355	case "theme":
356		if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
357			back(msg, "")
358			return
359		}
360		back("", "colour scheme saved")
361	case "diff-layout":
362		if _, msg, ok := s.runControl(u, []string{"web", "diff", "set", r.FormValue("layout")}); !ok {
363			back(msg, "")
364			return
365		}
366		back("", "diff layout saved")
367	case "notify-mail", "notify-watch", "notify-push", "notify-reply":
368		pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
369		state := "off"
370		if r.FormValue(pref) == "on" {
371			state = "on"
372		}
373		if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
374			back(msg, "")
375			return
376		}
377		back("", "notification preferences saved")
378	case "device-remove":
379		id := r.FormValue("id")
380		if ok, msg := confirmed(r, id); !ok {
381			back(msg, "")
382			return
383		}
384		if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok {
385			back(msg, "")
386			return
387		}
388		back("", "device removed")
389	case "profile":
390		argv := []string{"profile", "set",
391			"--description", r.FormValue("description"),
392			"--website", r.FormValue("website"),
393		}
394		for _, link := range profileLinkArgs(r.FormValue("links")) {
395			argv = append(argv, "--link", link)
396		}
397		if _, msg, ok := s.runControl(u, argv); !ok {
398			back(msg, "")
399			return
400		}
401		back("", "profile updated")
402	case "profile-repo":
403		// The about text is a file. Create the repository that holds it and
404		// commit a starter README, so the file editor has a branch to open.
405		path := u.Username + "/" + control.ProfileRepoName
406		if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
407			back(msg, "")
408			return
409		}
410		starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n"
411		if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path,
412			control.AboutBase + ".md", "--ref", "main",
413			"--message", "add profile about", "--file", "-"}, starter); !ok {
414			back(msg, "")
415			return
416		}
417		back("", "profile repository created")
418	default:
419		back("unknown form", "")
420	}
421}