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