forked from tangled.org/core
this repo has no description

appview: tangled pds signup flow

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.sh>

authored by anirudh.fi and committed by Tangled 20910c1a af9f2096

Changed files
+589 -11
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
··· 436 436 unique(repo_at, ref, language) 437 437 ); 438 438 439 + create table if not exists signups_inflight ( 440 + id integer primary key autoincrement, 441 + email text not null unique, 442 + invite_code text not null, 443 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 444 + ); 445 + 439 446 create table if not exists migrations ( 440 447 id integer primary key autoincrement, 441 448 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 ··· 153 153 ` 154 154 var count int 155 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 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) 156 170 if err != nil { 157 171 return false, err 158 172 }
+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 + }
+172
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/pages" 15 + "tangled.sh/tangled.sh/core/appview/state/userutil" 16 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 17 + ) 18 + 19 + type Signup struct { 20 + config *config.Config 21 + db *db.DB 22 + cf *dns.Cloudflare 23 + posthog posthog.Client 24 + xrpc *xrpcclient.Client 25 + idResolver *idresolver.Resolver 26 + pages *pages.Pages 27 + l *slog.Logger 28 + } 29 + 30 + func New(cfg *config.Config, cf *dns.Cloudflare, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 31 + return &Signup{ 32 + config: cfg, 33 + db: database, 34 + cf: cf, 35 + posthog: pc, 36 + idResolver: idResolver, 37 + pages: pages, 38 + l: l, 39 + } 40 + } 41 + 42 + func (s *Signup) Router() http.Handler { 43 + r := chi.NewRouter() 44 + r.Post("/", s.signup) 45 + r.Get("/complete", s.complete) 46 + r.Post("/complete", s.complete) 47 + 48 + return r 49 + } 50 + 51 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 52 + emailId := r.FormValue("email") 53 + 54 + if !email.IsValidEmail(emailId) { 55 + s.pages.Notice(w, "login-msg", "Invalid email address.") 56 + return 57 + } 58 + 59 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 60 + if err != nil { 61 + s.l.Error("failed to check email existence", "error", err) 62 + s.pages.Notice(w, "login-msg", "Failed to complete signup. Try again later.") 63 + return 64 + } 65 + if exists { 66 + s.pages.Notice(w, "login-msg", "Email already exists.") 67 + return 68 + } 69 + 70 + code, err := s.inviteCodeRequest() 71 + if err != nil { 72 + s.l.Error("failed to create invite code", "error", err) 73 + s.pages.Notice(w, "login-msg", "Failed to create invite code.") 74 + return 75 + } 76 + 77 + em := email.Email{ 78 + APIKey: s.config.Resend.ApiKey, 79 + From: s.config.Resend.SentFrom, 80 + To: emailId, 81 + Subject: "Verify your Tangled account", 82 + Text: `Copy and paste this code below to verify your account on Tangled. 83 + ` + code, 84 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 85 + <p><code>` + code + `</code></p>`, 86 + } 87 + 88 + err = email.SendEmail(em) 89 + if err != nil { 90 + s.l.Error("failed to send email", "error", err) 91 + s.pages.Notice(w, "login-msg", "Failed to send email.") 92 + return 93 + } 94 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 95 + Email: emailId, 96 + InviteCode: code, 97 + }) 98 + if err != nil { 99 + s.l.Error("failed to add inflight signup", "error", err) 100 + s.pages.Notice(w, "login-msg", "Failed to complete sign up. Try again later.") 101 + return 102 + } 103 + 104 + s.pages.HxRedirect(w, "/signup/complete") 105 + } 106 + 107 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 108 + switch r.Method { 109 + case http.MethodGet: 110 + s.pages.CompleteSignup(w, pages.SignupParams{}) 111 + case http.MethodPost: 112 + username := r.FormValue("username") 113 + password := r.FormValue("password") 114 + code := r.FormValue("code") 115 + 116 + if !userutil.IsValidSubdomain(username) { 117 + 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.") 118 + return 119 + } 120 + 121 + email, err := db.GetEmailForCode(s.db, code) 122 + if err != nil { 123 + s.l.Error("failed to get email for code", "error", err) 124 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 125 + return 126 + } 127 + 128 + did, err := s.createAccountRequest(username, password, email, code) 129 + if err != nil { 130 + s.l.Error("failed to create account", "error", err) 131 + s.pages.Notice(w, "signup-error", err.Error()) 132 + return 133 + } 134 + 135 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 136 + Type: "TXT", 137 + Name: "_atproto." + username, 138 + Content: "did=" + did, 139 + TTL: 6400, 140 + Proxied: false, 141 + }) 142 + if err != nil { 143 + s.l.Error("failed to create DNS record", "error", err) 144 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 145 + return 146 + } 147 + 148 + err = db.AddEmail(s.db, db.Email{ 149 + Did: did, 150 + Address: email, 151 + Verified: true, 152 + Primary: true, 153 + }) 154 + if err != nil { 155 + s.l.Error("failed to add email", "error", err) 156 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 157 + return 158 + } 159 + 160 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 161 + <a class="underline text-black dark:text-white" href="/login">login</a> 162 + with <code>%s.tngl.sh</code>.`, username)) 163 + 164 + go func() { 165 + err := db.DeleteInflightSignup(s.db, email) 166 + if err != nil { 167 + s.l.Error("failed to delete inflight signup", "error", err) 168 + } 169 + }() 170 + return 171 + } 172 + }
+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) ··· 217 219 pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 218 220 return pipes.Router(mw) 219 221 } 222 + 223 + func (s *State) SignupRouter() http.Handler { 224 + logger := log.New("signup") 225 + 226 + sig := signup.New(s.config, s.cf, s.db, s.posthog, s.idResolver, s.pages, logger) 227 + return sig.Router() 228 + }
+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/notify" 24 25 "tangled.sh/tangled.sh/core/appview/oauth" 25 26 "tangled.sh/tangled.sh/core/appview/pages" 26 - posthog_service "tangled.sh/tangled.sh/core/appview/posthog" 27 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 27 28 "tangled.sh/tangled.sh/core/appview/reporesolver" 28 29 "tangled.sh/tangled.sh/core/eventconsumer" 29 30 "tangled.sh/tangled.sh/core/idresolver" ··· 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...) 141 + 142 + cf, err := dns.NewCloudflare(config) 143 + if err != nil { 144 + return nil, fmt.Errorf("failed to create Cloudflare client: %w", err) 145 + } 139 146 140 147 state := &State{ 141 148 d, ··· 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 + }
+2
go.mod
··· 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 15 16 github.com/cyphar/filepath-securejoin v0.4.1 16 17 github.com/dgraph-io/ristretto v0.2.0 17 18 github.com/docker/docker v28.2.2+incompatible ··· 85 86 github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 86 87 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 87 88 github.com/golang/mock v1.6.0 // indirect 89 + github.com/google/go-querystring v1.1.0 // indirect 88 90 github.com/gorilla/css v1.0.1 // indirect 89 91 github.com/gorilla/securecookie v1.1.2 // indirect 90 92 github.com/hashicorp/errwrap v1.1.0 // indirect
+5
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.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 55 github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/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= ··· 152 154 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 153 155 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 154 156 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 157 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 155 158 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 156 159 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 157 160 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 158 161 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 159 162 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 163 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 164 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 160 165 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 161 166 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 162 167 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=