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