diff --git a/appview/config.go b/appview/config.go index 950b8be8..be28f2ca 100644 --- a/appview/config.go +++ b/appview/config.go @@ -12,6 +12,7 @@ type Config struct { ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"` Dev bool `env:"TANGLED_DEV, default=false"` JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` + ResendApiKey string `env:"TANGLED_RESEND_API_KEY"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/db/db.go b/appview/db/db.go index 01809ee1..b2147576 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -107,7 +107,7 @@ func Make(dbPath string) (*DB, error) { -- identifiers id integer primary key autoincrement, pull_id integer not null, - + -- at identifiers repo_at text not null, owner_did text not null, @@ -196,6 +196,17 @@ func Make(dbPath string) (*DB, error) { unique(starred_by_did, repo_at) ); + create table if not exists emails ( + id integer primary key autoincrement, + did text not null, + email text not null, + verified integer not null default 0, + verification_code text not null, + is_primary integer not null default 0, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + unique(did, email) + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique diff --git a/appview/db/email.go b/appview/db/email.go new file mode 100644 index 00000000..5dbbfe96 --- /dev/null +++ b/appview/db/email.go @@ -0,0 +1,203 @@ +package db + +import "time" + +type Email struct { + ID int64 + Did string + Address string + Verified bool + Primary bool + VerificationCode string + CreatedAt time.Time +} + +func GetPrimaryEmail(e Execer, did string) (Email, error) { + query := ` + select id, did, email, verified, is_primary, verification_code, created + from emails + where did = ? and is_primary = true + ` + var email Email + var createdStr string + err := e.QueryRow(query, did).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return Email{}, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return Email{}, err + } + return email, nil +} + +func GetEmail(e Execer, did string, em string) (Email, error) { + query := ` + select id, did, email, verified, is_primary, verification_code, created + from emails + where did = ? and email = ? + ` + var email Email + var createdStr string + err := e.QueryRow(query, did, em).Scan(&email.ID, &email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return Email{}, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return Email{}, err + } + return email, nil +} + +func GetDidForEmail(e Execer, em string) (string, error) { + query := ` + select did + from emails + where email = ? + ` + var did string + err := e.QueryRow(query, em).Scan(&did) + if err != nil { + return "", err + } + return did, nil +} + +func GetVerificationCodeForEmail(e Execer, did string, email string) (string, error) { + query := ` + select verification_code + from emails + where did = ? and email = ? + ` + var code string + err := e.QueryRow(query, did, email).Scan(&code) + if err != nil { + return "", err + } + return code, nil +} + +func CheckEmailExists(e Execer, did string, email string) (bool, error) { + query := ` + select count(*) + from emails + where did = ? and email = ? + ` + var count int + err := e.QueryRow(query, did, email).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) { + query := ` + select count(*) + from emails + where did = ? and email = ? and verification_code = ? + ` + var count int + err := e.QueryRow(query, did, email, code).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +func AddEmail(e Execer, email Email) error { + // Check if this is the first email for this DID + countQuery := ` + select count(*) + from emails + where did = ? + ` + var count int + err := e.QueryRow(countQuery, email.Did).Scan(&count) + if err != nil { + return err + } + + // If this is the first email, mark it as primary + if count == 0 { + email.Primary = true + } + + query := ` + insert into emails (did, email, verified, is_primary, verification_code) + values (?, ?, ?, ?, ?) + ` + _, err = e.Exec(query, email.Did, email.Address, email.Verified, email.Primary, email.VerificationCode) + return err +} + +func DeleteEmail(e Execer, did string, email string) error { + query := ` + delete from emails + where did = ? and email = ? + ` + _, err := e.Exec(query, did, email) + return err +} + +func MarkEmailVerified(e Execer, did string, email string) error { + query := ` + update emails + set verified = true + where did = ? and email = ? + ` + _, err := e.Exec(query, did, email) + return err +} + +func MakeEmailPrimary(e Execer, did string, email string) error { + // First, unset all primary emails for this DID + query1 := ` + update emails + set is_primary = false + where did = ? + ` + _, err := e.Exec(query1, did) + if err != nil { + return err + } + + // Then, set the specified email as primary + query2 := ` + update emails + set is_primary = true + where did = ? and email = ? + ` + _, err = e.Exec(query2, did, email) + return err +} + +func GetAllEmails(e Execer, did string) ([]Email, error) { + query := ` + select did, email, verified, is_primary, verification_code, created + from emails + where did = ? + ` + rows, err := e.Query(query, did) + if err != nil { + return nil, err + } + defer rows.Close() + + var emails []Email + for rows.Next() { + var email Email + var createdStr string + err := rows.Scan(&email.Did, &email.Address, &email.Verified, &email.Primary, &email.VerificationCode, &createdStr) + if err != nil { + return nil, err + } + email.CreatedAt, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return nil, err + } + emails = append(emails, email) + } + return emails, nil +} diff --git a/appview/email/email.go b/appview/email/email.go new file mode 100644 index 00000000..6ad23824 --- /dev/null +++ b/appview/email/email.go @@ -0,0 +1,69 @@ +package email + +import ( + "fmt" + "net" + "regexp" + "strings" + + "github.com/resend/resend-go/v2" +) + +type Email struct { + From string + To string + Subject string + Text string + Html string + APIKey string +} + +func SendEmail(email Email) error { + client := resend.NewClient(email.APIKey) + _, err := client.Emails.Send(&resend.SendEmailRequest{ + From: email.From, + To: []string{email.To}, + Subject: email.Subject, + Text: email.Text, + Html: email.Html, + }) + if err != nil { + return fmt.Errorf("error sending email: %w", err) + } + return nil +} + +func IsValidEmail(email string) bool { + // Basic length check + if len(email) < 3 || len(email) > 254 { + return false + } + + // Regular expression for email validation (RFC 5322 compliant) + pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + + // Compile regex + regex := regexp.MustCompile(pattern) + + // Check if email matches regex pattern + if !regex.MatchString(email) { + return false + } + + // Split email into local and domain parts + parts := strings.Split(email, "@") + domain := parts[1] + + mx, err := net.LookupMX(domain) + if err != nil || len(mx) == 0 { + return false + } + + return true +} + +func IsValidEmailSimple(email string) bool { + pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` + regex := regexp.MustCompile(pattern) + return regex.MatchString(email) && len(email) <= 254 && len(email) >= 3 +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index e3e38d7a..d7b9fee8 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -120,6 +120,7 @@ func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { type SettingsParams struct { LoggedInUser *auth.User PubKeys []db.PublicKey + Emails []db.Email } func (p *Pages) Settings(w io.Writer, params SettingsParams) error { diff --git a/appview/pages/templates/settings.html b/appview/pages/templates/settings.html index d5e9d63d..750d5255 100644 --- a/appview/pages/templates/settings.html +++ b/appview/pages/templates/settings.html @@ -8,6 +8,7 @@ {{ block "profile" . }} {{ end }} {{ block "keys" . }} {{ end }} {{ block "knots" . }} {{ end }} + {{ block "emails" . }} {{ end }} {{ end }} @@ -31,7 +32,7 @@
{{ .Name }}
added {{ .Created | timeFmt }}
{{ .Key }}
+ {{ .Key }}
add an ssh key
{{ .Address }}
+added {{ .CreatedAt | timeFmt }}
+ {{ if .Verified }} + verified + {{ else }} + unverified + {{ end }} + {{ if .Primary }} + primary + {{ end }} +add an email address
+ +Click the link (or copy and paste it into your browser) to verify your email address.
+`, + }) + + if err != nil { + log.Printf("sending email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to send verification email at this moment, try again later.") + return + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("failed to commit transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") + return + } + + s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") + return + case http.MethodDelete: + did := s.auth.GetDid(r) + emailAddr := r.FormValue("email") + emailAddr = strings.TrimSpace(emailAddr) + + // Begin transaction + tx, err := s.db.Begin() + if err != nil { + log.Printf("failed to start transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + defer tx.Rollback() + + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { + log.Printf("deleting email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + + // Commit transaction + if err := tx.Commit(); err != nil { + log.Printf("failed to commit transaction: %s", err) + s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") + return + } + + s.pages.HxLocation(w, "/settings") + return + } +} + +func (s *State) verifyUrl(did string, email string, code string) string { + var appUrl string + if s.config.Dev { + appUrl = "http://" + s.config.ListenAddr + } else { + appUrl = "https://tangled.sh" + } + + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, did, email, code) +} + +func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + // Get the parameters directly from the query + emailAddr := q.Get("email") + did := q.Get("did") + code := q.Get("code") + + valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) + if err != nil { + log.Printf("checking email verification: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") + return + } + + if !valid { + s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") + return + } + + // Mark email as verified in the database + if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { + log.Printf("marking email as verified: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") + return + } + + http.Redirect(w, r, "/settings", http.StatusSeeOther) +} + +func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { + did := s.auth.GetDid(r) + emailAddr := r.FormValue("email") + emailAddr = strings.TrimSpace(emailAddr) + + if emailAddr == "" { + s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") + return + } + + if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { + log.Printf("setting primary email: %s", err) + s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") + return + } + + s.pages.HxLocation(w, "/settings") +} + func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: diff --git a/go.mod b/go.mod index d37b2827..b79cc791 100644 --- a/go.mod +++ b/go.mod @@ -92,6 +92,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.54.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/resend/resend-go/v2 v2.15.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect diff --git a/go.sum b/go.sum index be8340ec..20e3ac7a 100644 --- a/go.sum +++ b/go.sum @@ -224,6 +224,8 @@ github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= +github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= diff --git a/input.css b/input.css index bdce9b6e..fa3db545 100644 --- a/input.css +++ b/input.css @@ -149,7 +149,7 @@ @apply py-1 text-red-400; } .success { - @apply py-1 text-green-400; + @apply py-1 text-gray-900; } } }