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}