diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ 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 @@ -85,6 +86,7 @@ github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/mock v1.6.0 // 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/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/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= @@ -152,11 +154,14 @@ 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= diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -59,6 +59,16 @@ 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 @@ 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 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -436,6 +436,13 @@ 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 --- a/appview/db/email.go +++ b/appview/db/email.go @@ -103,8 +103,8 @@ query := ` select email, did from emails - where - verified = ? + where + verified = ? and email in (` + strings.Join(placeholders, ",") + `) ` @@ -153,6 +153,20 @@ ` var count int err := e.QueryRow(query, did, email).Scan(&count) + if err != nil { + return false, err + } + 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 } diff --git a/appview/db/signup.go b/appview/db/signup.go new file mode 100644 --- /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 --- /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 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -262,6 +262,12 @@ 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/signup/requests.go b/appview/signup/requests.go new file mode 100644 --- /dev/null +++ b/appview/signup/requests.go @@ -0,0 +1,104 @@ +package signup + +// We have this extra code here for now since the xrpcclient package +// only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// makePdsRequest is a helper method to make requests to the PDS service +func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { + jsonData, err := json.Marshal(body) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + if useAuth { + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) + } + + return http.DefaultClient.Do(req) +} + +// handlePdsError processes error responses from the PDS service +func (s *Signup) handlePdsError(resp *http.Response, action string) error { + var errorResp struct { + Error string `json:"error"` + Message string `json:"message"` + } + + respBody, _ := io.ReadAll(resp.Body) + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) + } + + // Fallback if we couldn't parse the error + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) +} + +func (s *Signup) inviteCodeRequest() (string, error) { + body := map[string]any{"useCount": 1} + + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", s.handlePdsError(resp, "create invite code") + } + + var result map[string]string + json.NewDecoder(resp.Body).Decode(&result) + return result["code"], nil +} + +func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { + parsedURL, err := url.Parse(s.config.Pds.Host) + if err != nil { + return "", fmt.Errorf("invalid PDS host URL: %w", err) + } + + pdsDomain := parsedURL.Hostname() + + body := map[string]string{ + "email": email, + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), + "password": password, + "inviteCode": code, + } + + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", s.handlePdsError(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, nil +} diff --git a/appview/signup/signup.go b/appview/signup/signup.go new file mode 100644 --- /dev/null +++ b/appview/signup/signup.go @@ -0,0 +1,172 @@ +package signup + +import ( + "fmt" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/posthog/posthog-go" + "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/email" + "tangled.sh/tangled.sh/core/appview/pages" + "tangled.sh/tangled.sh/core/appview/state/userutil" + "tangled.sh/tangled.sh/core/appview/xrpcclient" +) + +type Signup struct { + config *config.Config + db *db.DB + cf *dns.Cloudflare + posthog posthog.Client + xrpc *xrpcclient.Client + idResolver *idresolver.Resolver + pages *pages.Pages + l *slog.Logger +} + +func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { + return &Signup{ + config: cfg, + db: database, + cf: cf, + posthog: pc, + idResolver: idResolver, + pages: pages, + l: l, + } +} + +func (s *Signup) Router() http.Handler { + r := chi.NewRouter() + 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) { + emailId := r.FormValue("email") + + if !email.IsValidEmail(emailId) { + s.pages.Notice(w, "login-msg", "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, "login-msg", "Failed to complete signup. Try again later.") + return + } + if exists { + s.pages.Notice(w, "login-msg", "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, "login-msg", "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, "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 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -14,6 +14,7 @@ "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 @@ 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,4 +218,11 @@ 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 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -20,10 +20,11 @@ "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/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/idresolver" @@ -46,6 +47,7 @@ jc *jetstream.JetstreamClient config *config.Config repoResolver *reporesolver.RepoResolver + cf *dns.Cloudflare knotstream *eventconsumer.Consumer spindlestream *eventconsumer.Consumer } @@ -133,9 +135,14 @@ 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, @@ -149,6 +156,7 @@ jc, config, repoResolver, + cf, knotstream, spindlestream, } diff --git a/appview/state/userutil/userutil.go b/appview/state/userutil/userutil.go --- a/appview/state/userutil/userutil.go +++ b/appview/state/userutil/userutil.go @@ -51,3 +51,9 @@ 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/appview/pages/templates/user/completeSignup.html b/appview/pages/templates/user/completeSignup.html new file mode 100644 --- /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 --- 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: