Monorepo for Tangled tangled.org
5

Configure Feed

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

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