internal/httpd/account.go

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

411 lines · 13003 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	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 "key-remove":
247		want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
248		if ok, msg := confirmed(r, want); !ok {
249			back(msg, "")
250			return
251		}
252		if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
253			back(msg, "")
254			return
255		}
256		back("", "key removed")
257	case "pgp-add":
258		body := strings.TrimSpace(r.FormValue("key"))
259		if body == "" {
260			back("paste an armored OpenPGP public key", "")
261			return
262		}
263		if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
264			back(msg, "")
265			return
266		}
267		back("", "PGP key registered")
268	case "pgp-remove":
269		fp := r.FormValue("fingerprint")
270		want := prefix8(fp)
271		if ok, msg := confirmed(r, want); !ok {
272			back(msg, "")
273			return
274		}
275		if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
276			back(msg, "")
277			return
278		}
279		back("", "PGP key removed")
280	case "email-add":
281		if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
282			back(msg, "")
283			return
284		}
285		back("", "check that inbox for a verification code")
286	case "email-verify":
287		if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
288			back(msg, "")
289			return
290		}
291		back("", "address verified")
292	case "email-remove":
293		address := r.FormValue("address")
294		if ok, msg := confirmed(r, address); !ok {
295			back(msg, "")
296			return
297		}
298		if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
299			back(msg, "")
300			return
301		}
302		back("", "address removed")
303	case "email-primary":
304		if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
305			back(msg, "")
306			return
307		}
308		back("", "primary address changed")
309	case "token-create":
310		name := strings.TrimSpace(r.FormValue("name"))
311		if name == "" {
312			back("name the token", "")
313			return
314		}
315		scope := r.FormValue("scope")
316		if scope != "full" {
317			scope = "read"
318		}
319		argv := []string{"token", "create", "--name", name, "--scope", scope}
320		if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
321			argv = append(argv, "--ttl", ttl)
322		}
323		var minted struct {
324			Token string `json:"token"`
325		}
326		if msg, ok := s.runControlInto(u, argv, &minted); !ok {
327			back(msg, "")
328			return
329		}
330		// The token is shown in this response and nowhere else: not in a
331		// redirect, a URL or a cookie, and never stored to be shown later.
332		w.Header().Set("Cache-Control", "no-store")
333		s.renderAccount(w, r, u, minted.Token)
334	case "token-revoke":
335		name := r.FormValue("name")
336		if ok, msg := confirmed(r, name); !ok {
337			back(msg, "")
338			return
339		}
340		if _, msg, ok := s.runControl(u, []string{"token", "revoke", "--", name}); !ok {
341			back(msg, "")
342			return
343		}
344		back("", "token revoked")
345	case "theme":
346		if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
347			back(msg, "")
348			return
349		}
350		back("", "colour scheme saved")
351	case "diff-layout":
352		if _, msg, ok := s.runControl(u, []string{"web", "diff", "set", r.FormValue("layout")}); !ok {
353			back(msg, "")
354			return
355		}
356		back("", "diff layout saved")
357	case "notify-mail", "notify-watch", "notify-push", "notify-reply":
358		pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
359		state := "off"
360		if r.FormValue(pref) == "on" {
361			state = "on"
362		}
363		if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
364			back(msg, "")
365			return
366		}
367		back("", "notification preferences saved")
368	case "device-remove":
369		id := r.FormValue("id")
370		if ok, msg := confirmed(r, id); !ok {
371			back(msg, "")
372			return
373		}
374		if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok {
375			back(msg, "")
376			return
377		}
378		back("", "device removed")
379	case "profile":
380		argv := []string{"profile", "set",
381			"--description", r.FormValue("description"),
382			"--website", r.FormValue("website"),
383		}
384		for _, link := range profileLinkArgs(r.FormValue("links")) {
385			argv = append(argv, "--link", link)
386		}
387		if _, msg, ok := s.runControl(u, argv); !ok {
388			back(msg, "")
389			return
390		}
391		back("", "profile updated")
392	case "profile-repo":
393		// The about text is a file. Create the repository that holds it and
394		// commit a starter README, so the file editor has a branch to open.
395		path := u.Username + "/" + control.ProfileRepoName
396		if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
397			back(msg, "")
398			return
399		}
400		starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n"
401		if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path,
402			control.AboutBase + ".md", "--ref", "main",
403			"--message", "add profile about", "--file", "-"}, starter); !ok {
404			back(msg, "")
405			return
406		}
407		back("", "profile repository created")
408	default:
409		back("unknown form", "")
410	}
411}