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
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.GetPublicKeysForDid(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: s.Config.Resend.SentFrom,
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 := tid.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}