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 {"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}