internal/httpd/account.go

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

289 lines · 9014 bytes

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