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