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