+12
appview/config/config.go
+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
+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
+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
+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
+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
+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
+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 · 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
+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 · tangled</title>
28
+
<title>login or sign up · 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
+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
+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
+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
+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
+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
+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
+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=