From 4e89523f114c28db1bf26103e6bf8c49af107934 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Sun, 27 Jul 2025 20:07:25 +0300 Subject: [PATCH] appview: tangled pds signup flow Change-Id: qlzpkvltqlzmptqkwqoxlpkzpuykutkx Signed-off-by: Anirudh Oppiliappan --- appview/config/config.go | 12 ++ appview/db/db.go | 7 + appview/db/email.go | 18 +- appview/db/signup.go | 29 +++ appview/dns/cloudflare.go | 53 ++++++ appview/pages/pages.go | 6 + .../pages/templates/user/completeSignup.html | 104 +++++++++++ appview/pages/templates/user/login.html | 61 +++++- appview/signup/requests.go | 104 +++++++++++ appview/signup/signup.go | 173 ++++++++++++++++++ appview/state/router.go | 9 + appview/state/state.go | 12 +- appview/state/userutil/userutil.go | 6 + go.mod | 5 +- go.sum | 9 +- 15 files changed, 594 insertions(+), 14 deletions(-) create mode 100644 appview/db/signup.go create mode 100644 appview/dns/cloudflare.go create mode 100644 appview/pages/templates/user/completeSignup.html create mode 100644 appview/signup/requests.go create mode 100644 appview/signup/signup.go diff --git a/appview/config/config.go b/appview/config/config.go index a95f726..201ea4b 100644 --- a/appview/config/config.go +++ b/appview/config/config.go @@ -59,6 +59,16 @@ type RedisConfig struct { DB int `env:"DB, default=0"` } +type PdsConfig struct { + Host string `env:"HOST, default=https://tngl.sh"` + AdminSecret string `env:"ADMIN_SECRET"` +} + +type Cloudflare struct { + ApiToken string `env:"API_TOKEN"` + ZoneId string `env:"ZONE_ID"` +} + func (cfg RedisConfig) ToURL() string { u := &url.URL{ Scheme: "redis", @@ -84,6 +94,8 @@ type Config struct { Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` } func LoadConfig(ctx context.Context) (*Config, error) { diff --git a/appview/db/db.go b/appview/db/db.go index cc6283c..c046c5d 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -437,6 +437,13 @@ func Make(dbPath string) (*DB, error) { unique(repo_at, ref, language) ); + create table if not exists signups_inflight ( + id integer primary key autoincrement, + email text not null unique, + invite_code text not null, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique diff --git a/appview/db/email.go b/appview/db/email.go index 9fa2733..3ce035e 100644 --- a/appview/db/email.go +++ b/appview/db/email.go @@ -103,8 +103,8 @@ func GetEmailToDid(e Execer, ems []string, isVerifiedFilter bool) (map[string]st query := ` select email, did from emails - where - verified = ? + where + verified = ? and email in (` + strings.Join(placeholders, ",") + `) ` @@ -159,6 +159,20 @@ func CheckEmailExists(e Execer, did string, email string) (bool, error) { return count > 0, nil } +func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { + query := ` + select count(*) + from emails + where email = ? + ` + var count int + err := e.QueryRow(query, email).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) { query := ` select count(*) diff --git a/appview/db/signup.go b/appview/db/signup.go new file mode 100644 index 0000000..822b6b0 --- /dev/null +++ b/appview/db/signup.go @@ -0,0 +1,29 @@ +package db + +import "time" + +type InflightSignup struct { + Id int64 + Email string + InviteCode string + Created time.Time +} + +func AddInflightSignup(e Execer, signup InflightSignup) error { + query := `insert into signups_inflight (email, invite_code) values (?, ?)` + _, err := e.Exec(query, signup.Email, signup.InviteCode) + return err +} + +func DeleteInflightSignup(e Execer, email string) error { + query := `delete from signups_inflight where email = ?` + _, err := e.Exec(query, email) + return err +} + +func GetEmailForCode(e Execer, inviteCode string) (string, error) { + query := `select email from signups_inflight where invite_code = ?` + var email string + err := e.QueryRow(query, inviteCode).Scan(&email) + return email, err +} diff --git a/appview/dns/cloudflare.go b/appview/dns/cloudflare.go new file mode 100644 index 0000000..6f000b4 --- /dev/null +++ b/appview/dns/cloudflare.go @@ -0,0 +1,53 @@ +package dns + +import ( + "context" + "fmt" + + "github.com/cloudflare/cloudflare-go" + "tangled.sh/tangled.sh/core/appview/config" +) + +type Record struct { + Type string + Name string + Content string + TTL int + Proxied bool +} + +type Cloudflare struct { + api *cloudflare.API + zone string +} + +func NewCloudflare(c *config.Config) (*Cloudflare, error) { + apiToken := c.Cloudflare.ApiToken + api, err := cloudflare.NewWithAPIToken(apiToken) + if err != nil { + return nil, err + } + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil +} + +func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ + Type: record.Type, + Name: record.Name, + Content: record.Content, + TTL: record.TTL, + Proxied: &record.Proxied, + }) + if err != nil { + return fmt.Errorf("failed to create DNS record: %w", err) + } + return nil +} + +func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) + if err != nil { + return fmt.Errorf("failed to delete DNS record: %w", err) + } + return nil +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index e22d23f..5e9bec6 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -262,6 +262,12 @@ func (p *Pages) Login(w io.Writer, params LoginParams) error { return p.executePlain("user/login", w, params) } +type SignupParams struct{} + +func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { + return p.executePlain("user/completeSignup", w, params) +} + type TimelineParams struct { LoggedInUser *oauth.User Timeline []db.TimelineEvent diff --git a/appview/pages/templates/user/completeSignup.html b/appview/pages/templates/user/completeSignup.html new file mode 100644 index 0000000..1dd1f78 --- /dev/null +++ b/appview/pages/templates/user/completeSignup.html @@ -0,0 +1,104 @@ +{{ define "user/completeSignup" }} + + + + + + + + + + + complete signup · tangled + + +
+

+ tangled +

+

+ tightly-knit social coding. +

+
+
+ + + + Enter the code sent to your email. + +
+ +
+ + + + Your complete handle will be of the form user.tngl.sh. + +
+ +
+ + + + Choose a strong password for your account. + +
+ + +
+

+

+
+ + +{{ end }} diff --git a/appview/pages/templates/user/login.html b/appview/pages/templates/user/login.html index a234da2..3a29b99 100644 --- a/appview/pages/templates/user/login.html +++ b/appview/pages/templates/user/login.html @@ -17,7 +17,7 @@ /> - login · tangled + login or sign up · tangled
@@ -51,12 +51,12 @@ name="handle" tabindex="1" required + placeholder="foo.tngl.sh" /> - Use your - Bluesky handle to log - in. You will then be redirected to your PDS to - complete authentication. + Use your ATProto + handle to log in. If you're unsure, this is likely + your Tangled (.tngl.sh) or Bluesky (.bsky.social) account. @@ -69,7 +69,54 @@ login -

+


+

+ Alternatively, you may create an account on Tangled below. You will + get a user.tngl.sh handle. +

+ +
+ + + create an account + +
{{ i "arrow-right" "w-4 h-4" }}
+ +
+
+
+ + +
+ + You will receive an email with a code. Enter that, along with your + desired username and password in the next page to complete your registration. + + +
+
+

Join our Discord or IRC channel: 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, "login-msg", "Failed to send email.") + return + } + err = db.AddInflightSignup(s.db, db.InflightSignup{ + Email: emailId, + InviteCode: code, + }) + if err != nil { + s.l.Error("failed to add inflight signup", "error", err) + s.pages.Notice(w, "login-msg", "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, pages.SignupParams{}) + 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 + } + + 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 + } + + 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 = s.cf.CreateDNSRecord(r.Context(), dns.Record{ + Type: "TXT", + Name: "_atproto." + username, + Content: "did=" + 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 complete sign up. Try again later.") + return + } + + err = db.AddEmail(s.db, db.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 + } + + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now +
login + with %s.tngl.sh.`, username)) + + go func() { + err := db.DeleteInflightSignup(s.db, email) + if err != nil { + s.l.Error("failed to delete inflight signup", "error", err) + } + }() + return + } +} diff --git a/appview/state/router.go b/appview/state/router.go index d164a0b..5df41d3 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -14,6 +14,7 @@ import ( "tangled.sh/tangled.sh/core/appview/pulls" "tangled.sh/tangled.sh/core/appview/repo" "tangled.sh/tangled.sh/core/appview/settings" + "tangled.sh/tangled.sh/core/appview/signup" "tangled.sh/tangled.sh/core/appview/spindles" "tangled.sh/tangled.sh/core/appview/state/userutil" "tangled.sh/tangled.sh/core/log" @@ -137,6 +138,7 @@ func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { r.Mount("/settings", s.SettingsRouter()) r.Mount("/knots", s.KnotsRouter(mw)) r.Mount("/spindles", s.SpindlesRouter()) + r.Mount("/signup", s.SignupRouter()) r.Mount("/", s.OAuthRouter()) r.Get("/keys/{user}", s.Keys) @@ -216,3 +218,10 @@ func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) return pipes.Router(mw) } + +func (s *State) SignupRouter() http.Handler { + logger := log.New("signup") + + sig := signup.New(s.config, s.cf, s.db, s.posthog, s.idResolver, s.pages, logger) + return sig.Router() +} diff --git a/appview/state/state.go b/appview/state/state.go index 85aae4f..591c61b 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -20,11 +20,12 @@ import ( "tangled.sh/tangled.sh/core/appview/cache/session" "tangled.sh/tangled.sh/core/appview/config" "tangled.sh/tangled.sh/core/appview/db" + "tangled.sh/tangled.sh/core/appview/dns" "tangled.sh/tangled.sh/core/appview/idresolver" "tangled.sh/tangled.sh/core/appview/notify" "tangled.sh/tangled.sh/core/appview/oauth" "tangled.sh/tangled.sh/core/appview/pages" - posthog_service "tangled.sh/tangled.sh/core/appview/posthog" + posthogService "tangled.sh/tangled.sh/core/appview/posthog" "tangled.sh/tangled.sh/core/appview/reporesolver" "tangled.sh/tangled.sh/core/eventconsumer" "tangled.sh/tangled.sh/core/jetstream" @@ -46,6 +47,7 @@ type State struct { jc *jetstream.JetstreamClient config *config.Config repoResolver *reporesolver.RepoResolver + cf *dns.Cloudflare knotstream *eventconsumer.Consumer spindlestream *eventconsumer.Consumer } @@ -133,10 +135,15 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { var notifiers []notify.Notifier if !config.Core.Dev { - notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog)) + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) } notifier := notify.NewMergedNotifier(notifiers...) + cf, err := dns.NewCloudflare(config) + if err != nil { + return nil, fmt.Errorf("failed to create Cloudflare client: %w", err) + } + state := &State{ d, notifier, @@ -149,6 +156,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { jc, config, repoResolver, + cf, knotstream, spindlestream, } diff --git a/appview/state/userutil/userutil.go b/appview/state/userutil/userutil.go index f29fce7..9097309 100644 --- a/appview/state/userutil/userutil.go +++ b/appview/state/userutil/userutil.go @@ -51,3 +51,9 @@ func FlattenDid(s string) string { func IsDid(s string) bool { return didRegex.MatchString(s) } + +var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) + +func IsValidSubdomain(name string) bool { + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) +} diff --git a/go.mod b/go.mod index acd308d..29b5cb3 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 github.com/carlmjohnson/versioninfo v0.22.5 github.com/casbin/casbin/v2 v2.103.0 + github.com/cloudflare/cloudflare-go v0.115.0 github.com/cyphar/filepath-securejoin v0.4.1 github.com/dgraph-io/ristretto v0.2.0 github.com/docker/docker v28.2.2+incompatible @@ -78,6 +79,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect @@ -147,7 +149,8 @@ require ( golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/sync v0.14.0 // indirect golang.org/x/sys v0.33.0 // indirect - golang.org/x/time v0.8.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect google.golang.org/grpc v1.72.1 // indirect diff --git a/go.sum b/go.sum index 279969c..f1b7d45 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= +github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -146,11 +148,14 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -534,8 +539,8 @@ golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -- 2.43.0