package signup
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/posthog/posthog-go"
"tangled.org/core/appview/cloudflare"
"tangled.org/core/appview/config"
"tangled.org/core/appview/db"
"tangled.org/core/appview/email"
"tangled.org/core/appview/models"
"tangled.org/core/appview/pages"
"tangled.org/core/appview/state/userutil"
"tangled.org/core/idresolver"
)
type Signup struct {
config *config.Config
db *db.DB
cf *cloudflare.Client
posthog posthog.Client
idResolver *idresolver.Resolver
pages *pages.Pages
l *slog.Logger
disallowedNicknames map[string]bool
}
func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup {
var cf *cloudflare.Client
if cfg.Cloudflare.ApiToken != "" {
var err error
cf, err = cloudflare.New(cfg)
if err != nil {
l.Warn("failed to create cloudflare client, signup will be disabled", "error", err)
}
}
disallowedNicknames := userutil.LoadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
return &Signup{
config: cfg,
db: database,
posthog: pc,
idResolver: idResolver,
cf: cf,
pages: pages,
l: l,
disallowedNicknames: disallowedNicknames,
}
}
// isNicknameAllowed checks if a nickname is allowed (not in the disallowed list)
func (s *Signup) isNicknameAllowed(nickname string) bool {
return !s.disallowedNicknames[strings.ToLower(nickname)]
}
func (s *Signup) Router() http.Handler {
r := chi.NewRouter()
r.Get("/", s.signup)
r.Post("/", s.signup)
r.Get("/complete", s.complete)
r.Post("/complete", s.complete)
return r
}
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
emailId := r.URL.Query().Get("id")
s.pages.Signup(w, pages.SignupParams{
BaseParams: pages.BaseParamsFromContext(r.Context()),
CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey,
EmailId: emailId,
})
case http.MethodPost:
if s.cf == nil {
http.Error(w, "signup is disabled", http.StatusFailedDependency)
return
}
emailId := r.FormValue("email")
cfToken := r.FormValue("cf-turnstile-response")
noticeId := "signup-msg"
if err := s.validateCaptcha(cfToken, r); err != nil {
s.l.Warn("turnstile validation failed", "error", err, "email", emailId)
s.pages.Notice(w, noticeId, "Captcha validation failed.")
return
}
if !email.IsValidEmail(emailId) {
s.pages.Notice(w, noticeId, "Invalid email address.")
return
}
exists, err := db.CheckEmailExistsAtAll(s.db, emailId)
if err != nil {
s.l.Error("failed to check email existence", "error", err)
s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.")
return
}
if exists {
s.pages.Notice(w, noticeId, "Email already exists.")
return
}
code, err := s.inviteCodeRequest()
if err != nil {
s.l.Error("failed to create invite code", "error", err)
s.pages.Notice(w, noticeId, "Failed to create invite code.")
return
}
em := email.Email{
APIKey: s.config.Resend.ApiKey,
From: s.config.Resend.SentFrom,
To: emailId,
Subject: "Verify your Tangled account",
Text: `Copy and paste this code below to verify your account on Tangled.
` + code,
Html: `
Copy and paste this code below to verify your account on Tangled.
` + code + `
`,
}
err = email.SendEmail(em)
if err != nil {
s.l.Error("failed to send email", "error", err)
s.pages.Notice(w, noticeId, "Failed to send email.")
return
}
err = db.AddInflightSignup(s.db, models.InflightSignup{
Email: emailId,
InviteCode: code,
})
if err != nil {
s.l.Error("failed to add inflight signup", "error", err)
s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.")
return
}
s.pages.HxRedirect(w, "/signup/complete")
}
}
func (s *Signup) complete(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.pages.CompleteSignup(w)
case http.MethodPost:
username := r.FormValue("username")
password := r.FormValue("password")
code := r.FormValue("code")
if !userutil.IsValidSubdomain(username) {
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.")
return
}
if !s.isNicknameAllowed(username) {
s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.")
return
}
email, err := db.GetEmailForCode(s.db, code)
if err != nil {
s.l.Error("failed to get email for code", "error", err)
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
return
}
if s.cf == nil {
s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration")
s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.")
return
}
// Execute signup transactionally with rollback capability
err = s.executeSignupTransaction(r.Context(), username, password, email, code, w)
if err != nil {
// Error already logged and notice already sent
return
}
}
}
// executeSignupTransaction performs the signup process transactionally with rollback
func (s *Signup) executeSignupTransaction(ctx context.Context, username, password, email, code string, w http.ResponseWriter) error {
// var recordID string
var did string
var emailAdded bool
success := false
defer func() {
if !success {
s.l.Info("rolling back signup transaction", "username", username, "did", did)
// Rollback DNS record
// if recordID != "" {
// if err := s.cf.DeleteDNSRecord(ctx, recordID); err != nil {
// s.l.Error("failed to rollback DNS record", "error", err, "recordID", recordID)
// } else {
// s.l.Info("successfully rolled back DNS record", "recordID", recordID)
// }
// }
// Rollback PDS account
if did != "" {
if err := s.deleteAccountRequest(did); err != nil {
s.l.Error("failed to rollback PDS account", "error", err, "did", did)
} else {
s.l.Info("successfully rolled back PDS account", "did", did)
}
}
// Rollback email from database
if emailAdded {
if err := db.DeleteEmail(s.db, did, email); err != nil {
s.l.Error("failed to rollback email from database", "error", err, "email", email)
} else {
s.l.Info("successfully rolled back email from database", "email", email)
}
}
}
}()
// step 1: create account in PDS
did, err := s.createAccountRequest(username, password, email, code)
if err != nil {
s.l.Error("failed to create account", "error", err)
s.pages.Notice(w, "signup-error", err.Error())
return err
}
// XXX: we have a wildcard *.tngl.sh record now
// step 2: create DNS record with actual DID
// recordID, err = s.cf.CreateDNSRecord(ctx, cloudflare.DNSRecord{
// Type: "TXT",
// Name: "_atproto." + username,
// Content: fmt.Sprintf(`"did=%s"`, did),
// TTL: 6400,
// Proxied: false,
// })
// if err != nil {
// s.l.Error("failed to create DNS record", "error", err)
// s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.")
// return err
// }
// step 3: add email to database
err = db.AddEmail(s.db, models.Email{
Did: did,
Address: email,
Verified: true,
Primary: true,
})
if err != nil {
s.l.Error("failed to add email", "error", err)
s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.")
return err
}
emailAdded = true
// step 4: auto-claim . for this user.
// All signups through this flow receive a .tngl.sh handle
// (or whatever the configured PDS host is), so we claim the matching
// sites subdomain on their behalf. This is the only way to obtain a
// *.tngl.sh sites domain; it cannot be claimed manually via settings.
pdsDomain := strings.TrimPrefix(s.config.Pds.Host, "https://")
pdsDomain = strings.TrimPrefix(pdsDomain, "http://")
autoClaimDomain := username + "." + pdsDomain
if err := db.ClaimDomain(s.db, did, autoClaimDomain); err != nil {
s.l.Warn("failed to auto-claim sites domain at signup",
"domain", autoClaimDomain,
"did", did,
"error", err,
)
} else {
s.l.Info("auto-claimed sites domain at signup", "domain", autoClaimDomain, "did", did)
}
// if we get here, we've successfully created the account and added the email
success = true
s.pages.SignupSuccess(w, pages.SignupSuccessParams{Handle: autoClaimDomain})
// clean up inflight signup asynchronously
go func() {
if err := db.DeleteInflightSignup(s.db, email); err != nil {
s.l.Error("failed to delete inflight signup", "error", err)
}
}()
return nil
}
type turnstileResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
ChallengeTs string `json:"challenge_ts,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
if cfToken == "" {
return errors.New("captcha token is empty")
}
if s.config.Cloudflare.Turnstile.SecretKey == "" {
return errors.New("turnstile secret key not configured")
}
data := url.Values{}
data.Set("secret", s.config.Cloudflare.Turnstile.SecretKey)
data.Set("response", cfToken)
// include the client IP if we have it
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
data.Set("remoteip", remoteIP)
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
data.Set("remoteip", strings.TrimSpace(ips[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 turnstileResp turnstileResponse
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
return fmt.Errorf("failed to decode turnstile response: %w", err)
}
if !turnstileResp.Success {
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
return errors.New("turnstile validation failed")
}
return nil
}