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