diff --git a/appview/config/config.go b/appview/config/config.go index a95f7269..201ea4bf 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 ef5aa138..032a4bc3 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -436,6 +436,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 9fa2733e..3ce035ee 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 00000000..822b6b07 --- /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 00000000..6f000b49 --- /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 232f400a..5ef0d57f 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 00000000..1dd1f78d --- /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: 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
index 60728d27..fd2faf1f 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)
@@ -217,3 +219,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 89455524..fee5c124 100644
--- a/appview/state/state.go
+++ b/appview/state/state.go
@@ -20,10 +20,11 @@ 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/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 @@ 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 f29fce74..9097309a 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 71b7edb7..e5f883d2 100644
--- a/go.mod
+++ b/go.mod
@@ -12,6 +12,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
@@ -85,6 +86,7 @@ require (
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
index 63c095a4..2e0ada5f 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.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/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=