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