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