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