this repo has no description
1package signup 2 3import ( 4 "bufio" 5 "fmt" 6 "log/slog" 7 "net/http" 8 "os" 9 "strings" 10 11 "github.com/go-chi/chi/v5" 12 "github.com/posthog/posthog-go" 13 "tangled.sh/tangled.sh/core/appview/config" 14 "tangled.sh/tangled.sh/core/appview/db" 15 "tangled.sh/tangled.sh/core/appview/dns" 16 "tangled.sh/tangled.sh/core/appview/email" 17 "tangled.sh/tangled.sh/core/appview/pages" 18 "tangled.sh/tangled.sh/core/appview/state/userutil" 19 "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 "tangled.sh/tangled.sh/core/idresolver" 21) 22 23type Signup struct { 24 config *config.Config 25 db *db.DB 26 cf *dns.Cloudflare 27 posthog posthog.Client 28 xrpc *xrpcclient.Client 29 idResolver *idresolver.Resolver 30 pages *pages.Pages 31 l *slog.Logger 32 disallowedNicknames map[string]bool 33} 34 35func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 var cf *dns.Cloudflare 37 if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 var err error 39 cf, err = dns.NewCloudflare(cfg) 40 if err != nil { 41 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 } 43 } 44 45 disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 47 return &Signup{ 48 config: cfg, 49 db: database, 50 posthog: pc, 51 idResolver: idResolver, 52 cf: cf, 53 pages: pages, 54 l: l, 55 disallowedNicknames: disallowedNicknames, 56 } 57} 58 59func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 disallowed := make(map[string]bool) 61 62 if filepath == "" { 63 logger.Debug("no disallowed nicknames file configured") 64 return disallowed 65 } 66 67 file, err := os.Open(filepath) 68 if err != nil { 69 logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 return disallowed 71 } 72 defer file.Close() 73 74 scanner := bufio.NewScanner(file) 75 lineNum := 0 76 for scanner.Scan() { 77 lineNum++ 78 line := strings.TrimSpace(scanner.Text()) 79 if line == "" || strings.HasPrefix(line, "#") { 80 continue // skip empty lines and comments 81 } 82 83 nickname := strings.ToLower(line) 84 if userutil.IsValidSubdomain(nickname) { 85 disallowed[nickname] = true 86 } else { 87 logger.Warn("invalid nickname format in disallowed nicknames file", 88 "file", filepath, "line", lineNum, "nickname", nickname) 89 } 90 } 91 92 if err := scanner.Err(); err != nil { 93 logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 } 95 96 logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 return disallowed 98} 99 100// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101func (s *Signup) isNicknameAllowed(nickname string) bool { 102 return !s.disallowedNicknames[strings.ToLower(nickname)] 103} 104 105func (s *Signup) Router() http.Handler { 106 r := chi.NewRouter() 107 r.Post("/", s.signup) 108 r.Get("/complete", s.complete) 109 r.Post("/complete", s.complete) 110 111 return r 112} 113 114func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 115 if s.cf == nil { 116 http.Error(w, "signup is disabled", http.StatusFailedDependency) 117 } 118 emailId := r.FormValue("email") 119 120 if !email.IsValidEmail(emailId) { 121 s.pages.Notice(w, "login-msg", "Invalid email address.") 122 return 123 } 124 125 exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 126 if err != nil { 127 s.l.Error("failed to check email existence", "error", err) 128 s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 129 return 130 } 131 if exists { 132 s.pages.Notice(w, "login-msg", "Email already exists.") 133 return 134 } 135 136 code, err := s.inviteCodeRequest() 137 if err != nil { 138 s.l.Error("failed to create invite code", "error", err) 139 s.pages.Notice(w, "login-msg", "Failed to create invite code.") 140 return 141 } 142 143 em := email.Email{ 144 APIKey: s.config.Resend.ApiKey, 145 From: s.config.Resend.SentFrom, 146 To: emailId, 147 Subject: "Verify your Tangled account", 148 Text: `Copy and paste this code below to verify your account on Tangled. 149 ` + code, 150 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 151<p><code>` + code + `</code></p>`, 152 } 153 154 err = email.SendEmail(em) 155 if err != nil { 156 s.l.Error("failed to send email", "error", err) 157 s.pages.Notice(w, "login-msg", "Failed to send email.") 158 return 159 } 160 err = db.AddInflightSignup(s.db, db.InflightSignup{ 161 Email: emailId, 162 InviteCode: code, 163 }) 164 if err != nil { 165 s.l.Error("failed to add inflight signup", "error", err) 166 s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 167 return 168 } 169 170 s.pages.HxRedirect(w, "/signup/complete") 171} 172 173func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 174 switch r.Method { 175 case http.MethodGet: 176 s.pages.CompleteSignup(w, pages.SignupParams{}) 177 case http.MethodPost: 178 username := r.FormValue("username") 179 password := r.FormValue("password") 180 code := r.FormValue("code") 181 182 if !userutil.IsValidSubdomain(username) { 183 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.") 184 return 185 } 186 187 if !s.isNicknameAllowed(username) { 188 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 189 return 190 } 191 192 email, err := db.GetEmailForCode(s.db, code) 193 if err != nil { 194 s.l.Error("failed to get email for code", "error", err) 195 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 196 return 197 } 198 199 did, err := s.createAccountRequest(username, password, email, code) 200 if err != nil { 201 s.l.Error("failed to create account", "error", err) 202 s.pages.Notice(w, "signup-error", err.Error()) 203 return 204 } 205 206 if s.cf == nil { 207 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 208 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 209 return 210 } 211 212 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 213 Type: "TXT", 214 Name: "_atproto." + username, 215 Content: "did=" + did, 216 TTL: 6400, 217 Proxied: false, 218 }) 219 if err != nil { 220 s.l.Error("failed to create DNS record", "error", err) 221 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 222 return 223 } 224 225 err = db.AddEmail(s.db, db.Email{ 226 Did: did, 227 Address: email, 228 Verified: true, 229 Primary: true, 230 }) 231 if err != nil { 232 s.l.Error("failed to add email", "error", err) 233 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 234 return 235 } 236 237 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 238 <a class="underline text-black dark:text-white" href="/login">login</a> 239 with <code>%s.tngl.sh</code>.`, username)) 240 241 go func() { 242 err := db.DeleteInflightSignup(s.db, email) 243 if err != nil { 244 s.l.Error("failed to delete inflight signup", "error", err) 245 } 246 }() 247 return 248 } 249}