internal/httpd/account.go

v1.38.0
gitbay/internal/httpd/account.go history · blame · raw

399 lines · 12528 bytes

  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	theme, _ := s.st.Theme(u.ID)
 99
100	var devices []accountDevice
101	if list, err := s.st.PushDevices(u.ID); err == nil {
102		for _, d := range list {
103			devices = append(devices, accountDevice{ID: d.ID, Label: d.Label,
104				Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt,
105				Confirm: strconv.FormatInt(d.ID, 10)})
106		}
107	}
108
109	var tokens []accountToken
110	if list, err := s.st.ListAPITokens(u.ID); err == nil {
111		for _, tk := range list {
112			expires, lastUsed := "never", "never"
113			if tk.ExpiresAt != nil {
114				expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
115			}
116			if tk.LastUsedAt != nil {
117				lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
118			}
119			tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
120		}
121	}
122
123	// The about text is a file. The page points at it rather than editing
124	// it: the repository's own editor already does that job.
125	aboutRepo := u.Username + "/" + control.ProfileRepoName
126	aboutEdit := ""
127	if profile.AboutPath != "" {
128		aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
129	}
130
131	notice := s.takeFlash(w, r)
132	reauth := s.reauthNotice(w, notice, "/settings")
133
134	s.render(w, "account.html", struct {
135		basePage
136		Tab          string // marks the rail's Settings row as current
137		Keys         []accountKey
138		PGP          []accountPGP
139		Emails       []store.Email
140		Profile      control.ProfileOut
141		LinksText    string
142		AboutRepo    string // <user>/.gitbay, which holds the about text
143		AboutEdit    string // the file editor's URL, empty when there is no file yet
144		Host         string
145		Notice       string
146		Message      string
147		MailOn       bool
148		WatchOn      bool
149		PushOn       bool
150		Devices      []accountDevice
151		ThemeSetting string // system, light or dark: the form's selected option
152		Tokens       []accountToken
153		TokenShown   string // a token minted by this request, shown once
154		Reauth       bool   // Notice is the stale-session refusal: link to sign in
155	}{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
156		aboutRepo, aboutEdit, s.cfg.SiteHost(),
157		notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme,
158		tokens, tokenShown, reauth})
159}
160
161// accountExport hands the browser the same bundle `account export`
162// writes. The command is ReadOnly, so a GET is enough; the response is an
163// attachment rather than a page because the bundle is a file to keep.
164func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
165	out, msg, code := s.runControlCode(u, []string{"account", "export"})
166	if code != protocol.ExitOK {
167		s.setFlash(w, msg)
168		http.Redirect(w, r, "/settings", http.StatusSeeOther)
169		return
170	}
171	w.Header().Set("Content-Type", "application/json")
172	w.Header().Set("X-Content-Type-Options", "nosniff")
173	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
174	io.WriteString(w, out)
175}
176
177// profileLinksText turns a profile's links into the form the textarea
178// shows and reads back: one per line, "label|url" when there is a label
179// and the bare url otherwise.
180func profileLinksText(links []store.ProfileLink) string {
181	lines := make([]string, len(links))
182	for i, l := range links {
183		if l.Label != "" {
184			lines[i] = l.Label + "|" + l.URL
185		} else {
186			lines[i] = l.URL
187		}
188	}
189	return strings.Join(lines, "\n")
190}
191
192// profileLinkArgs turns the textarea back into the --link values profile
193// set expects: one per non-blank line, or a single empty one to clear the
194// list when the field was emptied.
195func profileLinkArgs(raw string) []string {
196	var links []string
197	for _, line := range strings.Split(raw, "\n") {
198		if line = strings.TrimSpace(line); line != "" {
199			links = append(links, line)
200		}
201	}
202	if links == nil {
203		return []string{""}
204	}
205	return links
206}
207
208// accountSubmit routes the account forms to their commands. Keys,
209// addresses and the profile are the whole surface — no secret is accepted
210// over the web.
211func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
212	back := func(msg, note string) {
213		q := ""
214		if note != "" {
215			q = "?m=" + url.QueryEscape(note)
216		}
217		s.setFlash(w, msg)
218		http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
219	}
220
221	switch r.FormValue("field") {
222	case "key-add":
223		body := strings.TrimSpace(r.FormValue("key"))
224		if body == "" {
225			back("paste a public key in authorized_keys format", "")
226			return
227		}
228		argv := []string{"keys", "add"}
229		if scope := r.FormValue("scope"); scope == "git" {
230			argv = append(argv, "--scope", "git")
231		}
232		if label := strings.TrimSpace(r.FormValue("label")); label != "" {
233			argv = append(argv, "--label", label)
234		}
235		if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
236			back(msg, "")
237			return
238		}
239		back("", "key registered")
240	case "key-remove":
241		want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
242		if ok, msg := confirmed(r, want); !ok {
243			back(msg, "")
244			return
245		}
246		if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
247			back(msg, "")
248			return
249		}
250		back("", "key removed")
251	case "pgp-add":
252		body := strings.TrimSpace(r.FormValue("key"))
253		if body == "" {
254			back("paste an armored OpenPGP public key", "")
255			return
256		}
257		if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
258			back(msg, "")
259			return
260		}
261		back("", "PGP key registered")
262	case "pgp-remove":
263		fp := r.FormValue("fingerprint")
264		want := prefix8(fp)
265		if ok, msg := confirmed(r, want); !ok {
266			back(msg, "")
267			return
268		}
269		if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
270			back(msg, "")
271			return
272		}
273		back("", "PGP key removed")
274	case "email-add":
275		if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
276			back(msg, "")
277			return
278		}
279		back("", "check that inbox for a verification code")
280	case "email-verify":
281		if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
282			back(msg, "")
283			return
284		}
285		back("", "address verified")
286	case "email-remove":
287		address := r.FormValue("address")
288		if ok, msg := confirmed(r, address); !ok {
289			back(msg, "")
290			return
291		}
292		if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
293			back(msg, "")
294			return
295		}
296		back("", "address removed")
297	case "email-primary":
298		if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
299			back(msg, "")
300			return
301		}
302		back("", "primary address changed")
303	case "token-create":
304		name := strings.TrimSpace(r.FormValue("name"))
305		if name == "" {
306			back("name the token", "")
307			return
308		}
309		scope := r.FormValue("scope")
310		if scope != "full" {
311			scope = "read"
312		}
313		argv := []string{"token", "create", "--name", name, "--scope", scope}
314		if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
315			argv = append(argv, "--ttl", ttl)
316		}
317		var minted struct {
318			Token string `json:"token"`
319		}
320		if msg, ok := s.runControlInto(u, argv, &minted); !ok {
321			back(msg, "")
322			return
323		}
324		// The token is shown in this response and nowhere else: not in a
325		// redirect, a URL or a cookie, and never stored to be shown later.
326		w.Header().Set("Cache-Control", "no-store")
327		s.renderAccount(w, r, u, minted.Token)
328	case "token-revoke":
329		name := r.FormValue("name")
330		if ok, msg := confirmed(r, name); !ok {
331			back(msg, "")
332			return
333		}
334		if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok {
335			back(msg, "")
336			return
337		}
338		back("", "token revoked")
339	case "theme":
340		if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
341			back(msg, "")
342			return
343		}
344		back("", "colour scheme saved")
345	case "notify-mail", "notify-watch", "notify-push":
346		pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
347		state := "off"
348		if r.FormValue(pref) == "on" {
349			state = "on"
350		}
351		if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
352			back(msg, "")
353			return
354		}
355		back("", "notification preferences saved")
356	case "device-remove":
357		id := r.FormValue("id")
358		if ok, msg := confirmed(r, id); !ok {
359			back(msg, "")
360			return
361		}
362		if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok {
363			back(msg, "")
364			return
365		}
366		back("", "device removed")
367	case "profile":
368		argv := []string{"profile", "set",
369			"--description", r.FormValue("description"),
370			"--website", r.FormValue("website"),
371		}
372		for _, link := range profileLinkArgs(r.FormValue("links")) {
373			argv = append(argv, "--link", link)
374		}
375		if _, msg, ok := s.runControl(u, argv); !ok {
376			back(msg, "")
377			return
378		}
379		back("", "profile updated")
380	case "profile-repo":
381		// The about text is a file. Create the repository that holds it and
382		// commit a starter README, so the file editor has a branch to open.
383		path := u.Username + "/" + control.ProfileRepoName
384		if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
385			back(msg, "")
386			return
387		}
388		starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n"
389		if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path,
390			control.AboutBase + ".md", "--ref", "main",
391			"--message", "add profile about", "--file", "-"}, starter); !ok {
392			back(msg, "")
393			return
394		}
395		back("", "profile repository created")
396	default:
397		back("unknown form", "")
398	}
399}