This repository has no description
1package xrpc
2
3import (
4 "bytes"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "strings"
12
13 "tangled.org/core/api/tangled"
14 "tangled.org/core/appview/db"
15 "tangled.org/core/appview/email"
16 "tangled.org/core/appview/models"
17 "tangled.org/core/appview/state/userutil"
18)
19
20func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) {
21 l := x.Logger.With("handler", "AccountBeginSignup")
22
23 // signup is gated on cloudflare being configured, mirroring appview/signup
24 if x.Cloudflare == nil {
25 writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency)
26 return
27 }
28
29 var input tangled.TempAccountBeginSignup_Input
30 if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
31 writeError(w, errBadRequestBody, http.StatusBadRequest)
32 return
33 }
34
35 if err := x.validateTurnstile(input.TurnstileToken, r); err != nil {
36 l.Warn("turnstile validation failed", "err", err, "email", input.Email)
37 writeError(w, xrpcErrorTag("InvalidTurnstileToken", "captcha validation failed"), http.StatusForbidden)
38 return
39 }
40
41 if !email.IsValidEmail(input.Email) {
42 writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest)
43 return
44 }
45
46 exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email)
47 if err != nil {
48 l.Error("failed to check email existence", "err", err)
49 writeError(w, errInternal, http.StatusInternalServerError)
50 return
51 }
52 if exists {
53 writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict)
54 return
55 }
56
57 // the verification code is an invite code minted by the PDS
58 code, err := x.pdsCreateInviteCode()
59 if err != nil {
60 l.Error("failed to create invite code", "err", err)
61 writeError(w, errUpstream, http.StatusBadGateway)
62 return
63 }
64
65 em := email.Email{
66 APIKey: x.Config.Resend.ApiKey,
67 From: x.Config.Resend.SentFrom,
68 To: input.Email,
69 Subject: "Verify your Tangled account",
70 Text: "Copy and paste this code below to verify your account on Tangled.\n" + code,
71 Html: "<p>Copy and paste this code below to verify your account on Tangled.</p>\n<p><code>" + code + "</code></p>",
72 }
73 if err := email.SendEmail(em); err != nil {
74 l.Error("failed to send verification email", "err", err)
75 writeError(w, errInternal, http.StatusInternalServerError)
76 return
77 }
78
79 if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil {
80 l.Error("failed to add inflight signup", "err", err)
81 writeError(w, errInternal, http.StatusInternalServerError)
82 return
83 }
84
85 w.WriteHeader(http.StatusOK)
86}
87
88func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) {
89 l := x.Logger.With("handler", "AccountCompleteSignup")
90
91 if x.Cloudflare == nil {
92 writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency)
93 return
94 }
95
96 var input tangled.TempAccountCompleteSignup_Input
97 if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
98 writeError(w, errBadRequestBody, http.StatusBadRequest)
99 return
100 }
101
102 if !userutil.IsValidSubdomain(input.Username) {
103 writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest)
104 return
105 }
106 if x.DisallowedNicknames[strings.ToLower(input.Username)] {
107 writeError(w, xrpcErrorTag("UsernameUnavailable", "this username is not available"), http.StatusConflict)
108 return
109 }
110
111 emailAddr, err := db.GetEmailForCode(x.DB, input.Code)
112 if err != nil {
113 l.Error("failed to get email for code", "err", err)
114 writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest)
115 return
116 }
117
118 did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code)
119 if err != nil {
120 l.Error("failed to provision account", "err", err)
121 writeError(w, errUpstream, http.StatusBadGateway)
122 return
123 }
124
125 go func() {
126 if err := db.DeleteInflightSignup(x.DB, emailAddr); err != nil {
127 l.Error("failed to delete inflight signup", "err", err)
128 }
129 }()
130
131 x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle})
132}
133
134// provisionAccount creates the pds account, records its verified primary email,
135// and auto-claims the sites subdomain, rolling back on failure.
136func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) {
137 success := false
138 emailAdded := false
139 defer func() {
140 if success {
141 return
142 }
143 x.Logger.Info("rolling back signup", "username", username, "did", did)
144 if did != "" {
145 if derr := x.pdsDeleteAccount(did); derr != nil {
146 x.Logger.Error("failed to roll back PDS account", "err", derr, "did", did)
147 }
148 }
149 if emailAdded {
150 if derr := db.DeleteEmail(x.DB, did, emailAddr); derr != nil {
151 x.Logger.Error("failed to roll back email row", "err", derr, "email", emailAddr)
152 }
153 }
154 }()
155
156 did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code)
157 if err != nil {
158 return "", "", err
159 }
160
161 if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil {
162 return "", "", err
163 }
164 emailAdded = true
165
166 // auto-claim <username>.<pds domain>: the only way to get a pds-domain site
167 pdsDomain := strings.TrimPrefix(x.Config.Pds.Host, "https://")
168 pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
169 autoClaim := username + "." + pdsDomain
170 if err := db.ClaimDomain(x.DB, did, autoClaim); err != nil {
171 x.Logger.Warn("failed to auto-claim sites domain at signup", "domain", autoClaim, "did", did, "err", err)
172 }
173
174 success = true
175 return did, handle, nil
176}
177
178func (x *Xrpc) validateTurnstile(token string, r *http.Request) error {
179 if token == "" {
180 return errors.New("captcha token is empty")
181 }
182 if x.Config.Cloudflare.Turnstile.SecretKey == "" {
183 return errors.New("turnstile secret key not configured")
184 }
185
186 data := url.Values{}
187 data.Set("secret", x.Config.Cloudflare.Turnstile.SecretKey)
188 data.Set("response", token)
189 if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
190 data.Set("remoteip", ip)
191 } else if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
192 if parts := strings.Split(fwd, ","); len(parts) > 0 {
193 data.Set("remoteip", strings.TrimSpace(parts[0]))
194 }
195 } else {
196 data.Set("remoteip", r.RemoteAddr)
197 }
198
199 resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
200 if err != nil {
201 return fmt.Errorf("failed to verify turnstile token: %w", err)
202 }
203 defer resp.Body.Close()
204
205 var tr struct {
206 Success bool `json:"success"`
207 ErrorCodes []string `json:"error-codes,omitempty"`
208 }
209 if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
210 return fmt.Errorf("failed to decode turnstile response: %w", err)
211 }
212 if !tr.Success {
213 return fmt.Errorf("turnstile validation failed: %v", tr.ErrorCodes)
214 }
215 return nil
216}
217
218// pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via
219// basic auth. these are unauth'd or admin-authed, so they use raw http.
220func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) {
221 jsonData, err := json.Marshal(body)
222 if err != nil {
223 return nil, err
224 }
225 u := fmt.Sprintf("%s/xrpc/%s", x.Config.Pds.Host, endpoint)
226 req, err := http.NewRequest(http.MethodPost, u, bytes.NewBuffer(jsonData))
227 if err != nil {
228 return nil, err
229 }
230 req.Header.Set("Content-Type", "application/json")
231 if useAuth {
232 req.SetBasicAuth("admin", x.Config.Pds.AdminSecret)
233 }
234 return http.DefaultClient.Do(req)
235}
236
237func pdsError(resp *http.Response, action string) error {
238 var e struct {
239 Error string `json:"error"`
240 Message string `json:"message"`
241 }
242 b, _ := io.ReadAll(resp.Body)
243 if err := json.Unmarshal(b, &e); err == nil && e.Message != "" {
244 return fmt.Errorf("failed to %s: %s - %s", action, e.Error, e.Message)
245 }
246 return fmt.Errorf("failed to %s, status %d", action, resp.StatusCode)
247}
248
249func (x *Xrpc) pdsCreateInviteCode() (string, error) {
250 resp, err := x.pdsRequest("com.atproto.server.createInviteCode", map[string]any{"useCount": 1}, true)
251 if err != nil {
252 return "", err
253 }
254 defer resp.Body.Close()
255 if resp.StatusCode != http.StatusOK {
256 return "", pdsError(resp, "create invite code")
257 }
258 var result map[string]string
259 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
260 return "", fmt.Errorf("failed to decode invite code response: %w", err)
261 }
262 return result["code"], nil
263}
264
265func (x *Xrpc) pdsCreateAccount(username, password, emailAddr, code string) (did, handle string, err error) {
266 parsed, err := url.Parse(x.Config.Pds.Host)
267 if err != nil {
268 return "", "", fmt.Errorf("invalid PDS host URL: %w", err)
269 }
270 handle = fmt.Sprintf("%s.%s", username, parsed.Hostname())
271
272 body := map[string]string{
273 "email": emailAddr,
274 "handle": handle,
275 "password": password,
276 "inviteCode": code,
277 }
278 resp, err := x.pdsRequest("com.atproto.server.createAccount", body, false)
279 if err != nil {
280 return "", "", err
281 }
282 defer resp.Body.Close()
283 if resp.StatusCode != http.StatusOK {
284 return "", "", pdsError(resp, "create account")
285 }
286
287 var result struct {
288 DID string `json:"did"`
289 }
290 if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
291 return "", "", fmt.Errorf("failed to decode create account response: %w", err)
292 }
293 return result.DID, handle, nil
294}
295
296func (x *Xrpc) pdsDeleteAccount(did string) error {
297 resp, err := x.pdsRequest("com.atproto.admin.deleteAccount", map[string]string{"did": did}, true)
298 if err != nil {
299 return err
300 }
301 defer resp.Body.Close()
302 if resp.StatusCode != http.StatusOK {
303 return pdsError(resp, "delete account")
304 }
305 return nil
306}