this repo has no description
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.Post("/", s.signup)
108 r.Get("/complete", s.complete)
109 r.Post("/complete", s.complete)
110
111 return r
112}
113
114func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
115 if s.cf == nil {
116 http.Error(w, "signup is disabled", http.StatusFailedDependency)
117 }
118 emailId := r.FormValue("email")
119
120 if !email.IsValidEmail(emailId) {
121 s.pages.Notice(w, "login-msg", "Invalid email address.")
122 return
123 }
124
125 exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
126 if err != nil {
127 s.l.Error("failed to check email existence", "error", err)
128 s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.")
129 return
130 }
131 if exists {
132 s.pages.Notice(w, "login-msg", "Email already exists.")
133 return
134 }
135
136 code, err := s.inviteCodeRequest()
137 if err != nil {
138 s.l.Error("failed to create invite code", "error", err)
139 s.pages.Notice(w, "login-msg", "Failed to create invite code.")
140 return
141 }
142
143 em := email.Email{
144 APIKey: s.config.Resend.ApiKey,
145 From: s.config.Resend.SentFrom,
146 To: emailId,
147 Subject: "Verify your Tangled account",
148 Text: `Copy and paste this code below to verify your account on Tangled.
149 ` + code,
150 Html: `<p>Copy and paste this code below to verify your account on Tangled.</p>
151<p><code>` + code + `</code></p>`,
152 }
153
154 err = email.SendEmail(em)
155 if err != nil {
156 s.l.Error("failed to send email", "error", err)
157 s.pages.Notice(w, "login-msg", "Failed to send email.")
158 return
159 }
160 err = db.AddInflightSignup(s.db, db.InflightSignup{
161 Email: emailId,
162 InviteCode: code,
163 })
164 if err != nil {
165 s.l.Error("failed to add inflight signup", "error", err)
166 s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.")
167 return
168 }
169
170 s.pages.HxRedirect(w, "/signup/complete")
171}
172
173func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
174 switch r.Method {
175 case http.MethodGet:
176 s.pages.CompleteSignup(w, pages.SignupParams{})
177 case http.MethodPost:
178 username := r.FormValue("username")
179 password := r.FormValue("password")
180 code := r.FormValue("code")
181
182 if !userutil.IsValidSubdomain(username) {
183 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.")
184 return
185 }
186
187 if !s.isNicknameAllowed(username) {
188 s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
189 return
190 }
191
192 email, err := db.GetEmailForCode(s.db, code)
193 if err != nil {
194 s.l.Error("failed to get email for code", "error", err)
195 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
196 return
197 }
198
199 did, err := s.createAccountRequest(username, password, email, code)
200 if err != nil {
201 s.l.Error("failed to create account", "error", err)
202 s.pages.Notice(w, "signup-error", err.Error())
203 return
204 }
205
206 if s.cf == nil {
207 s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
208 s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
209 return
210 }
211
212 err = s.cf.CreateDNSRecord(r.Context(), dns.Record{
213 Type: "TXT",
214 Name: "_atproto." + username,
215 Content: "did=" + did,
216 TTL: 6400,
217 Proxied: false,
218 })
219 if err != nil {
220 s.l.Error("failed to create DNS record", "error", err)
221 s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
222 return
223 }
224
225 err = db.AddEmail(s.db, db.Email{
226 Did: did,
227 Address: email,
228 Verified: true,
229 Primary: true,
230 })
231 if err != nil {
232 s.l.Error("failed to add email", "error", err)
233 s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
234 return
235 }
236
237 s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now
238 <a class="underline text-black dark:text-white" href="/login">login</a>
239 with <code>%s.tngl.sh</code>.`, username))
240
241 go func() {
242 err := db.DeleteInflightSignup(s.db, email)
243 if err != nil {
244 s.l.Error("failed to delete inflight signup", "error", err)
245 }
246 }()
247 return
248 }
249}