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}