Monorepo for Tangled tangled.org
1package signup 2 3import ( 4 "bufio" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "net/url" 11 "os" 12 "strings" 13 14 "github.com/go-chi/chi/v5" 15 "github.com/posthog/posthog-go" 16 "tangled.org/core/appview/config" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/dns" 19 "tangled.org/core/appview/email" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/state/userutil" 23 "tangled.org/core/idresolver" 24) 25 26type Signup struct { 27 config *config.Config 28 db *db.DB 29 cf *dns.Cloudflare 30 posthog posthog.Client 31 idResolver *idresolver.Resolver 32 pages *pages.Pages 33 l *slog.Logger 34 disallowedNicknames map[string]bool 35} 36 37func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 38 var cf *dns.Cloudflare 39 if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 40 var err error 41 cf, err = dns.NewCloudflare(cfg) 42 if err != nil { 43 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 44 } 45 } 46 47 disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 48 49 return &Signup{ 50 config: cfg, 51 db: database, 52 posthog: pc, 53 idResolver: idResolver, 54 cf: cf, 55 pages: pages, 56 l: l, 57 disallowedNicknames: disallowedNicknames, 58 } 59} 60 61func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 62 disallowed := make(map[string]bool) 63 64 if filepath == "" { 65 logger.Debug("no disallowed nicknames file configured") 66 return disallowed 67 } 68 69 file, err := os.Open(filepath) 70 if err != nil { 71 logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 72 return disallowed 73 } 74 defer file.Close() 75 76 scanner := bufio.NewScanner(file) 77 lineNum := 0 78 for scanner.Scan() { 79 lineNum++ 80 line := strings.TrimSpace(scanner.Text()) 81 if line == "" || strings.HasPrefix(line, "#") { 82 continue // skip empty lines and comments 83 } 84 85 nickname := strings.ToLower(line) 86 if userutil.IsValidSubdomain(nickname) { 87 disallowed[nickname] = true 88 } else { 89 logger.Warn("invalid nickname format in disallowed nicknames file", 90 "file", filepath, "line", lineNum, "nickname", nickname) 91 } 92 } 93 94 if err := scanner.Err(); err != nil { 95 logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 96 } 97 98 logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 99 return disallowed 100} 101 102// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 103func (s *Signup) isNicknameAllowed(nickname string) bool { 104 return !s.disallowedNicknames[strings.ToLower(nickname)] 105} 106 107func (s *Signup) Router() http.Handler { 108 r := chi.NewRouter() 109 r.Get("/", s.signup) 110 r.Post("/", s.signup) 111 r.Get("/complete", s.complete) 112 r.Post("/complete", s.complete) 113 114 return r 115} 116 117func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 118 switch r.Method { 119 case http.MethodGet: 120 s.pages.Signup(w, pages.SignupParams{ 121 CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey, 122 }) 123 case http.MethodPost: 124 if s.cf == nil { 125 http.Error(w, "signup is disabled", http.StatusFailedDependency) 126 return 127 } 128 emailId := r.FormValue("email") 129 cfToken := r.FormValue("cf-turnstile-response") 130 131 noticeId := "signup-msg" 132 133 if err := s.validateCaptcha(cfToken, r); err != nil { 134 s.l.Warn("turnstile validation failed", "error", err, "email", emailId) 135 s.pages.Notice(w, noticeId, "Captcha validation failed.") 136 return 137 } 138 139 if !email.IsValidEmail(emailId) { 140 s.pages.Notice(w, noticeId, "Invalid email address.") 141 return 142 } 143 144 exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 145 if err != nil { 146 s.l.Error("failed to check email existence", "error", err) 147 s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 148 return 149 } 150 if exists { 151 s.pages.Notice(w, noticeId, "Email already exists.") 152 return 153 } 154 155 code, err := s.inviteCodeRequest() 156 if err != nil { 157 s.l.Error("failed to create invite code", "error", err) 158 s.pages.Notice(w, noticeId, "Failed to create invite code.") 159 return 160 } 161 162 em := email.Email{ 163 APIKey: s.config.Resend.ApiKey, 164 From: s.config.Resend.SentFrom, 165 To: emailId, 166 Subject: "Verify your Tangled account", 167 Text: `Copy and paste this code below to verify your account on Tangled. 168 ` + code, 169 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 170<p><code>` + code + `</code></p>`, 171 } 172 173 err = email.SendEmail(em) 174 if err != nil { 175 s.l.Error("failed to send email", "error", err) 176 s.pages.Notice(w, noticeId, "Failed to send email.") 177 return 178 } 179 err = db.AddInflightSignup(s.db, models.InflightSignup{ 180 Email: emailId, 181 InviteCode: code, 182 }) 183 if err != nil { 184 s.l.Error("failed to add inflight signup", "error", err) 185 s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 186 return 187 } 188 189 s.pages.HxRedirect(w, "/signup/complete") 190 } 191} 192 193func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 194 switch r.Method { 195 case http.MethodGet: 196 s.pages.CompleteSignup(w) 197 case http.MethodPost: 198 username := r.FormValue("username") 199 password := r.FormValue("password") 200 code := r.FormValue("code") 201 202 if !userutil.IsValidSubdomain(username) { 203 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.") 204 return 205 } 206 207 if !s.isNicknameAllowed(username) { 208 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 209 return 210 } 211 212 email, err := db.GetEmailForCode(s.db, code) 213 if err != nil { 214 s.l.Error("failed to get email for code", "error", err) 215 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 216 return 217 } 218 219 did, err := s.createAccountRequest(username, password, email, code) 220 if err != nil { 221 s.l.Error("failed to create account", "error", err) 222 s.pages.Notice(w, "signup-error", err.Error()) 223 return 224 } 225 226 if s.cf == nil { 227 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 228 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 229 return 230 } 231 232 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 233 Type: "TXT", 234 Name: "_atproto." + username, 235 Content: fmt.Sprintf(`"did=%s"`, did), 236 TTL: 6400, 237 Proxied: false, 238 }) 239 if err != nil { 240 s.l.Error("failed to create DNS record", "error", err) 241 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 242 return 243 } 244 245 err = db.AddEmail(s.db, models.Email{ 246 Did: did, 247 Address: email, 248 Verified: true, 249 Primary: true, 250 }) 251 if err != nil { 252 s.l.Error("failed to add email", "error", err) 253 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 254 return 255 } 256 257 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 258 <a class="underline text-black dark:text-white" href="/login">login</a> 259 with <code>%s.tngl.sh</code>.`, username)) 260 261 go func() { 262 err := db.DeleteInflightSignup(s.db, email) 263 if err != nil { 264 s.l.Error("failed to delete inflight signup", "error", err) 265 } 266 }() 267 return 268 } 269} 270 271type turnstileResponse struct { 272 Success bool `json:"success"` 273 ErrorCodes []string `json:"error-codes,omitempty"` 274 ChallengeTs string `json:"challenge_ts,omitempty"` 275 Hostname string `json:"hostname,omitempty"` 276} 277 278func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error { 279 if cfToken == "" { 280 return errors.New("captcha token is empty") 281 } 282 283 if s.config.Cloudflare.TurnstileSecretKey == "" { 284 return errors.New("turnstile secret key not configured") 285 } 286 287 data := url.Values{} 288 data.Set("secret", s.config.Cloudflare.TurnstileSecretKey) 289 data.Set("response", cfToken) 290 291 // include the client IP if we have it 292 if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" { 293 data.Set("remoteip", remoteIP) 294 } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" { 295 if ips := strings.Split(remoteIP, ","); len(ips) > 0 { 296 data.Set("remoteip", strings.TrimSpace(ips[0])) 297 } 298 } else { 299 data.Set("remoteip", r.RemoteAddr) 300 } 301 302 resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) 303 if err != nil { 304 return fmt.Errorf("failed to verify turnstile token: %w", err) 305 } 306 defer resp.Body.Close() 307 308 var turnstileResp turnstileResponse 309 if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil { 310 return fmt.Errorf("failed to decode turnstile response: %w", err) 311 } 312 313 if !turnstileResp.Success { 314 s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes) 315 return errors.New("turnstile validation failed") 316 } 317 318 return nil 319}