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