This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / xrpc / signup.go
9.7 kB 306 lines
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}