internal/httpd/account.go

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

326 lines · 10183 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// accountForm renders the account's own settings: keys, addresses, and the
 49// commands for everything that stays on SSH.
 50func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
 51	s.accountPage(w, r, u)
 52}
 53
 54// accountPage renders the settings page.
 55func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User) {
 56	var keys []accountKey
 57	if list, err := s.st.ListSSHKeys(u.ID); err == nil {
 58		for _, k := range list {
 59			confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:"))
 60			keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
 61		}
 62	}
 63	var pgp []accountPGP
 64	if list, err := s.st.ListPGPKeys(u.ID); err == nil {
 65		for _, k := range list {
 66			var uids []string
 67			json.Unmarshal([]byte(k.UIDsJSON), &uids)
 68			confirm := prefix8(k.Fingerprint)
 69			pgp = append(pgp, accountPGP{
 70				Fingerprint: k.Fingerprint, UIDs: uids,
 71				Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
 72			})
 73		}
 74	}
 75	emails, _ := s.st.ListEmails(u.ID)
 76
 77	var profile control.ProfileOut
 78	s.runControlInto(u, []string{"profile", "show"}, &profile)
 79	mailOn, _ := s.st.MailEnabled(u.ID)
 80	watchOn, _ := s.st.WatchEnabled(u.ID)
 81	pushOn, _ := s.st.PushEnabled(u.ID)
 82	theme, _ := s.st.Theme(u.ID)
 83
 84	var devices []accountDevice
 85	if list, err := s.st.PushDevices(u.ID); err == nil {
 86		for _, d := range list {
 87			devices = append(devices, accountDevice{ID: d.ID, Label: d.Label,
 88				Token: control.ShortToken(d.Token), LastSeenAt: d.LastSeenAt,
 89				Confirm: strconv.FormatInt(d.ID, 10)})
 90		}
 91	}
 92
 93	// The about text is a file. The page points at it rather than editing
 94	// it: the repository's own editor already does that job.
 95	aboutRepo := u.Username + "/" + control.ProfileRepoName
 96	aboutEdit := ""
 97	if profile.AboutPath != "" {
 98		aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
 99	}
100
101	s.render(w, "account.html", struct {
102		basePage
103		Tab          string // marks the rail's Settings row as current
104		Keys         []accountKey
105		PGP          []accountPGP
106		Emails       []store.Email
107		Profile      control.ProfileOut
108		LinksText    string
109		AboutRepo    string // <user>/.gitbay, which holds the about text
110		AboutEdit    string // the file editor's URL, empty when there is no file yet
111		Host         string
112		Notice       string
113		Message      string
114		MailOn       bool
115		WatchOn      bool
116		PushOn       bool
117		Devices      []accountDevice
118		ThemeSetting string // system, light or dark: the form's selected option
119	}{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
120		aboutRepo, aboutEdit, s.cfg.SiteHost(),
121		s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme})
122}
123
124// accountExport hands the browser the same bundle `account export`
125// writes. The command is ReadOnly, so a GET is enough; the response is an
126// attachment rather than a page because the bundle is a file to keep.
127func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
128	out, msg, code := s.runControlCode(u, []string{"account", "export"})
129	if code != protocol.ExitOK {
130		s.setFlash(w, msg)
131		http.Redirect(w, r, "/settings", http.StatusSeeOther)
132		return
133	}
134	w.Header().Set("Content-Type", "application/json")
135	w.Header().Set("X-Content-Type-Options", "nosniff")
136	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
137	io.WriteString(w, out)
138}
139
140// profileLinksText turns a profile's links into the form the textarea
141// shows and reads back: one per line, "label|url" when there is a label
142// and the bare url otherwise.
143func profileLinksText(links []store.ProfileLink) string {
144	lines := make([]string, len(links))
145	for i, l := range links {
146		if l.Label != "" {
147			lines[i] = l.Label + "|" + l.URL
148		} else {
149			lines[i] = l.URL
150		}
151	}
152	return strings.Join(lines, "\n")
153}
154
155// profileLinkArgs turns the textarea back into the --link values profile
156// set expects: one per non-blank line, or a single empty one to clear the
157// list when the field was emptied.
158func profileLinkArgs(raw string) []string {
159	var links []string
160	for _, line := range strings.Split(raw, "\n") {
161		if line = strings.TrimSpace(line); line != "" {
162			links = append(links, line)
163		}
164	}
165	if links == nil {
166		return []string{""}
167	}
168	return links
169}
170
171// accountSubmit routes the account forms to their commands. Keys,
172// addresses and the profile are the whole surface — no secret is accepted
173// over the web.
174func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
175	back := func(msg, note string) {
176		q := ""
177		if note != "" {
178			q = "?m=" + url.QueryEscape(note)
179		}
180		s.setFlash(w, msg)
181		http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
182	}
183
184	switch r.FormValue("field") {
185	case "key-add":
186		body := strings.TrimSpace(r.FormValue("key"))
187		if body == "" {
188			back("paste a public key in authorized_keys format", "")
189			return
190		}
191		argv := []string{"keys", "add"}
192		if scope := r.FormValue("scope"); scope == "git" {
193			argv = append(argv, "--scope", "git")
194		}
195		if label := strings.TrimSpace(r.FormValue("label")); label != "" {
196			argv = append(argv, "--label", label)
197		}
198		if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
199			back(msg, "")
200			return
201		}
202		back("", "key registered")
203	case "key-remove":
204		want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
205		if ok, msg := confirmed(r, want); !ok {
206			back(msg, "")
207			return
208		}
209		if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
210			back(msg, "")
211			return
212		}
213		back("", "key removed")
214	case "pgp-add":
215		body := strings.TrimSpace(r.FormValue("key"))
216		if body == "" {
217			back("paste an armored OpenPGP public key", "")
218			return
219		}
220		if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
221			back(msg, "")
222			return
223		}
224		back("", "PGP key registered")
225	case "pgp-remove":
226		fp := r.FormValue("fingerprint")
227		want := prefix8(fp)
228		if ok, msg := confirmed(r, want); !ok {
229			back(msg, "")
230			return
231		}
232		if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
233			back(msg, "")
234			return
235		}
236		back("", "PGP key removed")
237	case "email-add":
238		if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
239			back(msg, "")
240			return
241		}
242		back("", "check that inbox for a verification code")
243	case "email-verify":
244		if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
245			back(msg, "")
246			return
247		}
248		back("", "address verified")
249	case "email-remove":
250		address := r.FormValue("address")
251		if ok, msg := confirmed(r, address); !ok {
252			back(msg, "")
253			return
254		}
255		if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
256			back(msg, "")
257			return
258		}
259		back("", "address removed")
260	case "email-primary":
261		if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
262			back(msg, "")
263			return
264		}
265		back("", "primary address changed")
266	case "theme":
267		if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
268			back(msg, "")
269			return
270		}
271		back("", "colour scheme saved")
272	case "notify-mail", "notify-watch", "notify-push":
273		pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
274		state := "off"
275		if r.FormValue(pref) == "on" {
276			state = "on"
277		}
278		if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
279			back(msg, "")
280			return
281		}
282		back("", "notification preferences saved")
283	case "device-remove":
284		id := r.FormValue("id")
285		if ok, msg := confirmed(r, id); !ok {
286			back(msg, "")
287			return
288		}
289		if _, msg, ok := s.runControl(u, []string{"notifications", "device", "remove", id}); !ok {
290			back(msg, "")
291			return
292		}
293		back("", "device removed")
294	case "profile":
295		argv := []string{"profile", "set",
296			"--description", r.FormValue("description"),
297			"--website", r.FormValue("website"),
298		}
299		for _, link := range profileLinkArgs(r.FormValue("links")) {
300			argv = append(argv, "--link", link)
301		}
302		if _, msg, ok := s.runControl(u, argv); !ok {
303			back(msg, "")
304			return
305		}
306		back("", "profile updated")
307	case "profile-repo":
308		// The about text is a file. Create the repository that holds it and
309		// commit a starter README, so the file editor has a branch to open.
310		path := u.Username + "/" + control.ProfileRepoName
311		if _, msg, ok := s.runControl(u, []string{"repo", "create", path}); !ok {
312			back(msg, "")
313			return
314		}
315		starter := "# " + u.Username + "\n\nThis is the about text on your profile.\n"
316		if msg, ok := s.runControlStdin(u, []string{"repo", "commit-file", path,
317			control.AboutBase + ".md", "--ref", "main",
318			"--message", "add profile about", "--file", "-"}, starter); !ok {
319			back(msg, "")
320			return
321		}
322		back("", "profile repository created")
323	default:
324		back("unknown form", "")
325	}
326}