Monorepo for Tangled
tangled.org
1package signup
2
3import (
4 "bufio"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "net/url"
12 "os"
13 "strings"
14
15 "github.com/go-chi/chi/v5"
16 "github.com/posthog/posthog-go"
17 "tangled.org/core/appview/cloudflare"
18 "tangled.org/core/appview/config"
19 "tangled.org/core/appview/db"
20 "tangled.org/core/appview/email"
21 "tangled.org/core/appview/models"
22 "tangled.org/core/appview/pages"
23 "tangled.org/core/appview/state/userutil"
24 "tangled.org/core/idresolver"
25)
26
27type Signup struct {
28 config *config.Config
29 db *db.DB
30 cf *cloudflare.Client
31 posthog posthog.Client
32 idResolver *idresolver.Resolver
33 pages *pages.Pages
34 l *slog.Logger
35 disallowedNicknames map[string]bool
36}
37
38func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
39 var cf *cloudflare.Client
40 if cfg.Cloudflare.ApiToken != "" {
41 var err error
42 cf, err = cloudflare.New(cfg)
43 if err != nil {
44 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
45 }
46 }
47
48 disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
49
50 return &Signup{
51 config: cfg,
52 db: database,
53 posthog: pc,
54 idResolver: idResolver,
55 cf: cf,
56 pages: pages,
57 l: l,
58 disallowedNicknames: disallowedNicknames,
59 }
60}
61
62func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
63 disallowed := make(map[string]bool)
64
65 if filepath == "" {
66 logger.Warn("no disallowed nicknames file configured")
67 return disallowed
68 }
69
70 file, err := os.Open(filepath)
71 if err != nil {
72 logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
73 return disallowed
74 }
75 defer file.Close()
76
77 scanner := bufio.NewScanner(file)
78 lineNum := 0
79 for scanner.Scan() {
80 lineNum++
81 line := strings.TrimSpace(scanner.Text())
82 if line == "" || strings.HasPrefix(line, "#") {
83 continue // skip empty lines and comments
84 }
85
86 nickname := strings.ToLower(line)
87 if userutil.IsValidSubdomain(nickname) {
88 disallowed[nickname] = true
89 } else {
90 logger.Warn("invalid nickname format in disallowed nicknames file",
91 "file", filepath, "line", lineNum, "nickname", nickname)
92 }
93 }
94
95 if err := scanner.Err(); err != nil {
96 logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
97 }
98
99 logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
100 return disallowed
101}
102
103// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
104func (s *Signup) isNicknameAllowed(nickname string) bool {
105 return !s.disallowedNicknames[strings.ToLower(nickname)]
106}
107
108func (s *Signup) Router() http.Handler {
109 r := chi.NewRouter()
110 r.Get("/", s.signup)
111 r.Post("/", s.signup)
112 r.Get("/complete", s.complete)
113 r.Post("/complete", s.complete)
114
115 return r
116}
117
118func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
119 switch r.Method {
120 case http.MethodGet:
121 emailId := r.URL.Query().Get("id")
122 s.pages.Signup(w, pages.SignupParams{
123 CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey,
124 EmailId: emailId,
125 })
126 case http.MethodPost:
127 if s.cf == nil {
128 http.Error(w, "signup is disabled", http.StatusFailedDependency)
129 return
130 }
131 emailId := r.FormValue("email")
132 cfToken := r.FormValue("cf-turnstile-response")
133
134 noticeId := "signup-msg"
135
136 if err := s.validateCaptcha(cfToken, r); err != nil {
137 s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
138 s.pages.Notice(w, noticeId, "Captcha validation failed.")
139 return
140 }
141
142 if !email.IsValidEmail(emailId) {
143 s.pages.Notice(w, noticeId, "Invalid email address.")
144 return
145 }
146
147 exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
148 if err != nil {
149 s.l.Error("failed to check email existence", "error", err)
150 s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
151 return
152 }
153 if exists {
154 s.pages.Notice(w, noticeId, "Email already exists.")
155 return
156 }
157
158 code, err := s.inviteCodeRequest()
159 if err != nil {
160 s.l.Error("failed to create invite code", "error", err)
161 s.pages.Notice(w, noticeId, "Failed to create invite code.")
162 return
163 }
164
165 em := email.Email{
166 APIKey: s.config.Resend.ApiKey,
167 From: s.config.Resend.SentFrom,
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, emailId)
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 if s.cf == nil {
222 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
223 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
224 return
225 }
226
227 // Execute signup transactionally with rollback capability
228 err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
229 if err != nil {
230 // Error already logged and notice already sent
231 return
232 }
233 }
234}
235
236// executeSignupTransaction performs the signup process transactionally with rollback
237func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
238 // var recordID string
239 var did string
240 var emailAdded bool
241
242 success := false
243 defer func() {
244 if !success {
245 s.l.Info("rolling back signup transaction", "username", username, "did", did)
246
247 // Rollback DNS record
248 // if recordID != "" {
249 // if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
250 // s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
251 // } else {
252 // s.l.Info("successfully rolled back DNS record", "recordID", recordID)
253 // }
254 // }
255
256 // Rollback PDS account
257 if did != "" {
258 if err := s.deleteAccountRequest(did); err != nil {
259 s.l.Error("failed to rollback PDS account", "error", err, "did", did)
260 } else {
261 s.l.Info("successfully rolled back PDS account", "did", did)
262 }
263 }
264
265 // Rollback email from database
266 if emailAdded {
267 if err := db.DeleteEmail(s.db, did, email); err != nil {
268 s.l.Error("failed to rollback email from database", "error", err, "email", email)
269 } else {
270 s.l.Info("successfully rolled back email from database", "email", email)
271 }
272 }
273 }
274 }()
275
276 // step 1: create account in PDS
277 did, err := s.createAccountRequest(username, password, email, code)
278 if err != nil {
279 s.l.Error("failed to create account", "error", err)
280 s.pages.Notice(w, "signup-error", err.Error())
281 return err
282 }
283
284 // XXX: we have a wildcard *.tngl.sh record now
285 // step 2: create DNS record with actual DID
286 // recordID, err = s.cf.CreateDNSRecord(ctx, cloudflare.DNSRecord{
287 // Type: "TXT",
288 // Name: "_atproto." + username,
289 // Content: fmt.Sprintf(`"did=%s"`, did),
290 // TTL: 6400,
291 // Proxied: false,
292 // })
293 // if err != nil {
294 // s.l.Error("failed to create DNS record", "error", err)
295 // s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
296 // return err
297 // }
298
299 // step 3: add email to database
300 err = db.AddEmail(s.db, models.Email{
301 Did: did,
302 Address: email,
303 Verified: true,
304 Primary: true,
305 })
306 if err != nil {
307 s.l.Error("failed to add email", "error", err)
308 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
309 return err
310 }
311 emailAdded = true
312
313 // step 4: auto-claim <username>.<pds-domain> for this user.
314 // All signups through this flow receive a <username>.tngl.sh handle
315 // (or whatever the configured PDS host is), so we claim the matching
316 // sites subdomain on their behalf. This is the only way to obtain a
317 // *.tngl.sh sites domain; it cannot be claimed manually via settings.
318 pdsDomain := strings.TrimPrefix(s.config.Pds.Host, "https://")
319 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
320 autoClaimDomain := username + "." + pdsDomain
321 if err := db.ClaimDomain(s.db, did, autoClaimDomain); err != nil {
322 s.l.Warn("failed to auto-claim sites domain at signup",
323 "domain", autoClaimDomain,
324 "did", did,
325 "error", err,
326 )
327 } else {
328 s.l.Info("auto-claimed sites domain at signup", "domain", autoClaimDomain, "did", did)
329 }
330
331 // if we get here, we've successfully created the account and added the email
332 success = true
333
334 s.pages.NoticeHTMLWithClears(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
335 <a class="underline text-black dark:text-white" href="/login">login</a>
336 with <code>%s.tngl.sh</code>.`, username), "signup-error")
337
338 // clean up inflight signup asynchronously
339 go func() {
340 if err := db.DeleteInflightSignup(s.db, email); err != nil {
341 s.l.Error("failed to delete inflight signup", "error", err)
342 }
343 }()
344
345 return nil
346}
347
348type turnstileResponse struct {
349 Success bool `json:"success"`
350 ErrorCodes []string `json:"error-codes,omitempty"`
351 ChallengeTs string `json:"challenge_ts,omitempty"`
352 Hostname string `json:"hostname,omitempty"`
353}
354
355func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
356 if cfToken == "" {
357 return errors.New("captcha token is empty")
358 }
359
360 if s.config.Cloudflare.Turnstile.SecretKey == "" {
361 return errors.New("turnstile secret key not configured")
362 }
363
364 data := url.Values{}
365 data.Set("secret", s.config.Cloudflare.Turnstile.SecretKey)
366 data.Set("response", cfToken)
367
368 // include the client IP if we have it
369 if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
370 data.Set("remoteip", remoteIP)
371 } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
372 if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
373 data.Set("remoteip", strings.TrimSpace(ips[0]))
374 }
375 } else {
376 data.Set("remoteip", r.RemoteAddr)
377 }
378
379 resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
380 if err != nil {
381 return fmt.Errorf("failed to verify turnstile token: %w", err)
382 }
383 defer resp.Body.Close()
384
385 var turnstileResp turnstileResponse
386 if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
387 return fmt.Errorf("failed to decode turnstile response: %w", err)
388 }
389
390 if !turnstileResp.Success {
391 s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
392 return errors.New("turnstile validation failed")
393 }
394
395 return nil
396}