internal/httpd/account.go
267 lines · 7706 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 var keys []accountKey
38 if list, err := s.st.ListSSHKeys(u.ID); err == nil {
39 for _, k := range list {
40 confirm := strings.TrimPrefix(k.Fingerprint, "SHA256:")
41 if len(confirm) > 8 {
42 confirm = confirm[:8]
43 }
44 keys = append(keys, accountKey{Fingerprint: k.Fingerprint, Algo: k.Algo, Scope: k.Scope, Label: k.Label, Confirm: confirm})
45 }
46 }
47 var pgp []accountPGP
48 if list, err := s.st.ListPGPKeys(u.ID); err == nil {
49 for _, k := range list {
50 var uids []string
51 json.Unmarshal([]byte(k.UIDsJSON), &uids)
52 confirm := k.Fingerprint
53 if len(confirm) > 8 {
54 confirm = confirm[:8]
55 }
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
69 s.render(w, "account.html", struct {
70 basePage
71 Tab string // marks the rail's Settings row as current
72 Keys []accountKey
73 PGP []accountPGP
74 Emails []store.Email
75 Profile control.ProfileOut
76 LinksText string
77 Host string
78 Notice string
79 Message string
80 MailOn bool
81 WatchOn bool
82 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), s.cfg.SiteHost(),
83 s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn})
84}
85
86// accountExport hands the browser the same bundle `account export`
87// writes. The command is ReadOnly, so a GET is enough; the response is an
88// attachment rather than a page because the bundle is a file to keep.
89func (s *Server) accountExport(w http.ResponseWriter, r *http.Request, u store.User) {
90 out, msg, code := s.runControlCode(u, []string{"account", "export"})
91 if code != protocol.ExitOK {
92 s.setFlash(w, msg)
93 http.Redirect(w, r, "/settings", http.StatusSeeOther)
94 return
95 }
96 w.Header().Set("Content-Type", "application/json")
97 w.Header().Set("X-Content-Type-Options", "nosniff")
98 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", u.Username+".bundle"))
99 io.WriteString(w, out)
100}
101
102// profileLinksText turns a profile's links into the form the textarea
103// shows and reads back: one per line, "label|url" when there is a label
104// and the bare url otherwise.
105func profileLinksText(links []store.ProfileLink) string {
106 lines := make([]string, len(links))
107 for i, l := range links {
108 if l.Label != "" {
109 lines[i] = l.Label + "|" + l.URL
110 } else {
111 lines[i] = l.URL
112 }
113 }
114 return strings.Join(lines, "\n")
115}
116
117// profileLinkArgs turns the textarea back into the --link values profile
118// set expects: one per non-blank line, or a single empty one to clear the
119// list when the field was emptied.
120func profileLinkArgs(raw string) []string {
121 var links []string
122 for _, line := range strings.Split(raw, "\n") {
123 if line = strings.TrimSpace(line); line != "" {
124 links = append(links, line)
125 }
126 }
127 if links == nil {
128 return []string{""}
129 }
130 return links
131}
132
133// accountSubmit routes the account forms to their commands. Keys,
134// addresses and the profile are the whole surface — no secret is accepted
135// over the web.
136func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
137 back := func(msg, note string) {
138 q := ""
139 if note != "" {
140 q = "?m=" + url.QueryEscape(note)
141 }
142 s.setFlash(w, msg)
143 http.Redirect(w, r, "/settings"+q, http.StatusSeeOther)
144 }
145
146 switch r.FormValue("field") {
147 case "key-add":
148 body := strings.TrimSpace(r.FormValue("key"))
149 if body == "" {
150 back("paste a public key in authorized_keys format", "")
151 return
152 }
153 argv := []string{"keys", "add"}
154 if scope := r.FormValue("scope"); scope == "git" {
155 argv = append(argv, "--scope", "git")
156 }
157 if label := strings.TrimSpace(r.FormValue("label")); label != "" {
158 argv = append(argv, "--label", label)
159 }
160 if msg, ok := s.runControlStdin(u, argv, body+"\n"); !ok {
161 back(msg, "")
162 return
163 }
164 back("", "key registered")
165 case "key-remove":
166 want := strings.TrimPrefix(r.FormValue("fingerprint"), "SHA256:")
167 if len(want) > 8 {
168 want = want[:8]
169 }
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 := fp
193 if len(want) > 8 {
194 want = want[:8]
195 }
196 if ok, msg := confirmed(r, want); !ok {
197 back(msg, "")
198 return
199 }
200 if _, msg, ok := s.runControl(u, []string{"pgp", "remove", fp}); !ok {
201 back(msg, "")
202 return
203 }
204 back("", "PGP key removed")
205 case "email-add":
206 if _, msg, ok := s.runControl(u, []string{"email", "add", strings.TrimSpace(r.FormValue("address"))}); !ok {
207 back(msg, "")
208 return
209 }
210 back("", "check that inbox for a verification code")
211 case "email-verify":
212 if _, msg, ok := s.runControl(u, []string{"email", "verify", strings.TrimSpace(r.FormValue("code"))}); !ok {
213 back(msg, "")
214 return
215 }
216 back("", "address verified")
217 case "email-remove":
218 address := r.FormValue("address")
219 if ok, msg := confirmed(r, address); !ok {
220 back(msg, "")
221 return
222 }
223 if _, msg, ok := s.runControl(u, []string{"email", "remove", address}); !ok {
224 back(msg, "")
225 return
226 }
227 back("", "address removed")
228 case "email-primary":
229 if _, msg, ok := s.runControl(u, []string{"email", "primary", r.FormValue("address")}); !ok {
230 back(msg, "")
231 return
232 }
233 back("", "primary address changed")
234 case "notify-mail", "notify-watch":
235 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
236 state := "off"
237 if r.FormValue(pref) == "on" {
238 state = "on"
239 }
240 if _, msg, ok := s.runControl(u, []string{"notifications", "settings", pref, state}); !ok {
241 back(msg, "")
242 return
243 }
244 back("", "notification preferences saved")
245 case "profile":
246 format := r.FormValue("format")
247 if format != "org" {
248 format = "md"
249 }
250 argv := []string{"profile", "set",
251 "--description", r.FormValue("description"),
252 "--website", r.FormValue("website"),
253 "--about-format", format,
254 "--file", "-",
255 }
256 for _, link := range profileLinkArgs(r.FormValue("links")) {
257 argv = append(argv, "--link", link)
258 }
259 if msg, ok := s.runControlStdin(u, argv, r.FormValue("about")); !ok {
260 back(msg, "")
261 return
262 }
263 back("", "profile updated")
264 default:
265 back("unknown form", "")
266 }
267}