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