Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397package signup
import ( "bufio" "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "os" "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 := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l)
return &Signup{ config: cfg, db: database, posthog: pc, idResolver: idResolver, cf: cf, pages: pages, l: l, disallowedNicknames: disallowedNicknames, }}
func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { disallowed := make(map[string]bool)
if filepath == "" { logger.Warn("no disallowed nicknames file configured") return disallowed }
file, err := os.Open(filepath) if err != nil { logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) return disallowed } defer file.Close()
scanner := bufio.NewScanner(file) lineNum := 0 for scanner.Scan() { lineNum++ line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue // skip empty lines and comments }
nickname := strings.ToLower(line) if userutil.IsValidSubdomain(nickname) { disallowed[nickname] = true } else { logger.Warn("invalid nickname format in disallowed nicknames file", "file", filepath, "line", lineNum, "nickname", nickname) } }
if err := scanner.Err(); err != nil { logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) }
logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) return disallowed}
// 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: `<p>Copy and paste this code below to verify your account on Tangled.</p><p><code>` + code + `</code></p>`, }
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 rollbackfunc (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 <username>.<pds-domain> for this user. // All signups through this flow receive a <username>.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}