This repository has no description
1package signup
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "net/http"
10 "net/url"
11 "strings"
12
13 "github.com/go-chi/chi/v5"
14 "github.com/posthog/posthog-go"
15 "tangled.org/core/appview/cloudflare"
16 "tangled.org/core/appview/config"
17 "tangled.org/core/appview/db"
18 "tangled.org/core/appview/email"
19 "tangled.org/core/appview/models"
20 "tangled.org/core/appview/pages"
21 "tangled.org/core/appview/state/userutil"
22 "tangled.org/core/idresolver"
23)
24
25type Signup struct {
26 config *config.Config
27 db *db.DB
28 cf *cloudflare.Client
29 posthog posthog.Client
30 idResolver *idresolver.Resolver
31 pages *pages.Pages
32 l *slog.Logger
33 disallowedNicknames map[string]bool
34}
35
36func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
37 var cf *cloudflare.Client
38 if cfg.Cloudflare.ApiToken != "" {
39 var err error
40 cf, err = cloudflare.New(cfg)
41 if err != nil {
42 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
43 }
44 }
45
46 disallowedNicknames := userutil.LoadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
47
48 return &Signup{
49 config: cfg,
50 db: database,
51 posthog: pc,
52 idResolver: idResolver,
53 cf: cf,
54 pages: pages,
55 l: l,
56 disallowedNicknames: disallowedNicknames,
57 }
58}
59
60// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
61func (s *Signup) isNicknameAllowed(nickname string) bool {
62 return !s.disallowedNicknames[strings.ToLower(nickname)]
63}
64
65func (s *Signup) Router() http.Handler {
66 r := chi.NewRouter()
67 r.Get("/", s.signup)
68 r.Post("/", s.signup)
69 r.Get("/complete", s.complete)
70 r.Post("/complete", s.complete)
71
72 return r
73}
74
75func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
76 switch r.Method {
77 case http.MethodGet:
78 emailId := r.URL.Query().Get("id")
79 s.pages.Signup(w, pages.SignupParams{
80 BaseParams: pages.BaseParamsFromContext(r.Context()),
81 CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey,
82 EmailId: emailId,
83 })
84 case http.MethodPost:
85 if s.cf == nil {
86 http.Error(w, "signup is disabled", http.StatusFailedDependency)
87 return
88 }
89 emailId := r.FormValue("email")
90 cfToken := r.FormValue("cf-turnstile-response")
91
92 noticeId := "signup-msg"
93
94 if err := s.validateCaptcha(cfToken, r); err != nil {
95 s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
96 s.pages.Notice(w, noticeId, "Captcha validation failed.")
97 return
98 }
99
100 if !email.IsValidEmail(emailId) {
101 s.pages.Notice(w, noticeId, "Invalid email address.")
102 return
103 }
104
105 exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
106 if err != nil {
107 s.l.Error("failed to check email existence", "error", err)
108 s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
109 return
110 }
111 if exists {
112 s.pages.Notice(w, noticeId, "Email already exists.")
113 return
114 }
115
116 code, err := s.inviteCodeRequest()
117 if err != nil {
118 s.l.Error("failed to create invite code", "error", err)
119 s.pages.Notice(w, noticeId, "Failed to create invite code.")
120 return
121 }
122
123 em := email.Email{
124 APIKey: s.config.Resend.ApiKey,
125 From: s.config.Resend.SentFrom,
126 To: emailId,
127 Subject: "Verify your Tangled account",
128 Text: `Copy and paste this code below to verify your account on Tangled.
129 ` + code,
130 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
131<p><code>` + code + `</code></p>`,
132 }
133
134 err = email.SendEmail(em)
135 if err != nil {
136 s.l.Error("failed to send email", "error", err)
137 s.pages.Notice(w, noticeId, "Failed to send email.")
138 return
139 }
140 err = db.AddInflightSignup(s.db, models.InflightSignup{
141 Email: emailId,
142 InviteCode: code,
143 })
144 if err != nil {
145 s.l.Error("failed to add inflight signup", "error", err)
146 s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
147 return
148 }
149
150 s.pages.HxRedirect(w, "/signup/complete")
151 }
152}
153
154func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
155 switch r.Method {
156 case http.MethodGet:
157 s.pages.CompleteSignup(w)
158 case http.MethodPost:
159 username := r.FormValue("username")
160 password := r.FormValue("password")
161 code := r.FormValue("code")
162
163 if !userutil.IsValidSubdomain(username) {
164 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.")
165 return
166 }
167
168 if !s.isNicknameAllowed(username) {
169 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
170 return
171 }
172
173 email, err := db.GetEmailForCode(s.db, code)
174 if err != nil {
175 s.l.Error("failed to get email for code", "error", err)
176 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
177 return
178 }
179
180 if s.cf == nil {
181 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
182 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
183 return
184 }
185
186 // Execute signup transactionally with rollback capability
187 err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
188 if err != nil {
189 // Error already logged and notice already sent
190 return
191 }
192 }
193}
194
195// executeSignupTransaction performs the signup process transactionally with rollback
196func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
197 // var recordID string
198 var did string
199 var emailAdded bool
200
201 success := false
202 defer func() {
203 if !success {
204 s.l.Info("rolling back signup transaction", "username", username, "did", did)
205
206 // Rollback DNS record
207 // if recordID != "" {
208 // if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
209 // s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
210 // } else {
211 // s.l.Info("successfully rolled back DNS record", "recordID", recordID)
212 // }
213 // }
214
215 // Rollback PDS account
216 if did != "" {
217 if err := s.deleteAccountRequest(did); err != nil {
218 s.l.Error("failed to rollback PDS account", "error", err, "did", did)
219 } else {
220 s.l.Info("successfully rolled back PDS account", "did", did)
221 }
222 }
223
224 // Rollback email from database
225 if emailAdded {
226 if err := db.DeleteEmail(s.db, did, email); err != nil {
227 s.l.Error("failed to rollback email from database", "error", err, "email", email)
228 } else {
229 s.l.Info("successfully rolled back email from database", "email", email)
230 }
231 }
232 }
233 }()
234
235 // step 1: create account in PDS
236 did, err := s.createAccountRequest(username, password, email, code)
237 if err != nil {
238 s.l.Error("failed to create account", "error", err)
239 s.pages.Notice(w, "signup-error", err.Error())
240 return err
241 }
242
243 // XXX: we have a wildcard *.tngl.sh record now
244 // step 2: create DNS record with actual DID
245 // recordID, err = s.cf.CreateDNSRecord(ctx, cloudflare.DNSRecord{
246 // Type: "TXT",
247 // Name: "_atproto." + username,
248 // Content: fmt.Sprintf(`"did=%s"`, did),
249 // TTL: 6400,
250 // Proxied: false,
251 // })
252 // if err != nil {
253 // s.l.Error("failed to create DNS record", "error", err)
254 // s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
255 // return err
256 // }
257
258 // step 3: add email to database
259 err = db.AddEmail(s.db, models.Email{
260 Did: did,
261 Address: email,
262 Verified: true,
263 Primary: true,
264 })
265 if err != nil {
266 s.l.Error("failed to add email", "error", err)
267 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
268 return err
269 }
270 emailAdded = true
271
272 // step 4: auto-claim <username>.<pds-domain> for this user.
273 // All signups through this flow receive a <username>.tngl.sh handle
274 // (or whatever the configured PDS host is), so we claim the matching
275 // sites subdomain on their behalf. This is the only way to obtain a
276 // *.tngl.sh sites domain; it cannot be claimed manually via settings.
277 pdsDomain := strings.TrimPrefix(s.config.Pds.Host, "https://")
278 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
279 autoClaimDomain := username + "." + pdsDomain
280 if err := db.ClaimDomain(s.db, did, autoClaimDomain); err != nil {
281 s.l.Warn("failed to auto-claim sites domain at signup",
282 "domain", autoClaimDomain,
283 "did", did,
284 "error", err,
285 )
286 } else {
287 s.l.Info("auto-claimed sites domain at signup", "domain", autoClaimDomain, "did", did)
288 }
289
290 // if we get here, we've successfully created the account and added the email
291 success = true
292
293 s.pages.SignupSuccess(w, pages.SignupSuccessParams{Handle: autoClaimDomain})
294
295 // clean up inflight signup asynchronously
296 go func() {
297 if err := db.DeleteInflightSignup(s.db, email); err != nil {
298 s.l.Error("failed to delete inflight signup", "error", err)
299 }
300 }()
301
302 return nil
303}
304
305type turnstileResponse struct {
306 Success bool `json:"success"`
307 ErrorCodes []string `json:"error-codes,omitempty"`
308 ChallengeTs string `json:"challenge_ts,omitempty"`
309 Hostname string `json:"hostname,omitempty"`
310}
311
312func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
313 if cfToken == "" {
314 return errors.New("captcha token is empty")
315 }
316
317 if s.config.Cloudflare.Turnstile.SecretKey == "" {
318 return errors.New("turnstile secret key not configured")
319 }
320
321 data := url.Values{}
322 data.Set("secret", s.config.Cloudflare.Turnstile.SecretKey)
323 data.Set("response", cfToken)
324
325 // include the client IP if we have it
326 if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
327 data.Set("remoteip", remoteIP)
328 } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
329 if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
330 data.Set("remoteip", strings.TrimSpace(ips[0]))
331 }
332 } else {
333 data.Set("remoteip", r.RemoteAddr)
334 }
335
336 resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
337 if err != nil {
338 return fmt.Errorf("failed to verify turnstile token: %w", err)
339 }
340 defer resp.Body.Close()
341
342 var turnstileResp turnstileResponse
343 if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
344 return fmt.Errorf("failed to decode turnstile response: %w", err)
345 }
346
347 if !turnstileResp.Success {
348 s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
349 return errors.New("turnstile validation failed")
350 }
351
352 return nil
353}