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}