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