Monorepo for Tangled
tangled.org
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/idresolver"
24)
25
26type Signup struct {
27 config *config.Config
28 db *db.DB
29 cf *dns.Cloudflare
30 posthog posthog.Client
31 idResolver *idresolver.Resolver
32 pages *pages.Pages
33 l *slog.Logger
34 disallowedNicknames map[string]bool
35}
36
37func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
38 var cf *dns.Cloudflare
39 if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" {
40 var err error
41 cf, err = dns.NewCloudflare(cfg)
42 if err != nil {
43 l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
44 }
45 }
46
47 disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
48
49 return &Signup{
50 config: cfg,
51 db: database,
52 posthog: pc,
53 idResolver: idResolver,
54 cf: cf,
55 pages: pages,
56 l: l,
57 disallowedNicknames: disallowedNicknames,
58 }
59}
60
61func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool {
62 disallowed := make(map[string]bool)
63
64 if filepath == "" {
65 logger.Debug("no disallowed nicknames file configured")
66 return disallowed
67 }
68
69 file, err := os.Open(filepath)
70 if err != nil {
71 logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err)
72 return disallowed
73 }
74 defer file.Close()
75
76 scanner := bufio.NewScanner(file)
77 lineNum := 0
78 for scanner.Scan() {
79 lineNum++
80 line := strings.TrimSpace(scanner.Text())
81 if line == "" || strings.HasPrefix(line, "#") {
82 continue // skip empty lines and comments
83 }
84
85 nickname := strings.ToLower(line)
86 if userutil.IsValidSubdomain(nickname) {
87 disallowed[nickname] = true
88 } else {
89 logger.Warn("invalid nickname format in disallowed nicknames file",
90 "file", filepath, "line", lineNum, "nickname", nickname)
91 }
92 }
93
94 if err := scanner.Err(); err != nil {
95 logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err)
96 }
97
98 logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath)
99 return disallowed
100}
101
102// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
103func (s *Signup) isNicknameAllowed(nickname string) bool {
104 return !s.disallowedNicknames[strings.ToLower(nickname)]
105}
106
107func (s *Signup) Router() http.Handler {
108 r := chi.NewRouter()
109 r.Get("/", s.signup)
110 r.Post("/", s.signup)
111 r.Get("/complete", s.complete)
112 r.Post("/complete", s.complete)
113
114 return r
115}
116
117func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
118 switch r.Method {
119 case http.MethodGet:
120 s.pages.Signup(w, pages.SignupParams{
121 CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
122 })
123 case http.MethodPost:
124 if s.cf == nil {
125 http.Error(w, "signup is disabled", http.StatusFailedDependency)
126 return
127 }
128 emailId := r.FormValue("email")
129 cfToken := r.FormValue("cf-turnstile-response")
130
131 noticeId := "signup-msg"
132
133 if err := s.validateCaptcha(cfToken, r); err != nil {
134 s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
135 s.pages.Notice(w, noticeId, "Captcha validation failed.")
136 return
137 }
138
139 if !email.IsValidEmail(emailId) {
140 s.pages.Notice(w, noticeId, "Invalid email address.")
141 return
142 }
143
144 exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
145 if err != nil {
146 s.l.Error("failed to check email existence", "error", err)
147 s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
148 return
149 }
150 if exists {
151 s.pages.Notice(w, noticeId, "Email already exists.")
152 return
153 }
154
155 code, err := s.inviteCodeRequest()
156 if err != nil {
157 s.l.Error("failed to create invite code", "error", err)
158 s.pages.Notice(w, noticeId, "Failed to create invite code.")
159 return
160 }
161
162 em := email.Email{
163 APIKey: s.config.Resend.ApiKey,
164 From: s.config.Resend.SentFrom,
165 To: emailId,
166 Subject: "Verify your Tangled account",
167 Text: `Copy and paste this code below to verify your account on Tangled.
168 ` + code,
169 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
170<p><code>` + code + `</code></p>`,
171 }
172
173 err = email.SendEmail(em)
174 if err != nil {
175 s.l.Error("failed to send email", "error", err)
176 s.pages.Notice(w, noticeId, "Failed to send email.")
177 return
178 }
179 err = db.AddInflightSignup(s.db, models.InflightSignup{
180 Email: emailId,
181 InviteCode: code,
182 })
183 if err != nil {
184 s.l.Error("failed to add inflight signup", "error", err)
185 s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
186 return
187 }
188
189 s.pages.HxRedirect(w, "/signup/complete")
190 }
191}
192
193func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
194 switch r.Method {
195 case http.MethodGet:
196 s.pages.CompleteSignup(w)
197 case http.MethodPost:
198 username := r.FormValue("username")
199 password := r.FormValue("password")
200 code := r.FormValue("code")
201
202 if !userutil.IsValidSubdomain(username) {
203 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.")
204 return
205 }
206
207 if !s.isNicknameAllowed(username) {
208 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
209 return
210 }
211
212 email, err := db.GetEmailForCode(s.db, code)
213 if err != nil {
214 s.l.Error("failed to get email for code", "error", err)
215 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
216 return
217 }
218
219 did, err := s.createAccountRequest(username, password, email, code)
220 if err != nil {
221 s.l.Error("failed to create account", "error", err)
222 s.pages.Notice(w, "signup-error", err.Error())
223 return
224 }
225
226 if s.cf == nil {
227 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
228 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
229 return
230 }
231
232 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
233 Type: "TXT",
234 Name: "_atproto." + username,
235 Content: fmt.Sprintf(`"did=%s"`, did),
236 TTL: 6400,
237 Proxied: false,
238 })
239 if err != nil {
240 s.l.Error("failed to create DNS record", "error", err)
241 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
242 return
243 }
244
245 err = db.AddEmail(s.db, models.Email{
246 Did: did,
247 Address: email,
248 Verified: true,
249 Primary: true,
250 })
251 if err != nil {
252 s.l.Error("failed to add email", "error", err)
253 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
254 return
255 }
256
257 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
258 <a class="underline text-black dark:text-white" href="/login">login</a>
259 with <code>%s.tngl.sh</code>.`, username))
260
261 go func() {
262 err := db.DeleteInflightSignup(s.db, email)
263 if err != nil {
264 s.l.Error("failed to delete inflight signup", "error", err)
265 }
266 }()
267 return
268 }
269}
270
271type turnstileResponse struct {
272 Success bool `json:"success"`
273 ErrorCodes []string `json:"error-codes,omitempty"`
274 ChallengeTs string `json:"challenge_ts,omitempty"`
275 Hostname string `json:"hostname,omitempty"`
276}
277
278func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
279 if cfToken == "" {
280 return errors.New("captcha token is empty")
281 }
282
283 if s.config.Cloudflare.TurnstileSecretKey == "" {
284 return errors.New("turnstile secret key not configured")
285 }
286
287 data := url.Values{}
288 data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
289 data.Set("response", cfToken)
290
291 // include the client IP if we have it
292 if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
293 data.Set("remoteip", remoteIP)
294 } else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
295 if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
296 data.Set("remoteip", strings.TrimSpace(ips[0]))
297 }
298 } else {
299 data.Set("remoteip", r.RemoteAddr)
300 }
301
302 resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
303 if err != nil {
304 return fmt.Errorf("failed to verify turnstile token: %w", err)
305 }
306 defer resp.Body.Close()
307
308 var turnstileResp turnstileResponse
309 if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
310 return fmt.Errorf("failed to decode turnstile response: %w", err)
311 }
312
313 if !turnstileResp.Success {
314 s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
315 return errors.New("turnstile validation failed")
316 }
317
318 return nil
319}