appview: tangled pds signup flow #357

merged
opened by anirudh.fi targeting master from push-qlzpkvltqlzm
Changed files
+594 -14
appview
+12
appview/config/config.go
··· 59 59 DB int `env:"DB, default=0"` 60 60 } 61 61 62 + type PdsConfig struct { 63 + Host string `env:"HOST, default=https://tngl.sh"` 64 + AdminSecret string `env:"ADMIN_SECRET"` 65 + } 66 + 67 + type Cloudflare struct { 68 + ApiToken string `env:"API_TOKEN"` 69 + ZoneId string `env:"ZONE_ID"` 70 + } 71 + 62 72 func (cfg RedisConfig) ToURL() string { 63 73 u := &url.URL{ 64 74 Scheme: "redis", ··· 84 94 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 95 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 96 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 97 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 98 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 99 } 88 100 89 101 func LoadConfig(ctx context.Context) (*Config, error) {
+7
appview/db/db.go
··· 437 437 unique(repo_at, ref, language) 438 438 ); 439 439 440 + create table if not exists signups_inflight ( 441 + id integer primary key autoincrement, 442 + email text not null unique, 443 + invite_code text not null, 444 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 445 + ); 446 + 440 447 create table if not exists migrations ( 441 448 id integer primary key autoincrement, 442 449 name text unique
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 159 159 return count > 0, nil 160 160 } 161 161 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 170 + if err != nil { 171 + return false, err 172 + } 173 + return count > 0, nil 174 + } 175 + 162 176 func CheckValidVerificationCode(e Execer, did string, email string, code string) (bool, error) { 163 177 query := ` 164 178 select count(*)
+29
appview/db/signup.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+53
appview/dns/cloudflare.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.sh/tangled.sh/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
+6
appview/pages/pages.go
··· 262 262 return p.executePlain("user/login", w, params) 263 263 } 264 264 265 + type SignupParams struct{} 266 + 267 + func (p *Pages) CompleteSignup(w io.Writer, params SignupParams) error { 268 + return p.executePlain("user/completeSignup", w, params) 269 + } 270 + 265 271 type TimelineParams struct { 266 272 LoggedInUser *oauth.User 267 273 Timeline []db.TimelineEvent
+104
appview/pages/templates/user/completeSignup.html
··· 1 + {{ define "user/completeSignup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta 7 + name="viewport" 8 + content="width=device-width, initial-scale=1.0" 9 + /> 10 + <meta 11 + property="og:title" 12 + content="complete signup · tangled" 13 + /> 14 + <meta 15 + property="og:url" 16 + content="https://tangled.sh/complete-signup" 17 + /> 18 + <meta 19 + property="og:description" 20 + content="complete your signup for tangled" 21 + /> 22 + <script src="/static/htmx.min.js"></script> 23 + <link 24 + rel="stylesheet" 25 + href="/static/tw.css?{{ cssContentHash }}" 26 + type="text/css" 27 + /> 28 + <title>complete signup &middot; tangled</title> 29 + </head> 30 + <body class="flex items-center justify-center min-h-screen"> 31 + <main class="max-w-md px-6 -mt-4"> 32 + <h1 33 + class="text-center text-2xl font-semibold italic dark:text-white" 34 + > 35 + tangled 36 + </h1> 37 + <h2 class="text-center text-xl italic dark:text-white"> 38 + tightly-knit social coding. 39 + </h2> 40 + <form 41 + class="mt-4 max-w-sm mx-auto" 42 + hx-post="/signup/complete" 43 + hx-swap="none" 44 + hx-disabled-elt="#complete-signup-button" 45 + > 46 + <div class="flex flex-col"> 47 + <label for="code">verification code</label> 48 + <input 49 + type="text" 50 + id="code" 51 + name="code" 52 + tabindex="1" 53 + required 54 + placeholder="pds-tngl-sh-foo-bar" 55 + /> 56 + <span class="text-sm text-gray-500 mt-1"> 57 + Enter the code sent to your email. 58 + </span> 59 + </div> 60 + 61 + <div class="flex flex-col mt-4"> 62 + <label for="username">desired username</label> 63 + <input 64 + type="text" 65 + id="username" 66 + name="username" 67 + tabindex="2" 68 + required 69 + placeholder="jason" 70 + /> 71 + <span class="text-sm text-gray-500 mt-1"> 72 + Your complete handle will be of the form <code>user.tngl.sh</code>. 73 + </span> 74 + </div> 75 + 76 + <div class="flex flex-col mt-4"> 77 + <label for="password">password</label> 78 + <input 79 + type="password" 80 + id="password" 81 + name="password" 82 + tabindex="3" 83 + required 84 + /> 85 + <span class="text-sm text-gray-500 mt-1"> 86 + Choose a strong password for your account. 87 + </span> 88 + </div> 89 + 90 + <button 91 + class="btn-create w-full my-2 mt-6" 92 + type="submit" 93 + id="complete-signup-button" 94 + tabindex="4" 95 + > 96 + <span>complete signup</span> 97 + </button> 98 + </form> 99 + <p id="signup-error" class="error w-full"></p> 100 + <p id="signup-msg" class="dark:text-white w-full"></p> 101 + </main> 102 + </body> 103 + </html> 104 + {{ end }}
+54 -7
appview/pages/templates/user/login.html
··· 17 17 /> 18 18 <meta 19 19 property="og:description" 20 - content="login to tangled" 20 + content="login to or sign up for tangled" 21 21 /> 22 22 <script src="/static/htmx.min.js"></script> 23 23 <link ··· 25 25 href="/static/tw.css?{{ cssContentHash }}" 26 26 type="text/css" 27 27 /> 28 - <title>login &middot; tangled</title> 28 + <title>login or sign up &middot; tangled</title> 29 29 </head> 30 30 <body class="flex items-center justify-center min-h-screen"> 31 31 <main class="max-w-md px-6 -mt-4"> ··· 51 51 name="handle" 52 52 tabindex="1" 53 53 required 54 + placeholder="foo.tngl.sh" 54 55 /> 55 56 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 57 + Use your <a href="https://atproto.com">ATProto</a> 58 + handle to log in. If you're unsure, this is likely 59 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 60 </span> 61 61 </div> 62 62 ··· 69 69 <span>login</span> 70 70 </button> 71 71 </form> 72 - <p class="text-sm text-gray-500"> 72 + <hr class="my-4"> 73 + <p class="text-sm text-gray-500 mt-4"> 74 + Alternatively, you may create an account on Tangled below. You will 75 + get a <code>user.tngl.sh</code> handle. 76 + </p> 77 + 78 + <details class="group"> 79 + 80 + <summary 81 + class="btn cursor-pointer w-full mt-4 flex items-center justify-center gap-2" 82 + > 83 + create an account 84 + 85 + <div class="group-open:hidden flex">{{ i "arrow-right" "w-4 h-4" }}</div> 86 + <div class="hidden group-open:flex">{{ i "arrow-down" "w-4 h-4" }}</div> 87 + </summary> 88 + <form 89 + class="mt-4 max-w-sm mx-auto" 90 + hx-post="/signup" 91 + hx-swap="none" 92 + hx-disabled-elt="#signup-button" 93 + > 94 + <div class="flex flex-col mt-2"> 95 + <label for="email">email</label> 96 + <input 97 + type="email" 98 + id="email" 99 + name="email" 100 + tabindex="4" 101 + required 102 + placeholder="jason@bourne.co" 103 + /> 104 + </div> 105 + <span class="text-sm text-gray-500 mt-1"> 106 + You will receive an email with a code. Enter that, along with your 107 + desired username and password in the next page to complete your registration. 108 + </span> 109 + <button 110 + class="btn w-full my-2 mt-6" 111 + type="submit" 112 + id="signup-button" 113 + tabindex="7" 114 + > 115 + <span>sign up</span> 116 + </button> 117 + </form> 118 + </details> 119 + <p class="text-sm text-gray-500 mt-6"> 73 120 Join our <a href="https://chat.tangled.sh">Discord</a> or 74 121 IRC channel: 75 122 <a href="https://web.libera.chat/#tangled"
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+173
appview/signup/signup.go
··· 1 + package signup 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + 8 + "github.com/go-chi/chi/v5" 9 + "github.com/posthog/posthog-go" 10 + "tangled.sh/tangled.sh/core/appview/config" 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/dns" 13 + "tangled.sh/tangled.sh/core/appview/email" 14 + "tangled.sh/tangled.sh/core/appview/idresolver" 15 + "tangled.sh/tangled.sh/core/appview/pages" 16 + "tangled.sh/tangled.sh/core/appview/state/userutil" 17 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 18 + ) 19 + 20 + type Signup struct { 21 + config *config.Config 22 + db *db.DB 23 + cf *dns.Cloudflare 24 + posthog posthog.Client 25 + xrpc *xrpcclient.Client 26 + idResolver *idresolver.Resolver 27 + pages *pages.Pages 28 + l *slog.Logger 29 + } 30 + 31 + func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 32 + return &Signup{ 33 + config: cfg, 34 + db: database, 35 + cf: cf, 36 + posthog: pc, 37 + idResolver: idResolver, 38 + pages: pages, 39 + l: l, 40 + } 41 + } 42 + 43 + func (s *Signup) Router() http.Handler { 44 + r := chi.NewRouter() 45 + r.Post("/", s.signup) 46 + r.Get("/complete", s.complete) 47 + r.Post("/complete", s.complete) 48 + 49 + return r 50 + } 51 + 52 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 53 + emailId := r.FormValue("email") 54 + 55 + if !email.IsValidEmail(emailId) { 56 + s.pages.Notice(w, "login-msg", "Invalid email address.") 57 + return 58 + } 59 + 60 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 61 + if err != nil { 62 + s.l.Error("failed to check email existence", "error", err) 63 + s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 64 + return 65 + } 66 + if exists { 67 + s.pages.Notice(w, "login-msg", "Email already exists.") 68 + return 69 + } 70 + 71 + code, err := s.inviteCodeRequest() 72 + if err != nil { 73 + s.l.Error("failed to create invite code", "error", err) 74 + s.pages.Notice(w, "login-msg", "Failed to create invite code.") 75 + return 76 + } 77 + 78 + em := email.Email{ 79 + APIKey: s.config.Resend.ApiKey, 80 + From: s.config.Resend.SentFrom, 81 + To: emailId, 82 + Subject: "Verify your Tangled account", 83 + Text: `Copy and paste this code below to verify your account on Tangled. 84 + ` + code, 85 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 86 + <p><code>` + code + `</code></p>`, 87 + } 88 + 89 + err = email.SendEmail(em) 90 + if err != nil { 91 + s.l.Error("failed to send email", "error", err) 92 + s.pages.Notice(w, "login-msg", "Failed to send email.") 93 + return 94 + } 95 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 96 + Email: emailId, 97 + InviteCode: code, 98 + }) 99 + if err != nil { 100 + s.l.Error("failed to add inflight signup", "error", err) 101 + s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 102 + return 103 + } 104 + 105 + s.pages.HxRedirect(w, "/signup/complete") 106 + } 107 + 108 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 109 + switch r.Method { 110 + case http.MethodGet: 111 + s.pages.CompleteSignup(w, pages.SignupParams{}) 112 + case http.MethodPost: 113 + username := r.FormValue("username") 114 + password := r.FormValue("password") 115 + code := r.FormValue("code") 116 + 117 + if !userutil.IsValidSubdomain(username) { 118 + 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.") 119 + return 120 + } 121 + 122 + email, err := db.GetEmailForCode(s.db, code) 123 + if err != nil { 124 + s.l.Error("failed to get email for code", "error", err) 125 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 126 + return 127 + } 128 + 129 + did, err := s.createAccountRequest(username, password, email, code) 130 + if err != nil { 131 + s.l.Error("failed to create account", "error", err) 132 + s.pages.Notice(w, "signup-error", err.Error()) 133 + return 134 + } 135 + 136 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 137 + Type: "TXT", 138 + Name: "_atproto." + username, 139 + Content: "did=" + did, 140 + TTL: 6400, 141 + Proxied: false, 142 + }) 143 + if err != nil { 144 + s.l.Error("failed to create DNS record", "error", err) 145 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 146 + return 147 + } 148 + 149 + err = db.AddEmail(s.db, db.Email{ 150 + Did: did, 151 + Address: email, 152 + Verified: true, 153 + Primary: true, 154 + }) 155 + if err != nil { 156 + s.l.Error("failed to add email", "error", err) 157 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 158 + return 159 + } 160 + 161 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 162 + <a class="underline text-black dark:text-white" href="/login">login</a> 163 + with <code>%s.tngl.sh</code>.`, username)) 164 + 165 + go func() { 166 + err := db.DeleteInflightSignup(s.db, email) 167 + if err != nil { 168 + s.l.Error("failed to delete inflight signup", "error", err) 169 + } 170 + }() 171 + return 172 + } 173 + }
+9
appview/state/router.go
··· 14 14 "tangled.sh/tangled.sh/core/appview/pulls" 15 15 "tangled.sh/tangled.sh/core/appview/repo" 16 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 17 18 "tangled.sh/tangled.sh/core/appview/spindles" 18 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 19 20 "tangled.sh/tangled.sh/core/log" ··· 137 138 r.Mount("/settings", s.SettingsRouter()) 138 139 r.Mount("/knots", s.KnotsRouter(mw)) 139 140 r.Mount("/spindles", s.SpindlesRouter()) 141 + r.Mount("/signup", s.SignupRouter()) 140 142 r.Mount("/", s.OAuthRouter()) 141 143 142 144 r.Get("/keys/{user}", s.Keys) ··· 216 218 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 217 219 return pipes.Router(mw) 218 220 } 221 + 222 + func (s *State) SignupRouter() http.Handler { 223 + logger := log.New("signup") 224 + 225 + sig := signup.New(s.config, s.cf, s.db, s.posthog, s.idResolver, s.pages, logger) 226 + return sig.Router() 227 + }
+10 -2
appview/state/state.go
··· 20 20 "tangled.sh/tangled.sh/core/appview/cache/session" 21 21 "tangled.sh/tangled.sh/core/appview/config" 22 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/dns" 23 24 "tangled.sh/tangled.sh/core/appview/idresolver" 24 25 "tangled.sh/tangled.sh/core/appview/notify" 25 26 "tangled.sh/tangled.sh/core/appview/oauth" 26 27 "tangled.sh/tangled.sh/core/appview/pages" 27 - posthog_service "tangled.sh/tangled.sh/core/appview/posthog" 28 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 28 29 "tangled.sh/tangled.sh/core/appview/reporesolver" 29 30 "tangled.sh/tangled.sh/core/eventconsumer" 30 31 "tangled.sh/tangled.sh/core/jetstream" ··· 46 47 jc *jetstream.JetstreamClient 47 48 config *config.Config 48 49 repoResolver *reporesolver.RepoResolver 50 + cf *dns.Cloudflare 49 51 knotstream *eventconsumer.Consumer 50 52 spindlestream *eventconsumer.Consumer 51 53 } ··· 133 135 134 136 var notifiers []notify.Notifier 135 137 if !config.Core.Dev { 136 - notifiers = append(notifiers, posthog_service.NewPosthogNotifier(posthog)) 138 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 137 139 } 138 140 notifier := notify.NewMergedNotifier(notifiers...) 139 141 142 + cf, err := dns.NewCloudflare(config) 143 + if err != nil { 144 + return nil, fmt.Errorf("failed to create Cloudflare client: %w", err) 145 + } 146 + 140 147 state := &State{ 141 148 d, 142 149 notifier, ··· 149 156 jc, 150 157 config, 151 158 repoResolver, 159 + cf, 152 160 knotstream, 153 161 spindlestream, 154 162 }
+6
appview/state/userutil/userutil.go
··· 51 51 func IsDid(s string) bool { 52 52 return didRegex.MatchString(s) 53 53 } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 59 + }
+4 -1
go.mod
··· 13 13 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 14 14 github.com/carlmjohnson/versioninfo v0.22.5 15 15 github.com/casbin/casbin/v2 v2.103.0 16 + github.com/cloudflare/cloudflare-go v0.115.0 16 17 github.com/cyphar/filepath-securejoin v0.4.1 17 18 github.com/dgraph-io/ristretto v0.2.0 18 19 github.com/docker/docker v28.2.2+incompatible ··· 78 79 github.com/gogo/protobuf v1.3.2 // indirect 79 80 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 80 81 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 82 + github.com/google/go-querystring v1.1.0 // indirect 81 83 github.com/gorilla/css v1.0.1 // indirect 82 84 github.com/gorilla/securecookie v1.1.2 // indirect 83 85 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect ··· 147 149 golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 148 150 golang.org/x/sync v0.14.0 // indirect 149 151 golang.org/x/sys v0.33.0 // indirect 150 - golang.org/x/time v0.8.0 // indirect 152 + golang.org/x/text v0.25.0 // indirect 153 + golang.org/x/time v0.9.0 // indirect 151 154 google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 152 155 google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 153 156 google.golang.org/grpc v1.72.1 // indirect
+7 -2
go.sum
··· 53 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 54 54 github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 55 55 github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 56 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 57 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 58 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 146 148 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 147 149 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 150 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 151 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 152 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 150 153 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 151 154 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 152 155 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 153 156 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 157 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 158 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 154 159 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 155 160 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 156 161 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 534 539 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 535 540 golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 536 541 golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 537 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 538 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 542 + golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 543 + golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 539 544 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 540 545 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 541 546 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=