internal/httpd/account.go

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

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