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}