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