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 UserMentioned: r.FormValue("user_mentioned") == "on",
124 EmailNotifications: r.FormValue("email_notifications") == "on",
125 }
126
127 err := s.Db.UpdateNotificationPreferences(r.Context(), prefs)
128 if err != nil {
129 log.Printf("failed to update notification preferences: %s", err)
130 s.Pages.Notice(w, "settings-notifications-error", "Unable to save notification preferences.")
131 return
132 }
133
134 s.Pages.Notice(w, "settings-notifications-success", "Notification preferences saved successfully.")
135}
136
137func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) {
138 user := s.OAuth.GetUser(r)
139 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did)
140 if err != nil {
141 log.Println(err)
142 }
143
144 s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{
145 LoggedInUser: user,
146 PubKeys: pubKeys,
147 Tabs: settingsTabs,
148 Tab: "keys",
149 })
150}
151
152func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) {
153 user := s.OAuth.GetUser(r)
154 emails, err := db.GetAllEmails(s.Db, user.Did)
155 if err != nil {
156 log.Println(err)
157 }
158
159 s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{
160 LoggedInUser: user,
161 Emails: emails,
162 Tabs: settingsTabs,
163 Tab: "emails",
164 })
165}
166
167// buildVerificationEmail creates an email.Email struct for verification emails
168func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email {
169 verifyURL := s.verifyUrl(did, emailAddr, code)
170
171 return email.Email{
172 APIKey: s.Config.Resend.ApiKey,
173 From: s.Config.Resend.SentFrom,
174 To: emailAddr,
175 Subject: "Verify your Tangled email",
176 Text: `Click the link below (or copy and paste it into your browser) to verify your email address.
177` + verifyURL,
178 Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p>
179<p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`,
180 }
181}
182
183// sendVerificationEmail handles the common logic for sending verification emails
184func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error {
185 emailToSend := s.buildVerificationEmail(emailAddr, did, code)
186
187 err := email.SendEmail(emailToSend)
188 if err != nil {
189 log.Printf("sending email: %s", err)
190 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext))
191 return err
192 }
193
194 return nil
195}
196
197func (s *Settings) emails(w http.ResponseWriter, r *http.Request) {
198 switch r.Method {
199 case http.MethodGet:
200 s.Pages.Notice(w, "settings-emails", "Unimplemented.")
201 log.Println("unimplemented")
202 return
203 case http.MethodPut:
204 did := s.OAuth.GetDid(r)
205 emAddr := r.FormValue("email")
206 emAddr = strings.TrimSpace(emAddr)
207
208 if !email.IsValidEmail(emAddr) {
209 s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
210 return
211 }
212
213 // check if email already exists in database
214 existingEmail, err := db.GetEmail(s.Db, did, emAddr)
215 if err != nil && !errors.Is(err, sql.ErrNoRows) {
216 log.Printf("checking for existing email: %s", err)
217 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
218 return
219 }
220
221 if err == nil {
222 if existingEmail.Verified {
223 s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
224 return
225 }
226
227 s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.")
228 return
229 }
230
231 code := uuid.New().String()
232
233 // Begin transaction
234 tx, err := s.Db.Begin()
235 if err != nil {
236 log.Printf("failed to start transaction: %s", err)
237 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
238 return
239 }
240 defer tx.Rollback()
241
242 if err := db.AddEmail(tx, models.Email{
243 Did: did,
244 Address: emAddr,
245 Verified: false,
246 VerificationCode: code,
247 }); err != nil {
248 log.Printf("adding email: %s", err)
249 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
250 return
251 }
252
253 if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
254 return
255 }
256
257 // Commit transaction
258 if err := tx.Commit(); err != nil {
259 log.Printf("failed to commit transaction: %s", err)
260 s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.")
261 return
262 }
263
264 s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.")
265 return
266 case http.MethodDelete:
267 did := s.OAuth.GetDid(r)
268 emailAddr := r.FormValue("email")
269 emailAddr = strings.TrimSpace(emailAddr)
270
271 // Begin transaction
272 tx, err := s.Db.Begin()
273 if err != nil {
274 log.Printf("failed to start transaction: %s", err)
275 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
276 return
277 }
278 defer tx.Rollback()
279
280 if err := db.DeleteEmail(tx, did, emailAddr); err != nil {
281 log.Printf("deleting email: %s", err)
282 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
283 return
284 }
285
286 // Commit transaction
287 if err := tx.Commit(); err != nil {
288 log.Printf("failed to commit transaction: %s", err)
289 s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.")
290 return
291 }
292
293 s.Pages.HxLocation(w, "/settings/emails")
294 return
295 }
296}
297
298func (s *Settings) verifyUrl(did string, email string, code string) string {
299 var appUrl string
300 if s.Config.Core.Dev {
301 appUrl = "http://" + s.Config.Core.ListenAddr
302 } else {
303 appUrl = s.Config.Core.AppviewHost
304 }
305
306 return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code))
307}
308
309func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) {
310 q := r.URL.Query()
311
312 // Get the parameters directly from the query
313 emailAddr := q.Get("email")
314 did := q.Get("did")
315 code := q.Get("code")
316
317 valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code)
318 if err != nil {
319 log.Printf("checking email verification: %s", err)
320 s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.")
321 return
322 }
323
324 if !valid {
325 s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.")
326 return
327 }
328
329 // Mark email as verified in the database
330 if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil {
331 log.Printf("marking email as verified: %s", err)
332 s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.")
333 return
334 }
335
336 http.Redirect(w, r, "/settings/emails", http.StatusSeeOther)
337}
338
339func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) {
340 if r.Method != http.MethodPost {
341 s.Pages.Notice(w, "settings-emails-error", "Invalid request method.")
342 return
343 }
344
345 did := s.OAuth.GetDid(r)
346 emAddr := r.FormValue("email")
347 emAddr = strings.TrimSpace(emAddr)
348
349 if !email.IsValidEmail(emAddr) {
350 s.Pages.Notice(w, "settings-emails-error", "Invalid email address.")
351 return
352 }
353
354 // Check if email exists and is unverified
355 existingEmail, err := db.GetEmail(s.Db, did, emAddr)
356 if err != nil {
357 if errors.Is(err, sql.ErrNoRows) {
358 s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.")
359 } else {
360 log.Printf("checking for existing email: %s", err)
361 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
362 }
363 return
364 }
365
366 if existingEmail.Verified {
367 s.Pages.Notice(w, "settings-emails-error", "This email is already verified.")
368 return
369 }
370
371 // Check if last verification email was sent less than 10 minutes ago
372 if existingEmail.LastSent != nil {
373 timeSinceLastSent := time.Since(*existingEmail.LastSent)
374 if timeSinceLastSent < 10*time.Minute {
375 waitTime := 10*time.Minute - timeSinceLastSent
376 s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1)))
377 return
378 }
379 }
380
381 // Generate new verification code
382 code := uuid.New().String()
383
384 // Begin transaction
385 tx, err := s.Db.Begin()
386 if err != nil {
387 log.Printf("failed to start transaction: %s", err)
388 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
389 return
390 }
391 defer tx.Rollback()
392
393 // Update the verification code and last sent time
394 if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil {
395 log.Printf("updating email verification: %s", err)
396 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
397 return
398 }
399
400 // Send verification email
401 if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil {
402 return
403 }
404
405 // Commit transaction
406 if err := tx.Commit(); err != nil {
407 log.Printf("failed to commit transaction: %s", err)
408 s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.")
409 return
410 }
411
412 s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.")
413}
414
415func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) {
416 did := s.OAuth.GetDid(r)
417 emailAddr := r.FormValue("email")
418 emailAddr = strings.TrimSpace(emailAddr)
419
420 if emailAddr == "" {
421 s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.")
422 return
423 }
424
425 if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil {
426 log.Printf("setting primary email: %s", err)
427 s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.")
428 return
429 }
430
431 s.Pages.HxLocation(w, "/settings/emails")
432}
433
434func (s *Settings) keys(w http.ResponseWriter, r *http.Request) {
435 switch r.Method {
436 case http.MethodGet:
437 s.Pages.Notice(w, "settings-keys", "Unimplemented.")
438 log.Println("unimplemented")
439 return
440 case http.MethodPut:
441 did := s.OAuth.GetDid(r)
442 key := r.FormValue("key")
443 key = strings.TrimSpace(key)
444 name := r.FormValue("name")
445 client, err := s.OAuth.AuthorizedClient(r)
446 if err != nil {
447 s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.")
448 return
449 }
450
451 _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key))
452 if err != nil {
453 log.Printf("parsing public key: %s", err)
454 s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.")
455 return
456 }
457
458 rkey := tid.TID()
459
460 tx, err := s.Db.Begin()
461 if err != nil {
462 log.Printf("failed to start tx; adding public key: %s", err)
463 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
464 return
465 }
466 defer tx.Rollback()
467
468 if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil {
469 log.Printf("adding public key: %s", err)
470 s.Pages.Notice(w, "settings-keys", "Failed to add public key.")
471 return
472 }
473
474 // store in pds too
475 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
476 Collection: tangled.PublicKeyNSID,
477 Repo: did,
478 Rkey: rkey,
479 Record: &lexutil.LexiconTypeDecoder{
480 Val: &tangled.PublicKey{
481 CreatedAt: time.Now().Format(time.RFC3339),
482 Key: key,
483 Name: name,
484 }},
485 })
486 // invalid record
487 if err != nil {
488 log.Printf("failed to create record: %s", err)
489 s.Pages.Notice(w, "settings-keys", "Failed to create record.")
490 return
491 }
492
493 log.Println("created atproto record: ", resp.Uri)
494
495 err = tx.Commit()
496 if err != nil {
497 log.Printf("failed to commit tx; adding public key: %s", err)
498 s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.")
499 return
500 }
501
502 s.Pages.HxLocation(w, "/settings/keys")
503 return
504
505 case http.MethodDelete:
506 did := s.OAuth.GetDid(r)
507 q := r.URL.Query()
508
509 name := q.Get("name")
510 rkey := q.Get("rkey")
511 key := q.Get("key")
512
513 log.Println(name)
514 log.Println(rkey)
515 log.Println(key)
516
517 client, err := s.OAuth.AuthorizedClient(r)
518 if err != nil {
519 log.Printf("failed to authorize client: %s", err)
520 s.Pages.Notice(w, "settings-keys", "Failed to authorize client.")
521 return
522 }
523
524 if err := db.DeletePublicKey(s.Db, did, name, key); err != nil {
525 log.Printf("removing public key: %s", err)
526 s.Pages.Notice(w, "settings-keys", "Failed to remove public key.")
527 return
528 }
529
530 if rkey != "" {
531 // remove from pds too
532 _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
533 Collection: tangled.PublicKeyNSID,
534 Repo: did,
535 Rkey: rkey,
536 })
537
538 // invalid record
539 if err != nil {
540 log.Printf("failed to delete record from PDS: %s", err)
541 s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.")
542 return
543 }
544 }
545 log.Println("deleted successfully")
546
547 s.Pages.HxLocation(w, "/settings/keys")
548 return
549 }
550}