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