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