+4
-2
appview/config/config.go
+4
-2
appview/config/config.go
···
72
72
}
73
73
74
74
type Cloudflare struct {
75
-
ApiToken string `env:"API_TOKEN"`
76
-
ZoneId string `env:"ZONE_ID"`
75
+
ApiToken string `env:"API_TOKEN"`
76
+
ZoneId string `env:"ZONE_ID"`
77
+
TurnstileSiteKey string `env:"TURNSTILE_SITE_KEY"`
78
+
TurnstileSecretKey string `env:"TURNSTILE_SECRET_KEY"`
77
79
}
78
80
79
81
func (cfg RedisConfig) ToURL() string {
+6
-2
appview/pages/pages.go
+6
-2
appview/pages/pages.go
···
226
226
return p.executePlain("user/login", w, params)
227
227
}
228
228
229
-
func (p *Pages) Signup(w io.Writer) error {
230
-
return p.executePlain("user/signup", w, nil)
229
+
type SignupParams struct {
230
+
CloudflareSiteKey string
231
+
}
232
+
233
+
func (p *Pages) Signup(w io.Writer, params SignupParams) error {
234
+
return p.executePlain("user/signup", w, params)
231
235
}
232
236
233
237
func (p *Pages) CompleteSignup(w io.Writer) error {
+1
-1
appview/pages/templates/user/login.html
+1
-1
appview/pages/templates/user/login.html
···
36
36
placeholder="akshay.tngl.sh"
37
37
/>
38
38
<span class="text-sm text-gray-500 mt-1">
39
-
Use your <a href="https://atproto.com">ATProto</a>
39
+
Use your <a href="https://atproto.com">AT Protocol</a>
40
40
handle to log in. If you're unsure, this is likely
41
41
your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account.
42
42
</span>
+6
-1
appview/pages/templates/user/signup.html
+6
-1
appview/pages/templates/user/signup.html
···
10
10
<script src="/static/htmx.min.js"></script>
11
11
<link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
12
12
<title>sign up · tangled</title>
13
+
14
+
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
13
15
</head>
14
16
<body class="flex items-center justify-center min-h-screen">
15
17
<main class="max-w-md px-6 -mt-4">
···
39
41
invite code, desired username, and password in the next
40
42
page to complete your registration.
41
43
</span>
44
+
<div class="w-full mt-4">
45
+
<div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}"></div>
46
+
</div>
42
47
<button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
43
48
<span>join now</span>
44
49
</button>
45
50
</form>
46
51
<p class="text-sm text-gray-500">
47
-
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
52
+
Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
48
53
</p>
49
54
50
55
<p id="signup-msg" class="error w-full"></p>
+65
-1
appview/signup/signup.go
+65
-1
appview/signup/signup.go
···
2
2
3
3
import (
4
4
"bufio"
5
+
"encoding/json"
6
+
"errors"
5
7
"fmt"
6
8
"log/slog"
7
9
"net/http"
10
+
"net/url"
8
11
"os"
9
12
"strings"
10
13
···
116
119
func (s *Signup) signup(w http.ResponseWriter, r *http.Request) {
117
120
switch r.Method {
118
121
case http.MethodGet:
119
-
s.pages.Signup(w)
122
+
s.pages.Signup(w, pages.SignupParams{
123
+
CloudflareSiteKey: s.config.Cloudflare.TurnstileSiteKey,
124
+
})
120
125
case http.MethodPost:
121
126
if s.cf == nil {
122
127
http.Error(w, "signup is disabled", http.StatusFailedDependency)
128
+
return
123
129
}
124
130
emailId := r.FormValue("email")
131
+
cfToken := r.FormValue("cf-turnstile-response")
125
132
126
133
noticeId := "signup-msg"
134
+
135
+
if err := s.validateCaptcha(cfToken, r); err != nil {
136
+
s.l.Warn("turnstile validation failed", "error", err)
137
+
s.pages.Notice(w, noticeId, "Captcha validation failed.")
138
+
return
139
+
}
140
+
127
141
if !email.IsValidEmail(emailId) {
128
142
s.pages.Notice(w, noticeId, "Invalid email address.")
129
143
return
···
255
269
return
256
270
}
257
271
}
272
+
273
+
type turnstileResponse struct {
274
+
Success bool `json:"success"`
275
+
ErrorCodes []string `json:"error-codes,omitempty"`
276
+
ChallengeTs string `json:"challenge_ts,omitempty"`
277
+
Hostname string `json:"hostname,omitempty"`
278
+
}
279
+
280
+
func (s *Signup) validateCaptcha(cfToken string, r *http.Request) error {
281
+
if cfToken == "" {
282
+
return errors.New("captcha token is empty")
283
+
}
284
+
285
+
if s.config.Cloudflare.TurnstileSecretKey == "" {
286
+
return errors.New("turnstile secret key not configured")
287
+
}
288
+
289
+
data := url.Values{}
290
+
data.Set("secret", s.config.Cloudflare.TurnstileSecretKey)
291
+
data.Set("response", cfToken)
292
+
293
+
// include the client IP if we have it
294
+
if remoteIP := r.Header.Get("CF-Connecting-IP"); remoteIP != "" {
295
+
data.Set("remoteip", remoteIP)
296
+
} else if remoteIP := r.Header.Get("X-Forwarded-For"); remoteIP != "" {
297
+
if ips := strings.Split(remoteIP, ","); len(ips) > 0 {
298
+
data.Set("remoteip", strings.TrimSpace(ips[0]))
299
+
}
300
+
} else {
301
+
data.Set("remoteip", r.RemoteAddr)
302
+
}
303
+
304
+
resp, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", data)
305
+
if err != nil {
306
+
return fmt.Errorf("failed to verify turnstile token: %w", err)
307
+
}
308
+
defer resp.Body.Close()
309
+
310
+
var turnstileResp turnstileResponse
311
+
if err := json.NewDecoder(resp.Body).Decode(&turnstileResp); err != nil {
312
+
return fmt.Errorf("failed to decode turnstile response: %w", err)
313
+
}
314
+
315
+
if !turnstileResp.Success {
316
+
s.l.Warn("turnstile validation failed", "error_codes", turnstileResp.ErrorCodes)
317
+
return errors.New("turnstile validation failed")
318
+
}
319
+
320
+
return nil
321
+
}