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