This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / signup / signup.go
11 kB 353 lines
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}