this repo has no description
1package signup
2
3import (
4 "fmt"
5 "log/slog"
6 "net/http"
7
8 "github.com/go-chi/chi/v5"
9 "github.com/posthog/posthog-go"
10 "tangled.sh/tangled.sh/core/appview/config"
11 "tangled.sh/tangled.sh/core/appview/db"
12 "tangled.sh/tangled.sh/core/appview/dns"
13 "tangled.sh/tangled.sh/core/appview/email"
14 "tangled.sh/tangled.sh/core/appview/pages"
15 "tangled.sh/tangled.sh/core/appview/state/userutil"
16 "tangled.sh/tangled.sh/core/appview/xrpcclient"
17 "tangled.sh/tangled.sh/core/idresolver"
18)
19
20type Signup struct {
21 config *config.Config
22 db *db.DB
23 cf *dns.Cloudflare
24 posthog posthog.Client
25 xrpc *xrpcclient.Client
26 idResolver *idresolver.Resolver
27 pages *pages.Pages
28 l *slog.Logger
29}
30
31func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
32 var cf *dns.Cloudflare
33 if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
34 var err error
35 cf, err = dns.NewCloudflare(cfg)
36 if err != nil {
37 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
38 }
39 }
40
41 return &Signup{
42 config: cfg,
43 db: database,
44 posthog: pc,
45 idResolver: idResolver,
46 cf: cf,
47 pages: pages,
48 l: l,
49 }
50}
51
52func (s *Signup) Router() http.Handler {
53 r := chi.NewRouter()
54 r.Post("/", s.signup)
55 r.Get("/complete", s.complete)
56 r.Post("/complete", s.complete)
57
58 return r
59}
60
61func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
62 if s.cf == nil {
63 http.Error(w, "signup is disabled", http.StatusFailedDependency)
64 }
65 emailId := r.FormValue("email")
66
67 if !email.IsValidEmail(emailId) {
68 s.pages.Notice(w, "login-msg", "Invalid email address.")
69 return
70 }
71
72 exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
73 if err != nil {
74 s.l.Error("failed to check email existence", "error", err)
75 s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
76 return
77 }
78 if exists {
79 s.pages.Notice(w, "login-msg", "Email already exists.")
80 return
81 }
82
83 code, err := s.inviteCodeRequest()
84 if err != nil {
85 s.l.Error("failed to create invite code", "error", err)
86 s.pages.Notice(w, "login-msg", "Failed to create invite code.")
87 return
88 }
89
90 em := email.Email{
91 APIKey: s.config.Resend.ApiKey,
92 From: s.config.Resend.SentFrom,
93 To: emailId,
94 Subject: "Verify your Tangled account",
95 Text: `Copy and paste this code below to verify your account on Tangled.
96 ` + code,
97 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
98<p><code>` + code + `</code></p>`,
99 }
100
101 err = email.SendEmail(em)
102 if err != nil {
103 s.l.Error("failed to send email", "error", err)
104 s.pages.Notice(w, "login-msg", "Failed to send email.")
105 return
106 }
107 err = db.AddInflightSignup(s.db, db.InflightSignup{
108 Email: emailId,
109 InviteCode: code,
110 })
111 if err != nil {
112 s.l.Error("failed to add inflight signup", "error", err)
113 s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
114 return
115 }
116
117 s.pages.HxRedirect(w, "/signup/complete")
118}
119
120func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
121 switch r.Method {
122 case http.MethodGet:
123 s.pages.CompleteSignup(w, pages.SignupParams{})
124 case http.MethodPost:
125 username := r.FormValue("username")
126 password := r.FormValue("password")
127 code := r.FormValue("code")
128
129 if !userutil.IsValidSubdomain(username) {
130 s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4–63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.")
131 return
132 }
133
134 email, err := db.GetEmailForCode(s.db, code)
135 if err != nil {
136 s.l.Error("failed to get email for code", "error", err)
137 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
138 return
139 }
140
141 did, err := s.createAccountRequest(username, password, email, code)
142 if err != nil {
143 s.l.Error("failed to create account", "error", err)
144 s.pages.Notice(w, "signup-error", err.Error())
145 return
146 }
147
148 if s.cf == nil {
149 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
150 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
151 return
152 }
153
154 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
155 Type: "TXT",
156 Name: "_atproto." + username,
157 Content: "did=" + did,
158 TTL: 6400,
159 Proxied: false,
160 })
161 if err != nil {
162 s.l.Error("failed to create DNS record", "error", err)
163 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
164 return
165 }
166
167 err = db.AddEmail(s.db, db.Email{
168 Did: did,
169 Address: email,
170 Verified: true,
171 Primary: true,
172 })
173 if err != nil {
174 s.l.Error("failed to add email", "error", err)
175 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
176 return
177 }
178
179 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
180 <a class="underline text-black dark:text-white" href="/login">login</a>
181 with <code>%s.tngl.sh</code>.`, username))
182
183 go func() {
184 err := db.DeleteInflightSignup(s.db, email)
185 if err != nil {
186 s.l.Error("failed to delete inflight signup", "error", err)
187 }
188 }()
189 return
190 }
191}