package xrpc import ( "bytes" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/email" "tangled.org/core/appview/models" "tangled.org/core/appview/state/userutil" ) func (x *Xrpc) AccountBeginSignup(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "AccountBeginSignup") // signup is gated on cloudflare being configured, mirroring appview/signup if x.Cloudflare == nil { writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) return } var input tangled.TempAccountBeginSignup_Input if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, errBadRequestBody, http.StatusBadRequest) return } if err := x.validateTurnstile(input.TurnstileToken, r); err != nil { l.Warn("turnstile validation failed", "err", err, "email", input.Email) writeError(w, xrpcErrorTag("InvalidTurnstileToken", "captcha validation failed"), http.StatusForbidden) return } if !email.IsValidEmail(input.Email) { writeError(w, xrpcErrorTag("InvalidEmail", "invalid email address"), http.StatusBadRequest) return } exists, err := db.CheckEmailExistsAtAll(x.DB, input.Email) if err != nil { l.Error("failed to check email existence", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } if exists { writeError(w, xrpcErrorTag("EmailAlreadyRegistered", "an account already exists for this email"), http.StatusConflict) return } // the verification code is an invite code minted by the PDS code, err := x.pdsCreateInviteCode() if err != nil { l.Error("failed to create invite code", "err", err) writeError(w, errUpstream, http.StatusBadGateway) return } em := email.Email{ APIKey: x.Config.Resend.ApiKey, From: x.Config.Resend.SentFrom, To: input.Email, Subject: "Verify your Tangled account", Text: "Copy and paste this code below to verify your account on Tangled.\n" + code, Html: "

Copy and paste this code below to verify your account on Tangled.

\n

" + code + "

", } if err := email.SendEmail(em); err != nil { l.Error("failed to send verification email", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } if err := db.AddInflightSignup(x.DB, models.InflightSignup{Email: input.Email, InviteCode: code}); err != nil { l.Error("failed to add inflight signup", "err", err) writeError(w, errInternal, http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } func (x *Xrpc) AccountCompleteSignup(w http.ResponseWriter, r *http.Request) { l := x.Logger.With("handler", "AccountCompleteSignup") if x.Cloudflare == nil { writeError(w, xrpcErrorTag("SignupDisabled", "signup is not currently enabled"), http.StatusFailedDependency) return } var input tangled.TempAccountCompleteSignup_Input if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, errBadRequestBody, http.StatusBadRequest) return } if !userutil.IsValidSubdomain(input.Username) { writeError(w, xrpcErrorTag("InvalidUsername", "invalid username"), http.StatusBadRequest) return } if x.DisallowedNicknames[strings.ToLower(input.Username)] { writeError(w, xrpcErrorTag("UsernameUnavailable", "this username is not available"), http.StatusConflict) return } emailAddr, err := db.GetEmailForCode(x.DB, input.Code) if err != nil { l.Error("failed to get email for code", "err", err) writeError(w, xrpcErrorTag("InvalidCode", "invalid or expired verification code"), http.StatusBadRequest) return } did, handle, err := x.provisionAccount(input.Username, input.Password, emailAddr, input.Code) if err != nil { l.Error("failed to provision account", "err", err) writeError(w, errUpstream, http.StatusBadGateway) return } go func() { if err := db.DeleteInflightSignup(x.DB, emailAddr); err != nil { l.Error("failed to delete inflight signup", "err", err) } }() x.writeJSON(w, &tangled.TempAccountCompleteSignup_Output{Did: did, Handle: handle}) } // provisionAccount creates the pds account, records its verified primary email, // and auto-claims the sites subdomain, rolling back on failure. func (x *Xrpc) provisionAccount(username, password, emailAddr, code string) (did, handle string, err error) { success := false emailAdded := false defer func() { if success { return } x.Logger.Info("rolling back signup", "username", username, "did", did) if did != "" { if derr := x.pdsDeleteAccount(did); derr != nil { x.Logger.Error("failed to roll back PDS account", "err", derr, "did", did) } } if emailAdded { if derr := db.DeleteEmail(x.DB, did, emailAddr); derr != nil { x.Logger.Error("failed to roll back email row", "err", derr, "email", emailAddr) } } }() did, handle, err = x.pdsCreateAccount(username, password, emailAddr, code) if err != nil { return "", "", err } if err = db.AddEmail(x.DB, models.Email{Did: did, Address: emailAddr, Verified: true, Primary: true}); err != nil { return "", "", err } emailAdded = true // auto-claim .: the only way to get a pds-domain site pdsDomain := strings.TrimPrefix(x.Config.Pds.Host, "https://") pdsDomain = strings.TrimPrefix(pdsDomain, "http://") autoClaim := username + "." + pdsDomain if err := db.ClaimDomain(x.DB, did, autoClaim); err != nil { x.Logger.Warn("failed to auto-claim sites domain at signup", "domain", autoClaim, "did", did, "err", err) } success = true return did, handle, nil } func (x *Xrpc) validateTurnstile(token string, r *http.Request) error { if token == "" { return errors.New("captcha token is empty") } if x.Config.Cloudflare.Turnstile.SecretKey == "" { return errors.New("turnstile secret key not configured") } data := url.Values{} data.Set("secret", x.Config.Cloudflare.Turnstile.SecretKey) data.Set("response", token) if ip := r.Header.Get("CF-Connecting-IP"); ip != "" { data.Set("remoteip", ip) } else if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { if parts := strings.Split(fwd, ","); len(parts) > 0 { data.Set("remoteip", strings.TrimSpace(parts[0])) } } else { data.Set("remoteip", r.RemoteAddr) } resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data) if err != nil { return fmt.Errorf("failed to verify turnstile token: %w", err) } defer resp.Body.Close() var tr struct { Success bool `json:"success"` ErrorCodes []string `json:"error-codes,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil { return fmt.Errorf("failed to decode turnstile response: %w", err) } if !tr.Success { return fmt.Errorf("turnstile validation failed: %v", tr.ErrorCodes) } return nil } // pdsRequest posts to a pds xrpc endpoint; useAuth sends the admin secret via // basic auth. these are unauth'd or admin-authed, so they use raw http. func (x *Xrpc) pdsRequest(endpoint string, body any, useAuth bool) (*http.Response, error) { jsonData, err := json.Marshal(body) if err != nil { return nil, err } u := fmt.Sprintf("%s/xrpc/%s", x.Config.Pds.Host, endpoint) req, err := http.NewRequest(http.MethodPost, u, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if useAuth { req.SetBasicAuth("admin", x.Config.Pds.AdminSecret) } return http.DefaultClient.Do(req) } func pdsError(resp *http.Response, action string) error { var e struct { Error string `json:"error"` Message string `json:"message"` } b, _ := io.ReadAll(resp.Body) if err := json.Unmarshal(b, &e); err == nil && e.Message != "" { return fmt.Errorf("failed to %s: %s - %s", action, e.Error, e.Message) } return fmt.Errorf("failed to %s, status %d", action, resp.StatusCode) } func (x *Xrpc) pdsCreateInviteCode() (string, error) { resp, err := x.pdsRequest("com.atproto.server.createInviteCode", map[string]any{"useCount": 1}, true) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", pdsError(resp, "create invite code") } var result map[string]string if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode invite code response: %w", err) } return result["code"], nil } func (x *Xrpc) pdsCreateAccount(username, password, emailAddr, code string) (did, handle string, err error) { parsed, err := url.Parse(x.Config.Pds.Host) if err != nil { return "", "", fmt.Errorf("invalid PDS host URL: %w", err) } handle = fmt.Sprintf("%s.%s", username, parsed.Hostname()) body := map[string]string{ "email": emailAddr, "handle": handle, "password": password, "inviteCode": code, } resp, err := x.pdsRequest("com.atproto.server.createAccount", body, false) if err != nil { return "", "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", pdsError(resp, "create account") } var result struct { DID string `json:"did"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", "", fmt.Errorf("failed to decode create account response: %w", err) } return result.DID, handle, nil } func (x *Xrpc) pdsDeleteAccount(did string) error { resp, err := x.pdsRequest("com.atproto.admin.deleteAccount", map[string]string{"did": did}, true) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return pdsError(resp, "delete account") } return nil }