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 + `
%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" }}
+
+
+
+
+
+
+
+
+
+
+ .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.
+
Join our Discord or IRC channel: