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