forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package settings
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7 "log"
8 "net/http"
9 "net/url"
10 "strings"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/appview/config"
16 "tangled.org/core/appview/db"
17 "tangled.org/core/appview/email"
18 "tangled.org/core/appview/middleware"
19 "tangled.org/core/appview/models"
20 "tangled.org/core/appview/oauth"
21 "tangled.org/core/appview/pages"
22 "tangled.org/core/tid"
23
24 comatproto "github.com/bluesky-social/indigo/api/atproto"
25 "github.com/bluesky-social/indigo/atproto/syntax"
26 lexutil "github.com/bluesky-social/indigo/lex/util"
27 "github.com/gliderlabs/ssh"
28 "github.com/google/uuid"
29)
30
31type Settings struct {
32 Db *db.DB
33 OAuth *oauth.OAuth
34 Pages *pages.Pages
35 Config *config.Config
36}
37
38type tab = map[string]any
39
40var (
41 settingsTabs []tab = []tab{
42 {"Name": "profile", "Icon": "user"},
43 {"Name": "keys", "Icon": "key"},
44 {"Name": "emails", "Icon": "mail"},
45 {"Name": "notifications", "Icon": "bell"},
46 }
47)
48
49func (s *Settings) Router() http.Handler {
50 r := chi.NewRouter()
51
52 r.Use(middleware.AuthMiddleware(s.OAuth))
53
54 // settings pages
55 r.Get("/", s.profileSettings)
56 r.Get("/profile", s.profileSettings)
57
58 r.Route("/keys", func(r chi.Router) {
59 r.Get("/", s.keysSettings)
60 r.Put("/", s.keys)
61 r.Delete("/", s.keys)
62 })
63
64 r.Route("/emails", func(r chi.Router) {
65 r.Get("/", s.emailsSettings)
66 r.Put("/", s.emails)
67 r.Delete("/", s.emails)
68 r.Get("/verify", s.emailsVerify)
69 r.Post("/verify/resend", s.emailsVerifyResend)
70 r.Post("/primary", s.emailsPrimary)
71 })
72
73 r.Route("/notifications", func(r chi.Router) {
74 r.Get("/", s.notificationsSettings)
75 r.Put("/", s.updateNotificationPreferences)
76 })
77
78 return r
79}
80
81func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {
82 user := s.OAuth.GetUser(r)
83
84 s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{
85 LoggedInUser: user,
86 Tabs: settingsTabs,
87 Tab: "profile",
88 })
89}
90
91func (s *Settings) notificationsSettings(w http.ResponseWriter, r *http.Request) {
92 user := s.OAuth.GetUser(r)
93 did := s.OAuth.GetDid(r)
94
95 prefs, err := db.GetNotificationPreference(s.Db, did)
96 if err != nil {
97 log.Printf("failed to get notification preferences: %s", err)
98 s.Pages.Notice(w, "settings-notifications-error", "Unable to load notification preferences.")
99 return
100 }
101
102 s.Pages.UserNotificationSettings(w, pages.UserNotificationSettingsParams{
103 LoggedInUser: user,
104 Preferences: prefs,
105 Tabs: settingsTabs,
106 Tab: "notifications",
107 })
108}
109
110func (s *Settings) updateNotificationPreferences(w http.ResponseWriter, r *http.Request) {
111 did := s.OAuth.GetDid(r)
112
113 prefs := &models.NotificationPreferences{
114 UserDid: syntax.DID(did),
115 RepoStarred: r.FormValue("repo_starred") == "on",
116 IssueCreated: r.FormValue("issue_created") == "on",
117 IssueCommented: r.FormValue("issue_commented") == "on",
118 IssueClosed: r.FormValue("issue_closed") == "on",
119 PullCreated: r.FormValue("pull_created") == "on",
120 PullCommented: r.FormValue("pull_commented") == "on",
121 PullMerged: r.FormValue("pull_merged") == "on",
122 Followed: r.FormValue("followed") == "on",
123 EmailNotifications: r.FormValue("email_notifications") == "on",
124 }
125
126 err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
127 if err != nil {
128 log.Printf("failed to update notification preferences: %s", err)
129 s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
130 return
131 }
132
133 s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
134}
135
136func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
137 user := s.OAuth.GetUser(r)
138 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
139 if err != nil {
140 log.Println(err)
141 }
142
143 s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
144 LoggedInUser: user,
145 PubKeys: pubKeys,
146 Tabs: settingsTabs,
147 Tab: "keys",
148 })
149}
150
151func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
152 user := s.OAuth.GetUser(r)
153 emails, err := db.GetAllEmails(s.Db, user.Did)
154 if err != nil {
155 log.Println(err)
156 }
157
158 s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
159 LoggedInUser: user,
160 Emails: emails,
161 Tabs: settingsTabs,
162 Tab: "emails",
163 })
164}
165
166// buildVerificationEmail creates an email.Email struct for verification emails
167func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
168 verifyURL := s.verifyUrl(did, emailAddr, code)
169
170 return email.Email{
171 APIKey: s.Config.Resend.ApiKey,
172 From: s.Config.Resend.SentFrom,
173 To: emailAddr,
174 Subject: "Verify your Tangled email",
175 Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
176` + verifyURL,
177 Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
178<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
179 }
180}
181
182// sendVerificationEmail handles the common logic for sending verification emails
183func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
184 emailToSend := s.buildVerificationEmail(emailAddr, did, code)
185
186 err := email.SendEmail(emailToSend)
187 if err != nil {
188 log.Printf("sending email: %s", err)
189 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
190 return err
191 }
192
193 return nil
194}
195
196func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
197 switch r.Method {
198 case http.MethodGet:
199 s.Pages.Notice(w, "settings-emails", "Unimplemented.")
200 log.Println("unimplemented")
201 return
202 case http.MethodPut:
203 did := s.OAuth.GetDid(r)
204 emAddr := r.FormValue("email")
205 emAddr = strings.TrimSpace(emAddr)
206
207 if !email.IsValidEmail(emAddr) {
208 s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
209 return
210 }
211
212 // check if email already exists in database
213 existingEmail, err := db.GetEmail(s.Db, did, emAddr)
214 if err != nil && !errors.Is(err, sql.ErrNoRows) {
215 log.Printf("checking for existing email: %s", err)
216 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
217 return
218 }
219
220 if err == nil {
221 if existingEmail.Verified {
222 s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
223 return
224 }
225
226 s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
227 return
228 }
229
230 code := uuid.New().String()
231
232 // Begin transaction
233 tx, err := s.Db.Begin()
234 if err != nil {
235 log.Printf("failed to start transaction: %s", err)
236 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
237 return
238 }
239 defer tx.Rollback()
240
241 if err := db.AddEmail(tx, models.Email{
242 Did: did,
243 Address: emAddr,
244 Verified: false,
245 VerificationCode: code,
246 }); err != nil {
247 log.Printf("adding email: %s", err)
248 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
249 return
250 }
251
252 if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
253 return
254 }
255
256 // Commit transaction
257 if err := tx.Commit(); err != nil {
258 log.Printf("failed to commit transaction: %s", err)
259 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
260 return
261 }
262
263 s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
264 return
265 case http.MethodDelete:
266 did := s.OAuth.GetDid(r)
267 emailAddr := r.FormValue("email")
268 emailAddr = strings.TrimSpace(emailAddr)
269
270 // Begin transaction
271 tx, err := s.Db.Begin()
272 if err != nil {
273 log.Printf("failed to start transaction: %s", err)
274 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
275 return
276 }
277 defer tx.Rollback()
278
279 if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
280 log.Printf("deleting email: %s", err)
281 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
282 return
283 }
284
285 // Commit transaction
286 if err := tx.Commit(); err != nil {
287 log.Printf("failed to commit transaction: %s", err)
288 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
289 return
290 }
291
292 s.Pages.HxLocation(w, "/settings/emails")
293 return
294 }
295}
296
297func (s *Settings) verifyUrl(did string, email string, code string) string {
298 var appUrl string
299 if s.Config.Core.Dev {
300 appUrl = "http://" + s.Config.Core.ListenAddr
301 } else {
302 appUrl = s.Config.Core.AppviewHost
303 }
304
305 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
306}
307
308func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
309 q := r.URL.Query()
310
311 // Get the parameters directly from the query
312 emailAddr := q.Get("email")
313 did := q.Get("did")
314 code := q.Get("code")
315
316 valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
317 if err != nil {
318 log.Printf("checking email verification: %s", err)
319 s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
320 return
321 }
322
323 if !valid {
324 s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
325 return
326 }
327
328 // Mark email as verified in the database
329 if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
330 log.Printf("marking email as verified: %s", err)
331 s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
332 return
333 }
334
335 http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
336}
337
338func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
339 if r.Method != http.MethodPost {
340 s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
341 return
342 }
343
344 did := s.OAuth.GetDid(r)
345 emAddr := r.FormValue("email")
346 emAddr = strings.TrimSpace(emAddr)
347
348 if !email.IsValidEmail(emAddr) {
349 s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
350 return
351 }
352
353 // Check if email exists and is unverified
354 existingEmail, err := db.GetEmail(s.Db, did, emAddr)
355 if err != nil {
356 if errors.Is(err, sql.ErrNoRows) {
357 s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
358 } else {
359 log.Printf("checking for existing email: %s", err)
360 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
361 }
362 return
363 }
364
365 if existingEmail.Verified {
366 s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
367 return
368 }
369
370 // Check if last verification email was sent less than 10 minutes ago
371 if existingEmail.LastSent != nil {
372 timeSinceLastSent := time.Since(*existingEmail.LastSent)
373 if timeSinceLastSent < 10*time.Minute {
374 waitTime := 10*time.Minute - timeSinceLastSent
375 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
376 return
377 }
378 }
379
380 // Generate new verification code
381 code := uuid.New().String()
382
383 // Begin transaction
384 tx, err := s.Db.Begin()
385 if err != nil {
386 log.Printf("failed to start transaction: %s", err)
387 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
388 return
389 }
390 defer tx.Rollback()
391
392 // Update the verification code and last sent time
393 if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
394 log.Printf("updating email verification: %s", err)
395 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
396 return
397 }
398
399 // Send verification email
400 if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
401 return
402 }
403
404 // Commit transaction
405 if err := tx.Commit(); err != nil {
406 log.Printf("failed to commit transaction: %s", err)
407 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
408 return
409 }
410
411 s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
412}
413
414func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
415 did := s.OAuth.GetDid(r)
416 emailAddr := r.FormValue("email")
417 emailAddr = strings.TrimSpace(emailAddr)
418
419 if emailAddr == "" {
420 s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
421 return
422 }
423
424 if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
425 log.Printf("setting primary email: %s", err)
426 s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
427 return
428 }
429
430 s.Pages.HxLocation(w, "/settings/emails")
431}
432
433func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
434 switch r.Method {
435 case http.MethodGet:
436 s.Pages.Notice(w, "settings-keys", "Unimplemented.")
437 log.Println("unimplemented")
438 return
439 case http.MethodPut:
440 did := s.OAuth.GetDid(r)
441 key := r.FormValue("key")
442 key = strings.TrimSpace(key)
443 name := r.FormValue("name")
444 client, err := s.OAuth.AuthorizedClient(r)
445 if err != nil {
446 s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.")
447 return
448 }
449
450 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key))
451 if err != nil {
452 log.Printf("parsing public key: %s", err)
453 s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
454 return
455 }
456
457 rkey := tid.TID()
458
459 tx, err := s.Db.Begin()
460 if err != nil {
461 log.Printf("failed to start tx; adding public key: %s", err)
462 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
463 return
464 }
465 defer tx.Rollback()
466
467 if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
468 log.Printf("adding public key: %s", err)
469 s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
470 return
471 }
472
473 // store in pds too
474 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
475 Collection: tangled.PublicKeyNSID,
476 Repo: did,
477 Rkey: rkey,
478 Record: &lexutil.LexiconTypeDecoder{
479 Val: &tangled.PublicKey{
480 CreatedAt: time.Now().Format(time.RFC3339),
481 Key: key,
482 Name: name,
483 }},
484 })
485 // invalid record
486 if err != nil {
487 log.Printf("failed to create record: %s", err)
488 s.Pages.Notice(w, "settings-keys", "Failed to create record.")
489 return
490 }
491
492 log.Println("created atproto record: ", resp.Uri)
493
494 err = tx.Commit()
495 if err != nil {
496 log.Printf("failed to commit tx; adding public key: %s", err)
497 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
498 return
499 }
500
501 s.Pages.HxLocation(w, "/settings/keys")
502 return
503
504 case http.MethodDelete:
505 did := s.OAuth.GetDid(r)
506 q := r.URL.Query()
507
508 name := q.Get("name")
509 rkey := q.Get("rkey")
510 key := q.Get("key")
511
512 log.Println(name)
513 log.Println(rkey)
514 log.Println(key)
515
516 client, err := s.OAuth.AuthorizedClient(r)
517 if err != nil {
518 log.Printf("failed to authorize client: %s", err)
519 s.Pages.Notice(w, "settings-keys", "Failed to authorize client.")
520 return
521 }
522
523 if err := db.DeletePublicKey(s.Db, did, name, key); err != nil {
524 log.Printf("removing public key: %s", err)
525 s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
526 return
527 }
528
529 if rkey != "" {
530 // remove from pds too
531 _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
532 Collection: tangled.PublicKeyNSID,
533 Repo: did,
534 Rkey: rkey,
535 })
536
537 // invalid record
538 if err != nil {
539 log.Printf("failed to delete record from PDS: %s", err)
540 s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
541 return
542 }
543 }
544 log.Println("deleted successfully")
545
546 s.Pages.HxLocation(w, "/settings/keys")
547 return
548 }
549}