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}