forked from tangled.org/core
this repo has no description
at master 7.3 kB view raw
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.Get("/", s.signup) 108 r.Post("/", s.signup) 109 r.Get("/complete", s.complete) 110 r.Post("/complete", s.complete) 111 112 return r 113} 114 115func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 switch r.Method { 117 case http.MethodGet: 118 s.pages.Signup(w) 119 case http.MethodPost: 120 if s.cf == nil { 121 http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 } 123 emailId := r.FormValue("email") 124 125 noticeId := "signup-msg" 126 if !email.IsValidEmail(emailId) { 127 s.pages.Notice(w, noticeId, "Invalid email address.") 128 return 129 } 130 131 exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 if err != nil { 133 s.l.Error("failed to check email existence", "error", err) 134 s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 return 136 } 137 if exists { 138 s.pages.Notice(w, noticeId, "Email already exists.") 139 return 140 } 141 142 code, err := s.inviteCodeRequest() 143 if err != nil { 144 s.l.Error("failed to create invite code", "error", err) 145 s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 return 147 } 148 149 em := email.Email{ 150 APIKey: s.config.Resend.ApiKey, 151 From: s.config.Resend.SentFrom, 152 To: emailId, 153 Subject: "Verify your Tangled account", 154 Text: `Copy and paste this code below to verify your account on Tangled. 155 ` + code, 156 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 157<p><code>` + code + `</code></p>`, 158 } 159 160 err = email.SendEmail(em) 161 if err != nil { 162 s.l.Error("failed to send email", "error", err) 163 s.pages.Notice(w, noticeId, "Failed to send email.") 164 return 165 } 166 err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 Email: emailId, 168 InviteCode: code, 169 }) 170 if err != nil { 171 s.l.Error("failed to add inflight signup", "error", err) 172 s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 return 174 } 175 176 s.pages.HxRedirect(w, "/signup/complete") 177 } 178} 179 180func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 181 switch r.Method { 182 case http.MethodGet: 183 s.pages.CompleteSignup(w) 184 case http.MethodPost: 185 username := r.FormValue("username") 186 password := r.FormValue("password") 187 code := r.FormValue("code") 188 189 if !userutil.IsValidSubdomain(username) { 190 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.") 191 return 192 } 193 194 if !s.isNicknameAllowed(username) { 195 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 return 197 } 198 199 email, err := db.GetEmailForCode(s.db, code) 200 if err != nil { 201 s.l.Error("failed to get email for code", "error", err) 202 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 203 return 204 } 205 206 did, err := s.createAccountRequest(username, password, email, code) 207 if err != nil { 208 s.l.Error("failed to create account", "error", err) 209 s.pages.Notice(w, "signup-error", err.Error()) 210 return 211 } 212 213 if s.cf == nil { 214 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 return 217 } 218 219 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 Type: "TXT", 221 Name: "_atproto." + username, 222 Content: fmt.Sprintf(`"did=%s"`, did), 223 TTL: 6400, 224 Proxied: false, 225 }) 226 if err != nil { 227 s.l.Error("failed to create DNS record", "error", err) 228 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 return 230 } 231 232 err = db.AddEmail(s.db, db.Email{ 233 Did: did, 234 Address: email, 235 Verified: true, 236 Primary: true, 237 }) 238 if err != nil { 239 s.l.Error("failed to add email", "error", err) 240 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 241 return 242 } 243 244 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 245 <a class="underline text-black dark:text-white" href="/login">login</a> 246 with <code>%s.tngl.sh</code>.`, username)) 247 248 go func() { 249 err := db.DeleteInflightSignup(s.db, email) 250 if err != nil { 251 s.l.Error("failed to delete inflight signup", "error", err) 252 } 253 }() 254 return 255 } 256}