internal/httpd/account.go
271 lines · 8227 bytes
1package httpd
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/protocol"
13 "gitbay.org/gitbay/internal/store"
14)
15
16// accountKey is one SSH key as the settings page shows it: enough to
17// recognise which key this is without printing the whole blob.
18type accountKey struct {
19 Fingerprint string
20 Algo string
21 Scope string
22 Label string
23 Confirm string // the 8 characters after SHA256: — a label can be empty
24}
25
26type accountPGP struct {
27 Fingerprint string
28 UIDs []string
29 Expired bool
30 Revoked bool
31 Confirm string // the fingerprint's first 8 characters
32}
33
34// accountForm renders the account's own settings: keys, addresses, and the
35// commands for everything that stays on SSH.
36func (s *Server) accountForm(w http.ResponseWriter, r *http.Request, u store.User) {
37 s.accountPage(w, r, u, nil)
38}
39
40// accountPage renders the settings page. d is non-nil when the profile
41// form asked to see its about text rather than save it (#235).
42func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, u store.User, d *draft) {
43 var keys []accountKey
44 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
45 for _, k := range list {
46 confirm := prefix8(strings.TrimPrefix(k.Fingerprint, "SHA256:"))
47 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
48 }
49 }
50 var pgp []accountPGP
51 if list, err := s.st.ListPGPKeys(u.ID); err == nil {
52 for _, k := range list {
53 var uids []string
54 json.Unmarshal([]byte(k.UIDsJSON), &uids)
55 confirm := prefix8(k.Fingerprint)
56 pgp = append(pgp, accountPGP{
57 Fingerprint: k.Fingerprint, UIDs: uids,
58 Expired: k.ExpiresAt != nil, Revoked: k.RevokedAt != nil, Confirm: confirm,
59 })
60 }
61 }
62 emails, _ := s.st.ListEmails(u.ID)
63
64 var profile control.ProfileOut
65 s.runControlInto(u, []string{"profile", "show"}, &profile)
66 mailOn, _ := s.st.MailEnabled(u.ID)
67 watchOn, _ := s.st.WatchEnabled(u.ID)
68 theme, _ := s.st.Theme(u.ID)
69
70 s.render(w, "account.html", struct {
71 basePage
72 Tab string // marks the rail's Settings row as current
73 Keys []accountKey
74 PGP []accountPGP
75 Emails []store.Email
76 Profile control.ProfileOut
77 LinksText string
78 Host string
79 Notice string
80 Message string
81 MailOn bool
82 WatchOn bool
83 ThemeSetting string // system, light or dark: the form's selected option
84 Draft *draft
85 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
86 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, theme, d})
87}
88
89// accountExport hands the browser the same bundle `account export`
90// writes. The command is ReadOnly, so a GET is enough; the response is an
91// attachment rather than a page because the bundle is a file to keep.
92func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
93 out, msg, code := s.runControlCode(u, []string{"account", "export"})
94 if code != protocol.ExitOK {
95 s.setFlash(w, msg)
96 http.Redirect(w, r, "/settings", http.StatusSeeOther)
97 return
98 }
99 w.Header().Set("Content-Type", "application/json")
100 w.Header().Set("X-Content-Type-Options", "nosniff")
101 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
102 io.WriteString(w, out)
103}
104
105// profileLinksText turns a profile's links into the form the textarea
106// shows and reads back: one per line, "label|url" when there is a label
107// and the bare url otherwise.
108func profileLinksText(links []store.ProfileLink) string {
109 lines := make([]string, len(links))
110 for i, l := range links {
111 if l.Label != "" {
112 lines[i] = l.Label + "|" + l.URL
113 } else {
114 lines[i] = l.URL
115 }
116 }
117 return strings.Join(lines, "\n")
118}
119
120// profileLinkArgs turns the textarea back into the --link values profile
121// set expects: one per non-blank line, or a single empty one to clear the
122// list when the field was emptied.
123func profileLinkArgs(raw string) []string {
124 var links []string
125 for _, line := range strings.Split(raw, "\n") {
126 if line = strings.TrimSpace(line); line != "" {
127 links = append(links, line)
128 }
129 }
130 if links == nil {
131 return []string{""}
132 }
133 return links
134}
135
136// accountSubmit routes the account forms to their commands. Keys,
137// addresses and the profile are the whole surface — no secret is accepted
138// over the web.
139func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
140 back := func(msg, note string) {
141 q := ""
142 if note != "" {
143 q = "?m=" + url.QueryEscape(note)
144 }
145 s.setFlash(w, msg)
146 http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
147 }
148
149 switch r.FormValue("field") {
150 case "key-add":
151 body := strings.TrimSpace(r.FormValue("key"))
152 if body == "" {
153 back("paste a public key in authorized_keys format", "")
154 return
155 }
156 argv := []string{"keys", "add"}
157 if scope := r.FormValue("scope"); scope == "git" {
158 argv = append(argv, "--scope", "git")
159 }
160 if label := strings.TrimSpace(r.FormValue("label")); label != "" {
161 argv = append(argv, "--label", label)
162 }
163 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
164 back(msg, "")
165 return
166 }
167 back("", "key registered")
168 case "key-remove":
169 want := prefix8(strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:"))
170 if ok, msg := confirmed(r, want); !ok {
171 back(msg, "")
172 return
173 }
174 if _, msg, ok := s.runControl(u, []string{"keys", "remove", r.FormValue("fingerprint")}); !ok {
175 back(msg, "")
176 return
177 }
178 back("", "key removed")
179 case "pgp-add":
180 body := strings.TrimSpace(r.FormValue("key"))
181 if body == "" {
182 back("paste an armored OpenPGP public key", "")
183 return
184 }
185 if msg, ok := s.runControlStdin(u, []string{"pgp", "add"}, body+"\n"); !ok {
186 back(msg, "")
187 return
188 }
189 back("", "PGP key registered")
190 case "pgp-remove":
191 fp := r.FormValue("fingerprint")
192 want := prefix8(fp)
193 if ok, msg := confirmed(r, want); !ok {
194 back(msg, "")
195 return
196 }
197 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
198 back(msg, "")
199 return
200 }
201 back("", "PGP key removed")
202 case "email-add":
203 if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
204 back(msg, "")
205 return
206 }
207 back("", "check that inbox for a verification code")
208 case "email-verify":
209 if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
210 back(msg, "")
211 return
212 }
213 back("", "address verified")
214 case "email-remove":
215 address := r.FormValue("address")
216 if ok, msg := confirmed(r, address); !ok {
217 back(msg, "")
218 return
219 }
220 if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
221 back(msg, "")
222 return
223 }
224 back("", "address removed")
225 case "email-primary":
226 if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
227 back(msg, "")
228 return
229 }
230 back("", "primary address changed")
231 case "theme":
232 if _, msg, ok := s.runControl(u, []string{"web", "theme", "set", r.FormValue("theme")}); !ok {
233 back(msg, "")
234 return
235 }
236 back("", "colour scheme saved")
237 case "notify-mail", "notify-watch":
238 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
239 state := "off"
240 if r.FormValue(pref) == "on" {
241 state = "on"
242 }
243 if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
244 back(msg, "")
245 return
246 }
247 back("", "notification preferences saved")
248 case "profile":
249 format := bodyFormat(r)
250 if wantsPreview(r) {
251 s.accountPage(w, r, u, s.draftWith(r, "about", format, r.FormValue("about"), ugcHTML))
252 return
253 }
254 argv := []string{"profile", "set",
255 "--description", r.FormValue("description"),
256 "--website", r.FormValue("website"),
257 "--about-format", format,
258 "--file", "-",
259 }
260 for _, link := range profileLinkArgs(r.FormValue("links")) {
261 argv = append(argv, "--link", link)
262 }
263 if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
264 back(msg, "")
265 return
266 }
267 back("", "profile updated")
268 default:
269 back("unknown form", "")
270 }
271}