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