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}