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