diff --git a/.github/workflows/gen_ai_review.yaml b/.github/workflows/gen_ai_review.yaml
new file mode 100644
index 00000000..9d76ea1f
--- /dev/null
+++ b/.github/workflows/gen_ai_review.yaml
@@ -0,0 +1,28 @@
+---
+name: "gen: AI review"
+on:
+ pull_request:
+ types: [opened, reopened, ready_for_review]
+ issue_comment:
+jobs:
+ pr_agent_job:
+ if: ${{ github.event.sender.type != 'Bot' }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ issues: write
+ pull-requests: write
+ contents: write
+ name: Run pr agent on every pull request, respond to user comments
+ steps:
+ - name: PR Agent action step
+ id: pragent
+ uses: Codium-ai/pr-agent@v0.24
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ config.max_model_tokens: 100000
+ config.model: "anthropic/claude-3-5-sonnet-20240620"
+ config.model_turbo: "anthropic/claude-3-5-sonnet-20240620"
+ config.ignore.glob: "['vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
diff --git a/build/dev/docker/docker-compose.yaml b/build/dev/docker/docker-compose.yaml
index c75cf7a9..8534dd81 100644
--- a/build/dev/docker/docker-compose.yaml
+++ b/build/dev/docker/docker-compose.yaml
@@ -160,5 +160,15 @@ services:
SMTP_USER: user
restart: always
+ nginx:
+ image: nginx:alpine
+ ports:
+ - "443:443"
+ volumes:
+ - ./nginx/html:/usr/share/nginx/html
+ - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
+ - ./nginx//ssl:/etc/nginx/ssl
+ restart: unless-stopped
+
volumes:
pgdata: {}
diff --git a/build/dev/docker/nginx/html/index.html b/build/dev/docker/nginx/html/index.html
new file mode 100644
index 00000000..d228779d
--- /dev/null
+++ b/build/dev/docker/nginx/html/index.html
@@ -0,0 +1,95 @@
+
+
+
+
+
+ Sign Up Form
+
+
+
+
+
+
+
+
+
diff --git a/build/dev/docker/nginx/nginx.conf b/build/dev/docker/nginx/nginx.conf
new file mode 100644
index 00000000..f4f2c71b
--- /dev/null
+++ b/build/dev/docker/nginx/nginx.conf
@@ -0,0 +1,41 @@
+events {
+ worker_connections 1024;
+}
+
+http {
+ server {
+ listen 443 ssl;
+ server_name localhost;
+
+ ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
+ ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
+
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_prefer_server_ciphers on;
+ ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
+
+ location / {
+ root /usr/share/nginx/html;
+ index index.html;
+
+ add_header 'Access-Control-Allow-Origin' 'https://challenges.cloudflare.com';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
+ add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
+
+
+ add_header Content-Security-Policy "default-src 'self'; frame-src 'self' https://challenges.cloudflare.com; frame-ancestors 'self' https://challenges.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com; style-src 'self' 'unsafe-inline' https://challenges.cloudflare.com; img-src 'self' data: https:; font-src 'self' https:; connect-src 'self' http://localhost:4000 https://challenges.cloudflare.com;";
+
+ if ($request_method = 'OPTIONS') {
+ add_header 'Access-Control-Allow-Origin' 'https://challenges.cloudflare.com';
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
+ add_header 'Access-Control-Max-Age' 1728000;
+ add_header 'Content-Type' 'text/plain; charset=utf-8';
+ add_header 'Content-Length' 0;
+ return 204;
+ }
+ }
+
+ }
+}
diff --git a/build/dev/docker/nginx/ssl/nginx-selfsigned.crt b/build/dev/docker/nginx/ssl/nginx-selfsigned.crt
new file mode 100644
index 00000000..c960fc93
--- /dev/null
+++ b/build/dev/docker/nginx/ssl/nginx-selfsigned.crt
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDSDCCAjACCQDGV/un6TjVazANBgkqhkiG9w0BAQsFADBmMQswCQYDVQQGEwJV
+UzEOMAwGA1UECAwFU3RhdGUxDTALBgNVBAcMBENpdHkxFTATBgNVBAoMDE9yZ2Fu
+aXphdGlvbjENMAsGA1UECwwEVW5pdDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI0
+MDkyNTA4MTA0NVoXDTI1MDkyNTA4MTA0NVowZjELMAkGA1UEBhMCVVMxDjAMBgNV
+BAgMBVN0YXRlMQ0wCwYDVQQHDARDaXR5MRUwEwYDVQQKDAxPcmdhbml6YXRpb24x
+DTALBgNVBAsMBFVuaXQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcN
+AQEBBQADggEPADCCAQoCggEBAMLzWZAlYj7FMsBHFbiRPNebUTYiVmDiyH/Yk/eG
+JhS4Rpw2UjI9LpBgGOJKrPRAD14KBjNHJSSTVrPV6l2kdsugDJDwDUhWlRxng1+Z
+zP6MNB9WME6wOFxiUARHumHaABqd/8zY4Ws1DJbPgyXgARmv+2PaeFMOrTsBn70N
+Og5s7JZC0V9u7vRxsbrmmWiq9Ew3GOO3RnPg+6gmrxQEo2K85h0npOeCRtP3zfd+
+pAUnujstk+75gqAEPlkf+DrUmg8opd4CquoRg2dVai2YwIdvtMqE0SWgZs/wbaOg
+gATdOtp8T8YKuyTZ7cTnQRHZJFVMkzfZ6/WCLcysHgtItTMCAwEAATANBgkqhkiG
+9w0BAQsFAAOCAQEAXIDN3YBDj+QF+L1F1PlV54/hUv3vXoU35hTfKLu09k76n7wD
+pYLHj7/pdZtuqt+0PCllfGBXnCMMtFeH+ZSUZciSinoDg4fpUUT9BnhUVr9MvESu
+1fa64aYsOY9KYi8I9LpdIYs3DfpVuAKxFOOW2vH54+HaZMKfGH3ZZKQ8QL+KZzk4
+zxaCkl2jCFwD2cCuvc/wu5EBGcjkv429eapFp/8lXqv9YbiW2833t0r4rkEUroP1
+Dbm+kf6AUt7tm5AkxvRmbtKjQ8l5kB+GnHVMo+eltmesb8HHMYJqOBbIOTOUz6b8
+uPJWrTgLvYT2El87a3pFL2XP4PkV9igakf94gg==
+-----END CERTIFICATE-----
diff --git a/build/dev/docker/nginx/ssl/nginx-selfsigned.key b/build/dev/docker/nginx/ssl/nginx-selfsigned.key
new file mode 100644
index 00000000..014e6d94
--- /dev/null
+++ b/build/dev/docker/nginx/ssl/nginx-selfsigned.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDC81mQJWI+xTLA
+RxW4kTzXm1E2IlZg4sh/2JP3hiYUuEacNlIyPS6QYBjiSqz0QA9eCgYzRyUkk1az
+1epdpHbLoAyQ8A1IVpUcZ4Nfmcz+jDQfVjBOsDhcYlAER7ph2gAanf/M2OFrNQyW
+z4Ml4AEZr/tj2nhTDq07AZ+9DToObOyWQtFfbu70cbG65ploqvRMNxjjt0Zz4Puo
+Jq8UBKNivOYdJ6TngkbT9833fqQFJ7o7LZPu+YKgBD5ZH/g61JoPKKXeAqrqEYNn
+VWotmMCHb7TKhNEloGbP8G2joIAE3TrafE/GCrsk2e3E50ER2SRVTJM32ev1gi3M
+rB4LSLUzAgMBAAECggEAHnzCD+nYwGpEco9rVS7ZbfprK+UYzxQIOP4cvcPr5qee
+20Ufe53Xz1pH6fO1sojmHlXA/Hnu1BZ6o6sbeMOElHmWHYB9A0gPD58ci3HY/iHc
+8N2gtl2WotP5IYI6Ip1eEMuOunFcZ1CnhFo1b1HluiesT8RVtt9/tc+eNImB/8lA
+/FFGn6/Q78xeS9MbwFEpEXgEaBX3DKEcmfwcvu5fDpc12koYWenLGTor16fepJ5a
+rFI64yCxDAg3A0mRdiY6RlgmFvqlm9J3BdO+np/GrE4QkrmE78LuX8mToMwnE8Q9
+vZeh731WNv2EzJsewy+ayyh/RIEH5PUAgVt+qLP+AQKBgQDujnQzgoBEgA7XTTiz
+w6I90lvFxw3DR7ptc4ai79gz/cTzoJoIGpcjI2cyDpcGL5RuBh06L8Y64NVQxp29
+jnGFt9Hil/2ZomYlCnc3OUiaTQZnn98k4jPsLNwB8Ng+AZYHMVuJ2qut2x0KDUz2
+e6F/N7h5VGjLFZnM1w80T3TIMwKBgQDRNKLbSPOLxCiNV2OF5KfDbIQt6sWPMnDo
+zGyGQgT4WZjLdVX4a3NYKwaHVQq6bASqbr2X1Vdk5zEMzqqTM+AvFA/9OCne1mrH
+fPISBimZzLB3iNyu2qTmrJKZcuTV/Bn6lwjbH2sy2Ms1CugQVJDd+LQjJ7/ZaaoM
+BmxUj1NfAQKBgQCLjSYI4/SpHciQxom/D1iflak9/33bmOBEGurN8kSl1XQbmP3C
+c9uqIJHDxKkwYzEPU+BRI5Vw6AmhoS6xrtxV/vx287bU4x2h2Yd39Li2Gwz+HZXp
++7GoHW3ubLfzPfZH6uXDtPntUFqigLlfD1+gDjaKM4jCFLbOD5jDXx/P5QKBgQCM
+BylCkY/Ca2ehQ27v/d50pbvLaCsX7/E3QS0aqDHfcUkeVclXX8RyrUnPZ5KouQhe
+c4UfjcLTXROtuN7fbIePu2QAX4lXCDmskOsOOWW69hDf0ZG0z9A0PipZ31dgCz/w
+RQu+b0c3X3iUZlpyI8hbas5YAZEeGuWg6uOzrcNmAQKBgAf15LHx7IVEeIfn8ta/
+YnZnODsvN5+4ybMIIuX8yAPmXOy3klYvp1+8BH6jZMVHXAqQugooGirfik02RzQv
+tCbCXDoTSMzMQU5iSlvnxtcug93rWa4VFvmU9nSqp7lpJQmiOBwWFf2/mmSwyLc9
+MvOVZTA46IefrGuG64T83RRf
+-----END PRIVATE KEY-----
diff --git a/go/cmd/serve.go b/go/cmd/serve.go
index 5efaf430..1f858ec5 100644
--- a/go/cmd/serve.go
+++ b/go/cmd/serve.go
@@ -91,6 +91,7 @@ const (
flagRateLimitSignupsInterval = "rate-limit-signups-interval"
flagRateLimitMemcacheServer = "rate-limit-memcache-server"
flagRateLimitMemcachePrefix = "rate-limit-memcache-prefix"
+ flagTurnstileSecret = "turnstile-secret"
)
func CommandServe() *cli.Command { //nolint:funlen,maintidx
@@ -554,6 +555,12 @@ func CommandServe() *cli.Command { //nolint:funlen,maintidx
Category: "rate-limit",
EnvVars: []string{"AUTH_RATE_LIMIT_MEMCACHE_PREFIX"},
},
+ &cli.StringFlag{ //nolint: exhaustruct
+ Name: flagTurnstileSecret,
+ Usage: "Turnstile secret. If passed, enable Cloudflare's turnstile for signup methods. The header `X-Cf-Turnstile-Response ` will have to be included in the request for verification",
+ Category: "turnstile",
+ EnvVars: []string{"AUTH_TURNSTILE_SECRET"},
+ },
},
Action: serve,
}
@@ -653,6 +660,12 @@ func getGoServer( //nolint:funlen
handlers = append(handlers, getRateLimiter(cCtx, logger))
}
+ if cCtx.String(flagTurnstileSecret) != "" {
+ handlers = append(handlers, middleware.Tunrstile(
+ cCtx.String(flagTurnstileSecret), cCtx.String(flagAPIPrefix)),
+ )
+ }
+
router.Use(handlers...)
emailer, err := getEmailer(cCtx, logger)
diff --git a/go/middleware/turnstile.go b/go/middleware/turnstile.go
new file mode 100644
index 00000000..3cbdd234
--- /dev/null
+++ b/go/middleware/turnstile.go
@@ -0,0 +1,118 @@
+package middleware
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+var ErrTurnstileFailed = errors.New("failed to pass turnstile")
+
+const (
+ tunrstileURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
+)
+
+type TurnstileResponse struct {
+ Success bool `json:"success"`
+ ErrorCodes []string `json:"error-codes"`
+ Messages []string `json:"messages"`
+ Raw []byte
+}
+
+func makeTurnstileRequest(
+ ctx context.Context,
+ cl *http.Client,
+ secret string,
+ tokenResponse string,
+) (*TurnstileResponse, error) {
+ request, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ tunrstileURL,
+ bytes.NewBufferString(`{"secret": "`+secret+`","response":"`+tokenResponse+`"}`),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create turnstile request: %w", err)
+ }
+
+ request.Header.Set("Content-Type", "application/json")
+
+ response, err := cl.Do(request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send turnstile request: %w", err)
+ }
+ defer response.Body.Close()
+
+ b, err := io.ReadAll(response.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read turnstile response: %w", err)
+ }
+
+ var turnstileResponse TurnstileResponse
+ if err := json.Unmarshal(b, &turnstileResponse); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal turnstile response: %w", err)
+ }
+
+ turnstileResponse.Raw = b
+
+ return &turnstileResponse, nil
+}
+
+func Tunrstile(secret string, prefix string) gin.HandlerFunc {
+ cl := http.Client{} //nolint:exhaustruct
+
+ return func(ctx *gin.Context) {
+ if !strings.HasPrefix(ctx.Request.URL.Path, prefix+"/signup/") ||
+ strings.HasSuffix(ctx.Request.URL.Path, "/verify") ||
+ strings.HasSuffix(ctx.Request.URL.Path, "/callback") {
+ ctx.Next()
+ return
+ }
+
+ token := ctx.Request.Header.Get("x-cf-turnstile-response")
+
+ if token == "" {
+ _ = ctx.Error(
+ fmt.Errorf("%w: missing x-cf-turnstile-response header", ErrTurnstileFailed),
+ )
+ ctx.AbortWithStatusJSON(
+ http.StatusForbidden,
+ gin.H{"error": "missing x-cf-turnstile-response header"},
+ )
+ return
+ }
+
+ turnstileResponse, err := makeTurnstileRequest(ctx.Request.Context(), &cl, secret, token)
+ if err != nil {
+ _ = ctx.Error(fmt.Errorf("%w: %w", ErrTurnstileFailed, err))
+ ctx.AbortWithStatusJSON(
+ http.StatusInternalServerError,
+ gin.H{"error": "internal server error when attempting to pass turnstile"},
+ )
+ return
+ }
+
+ if !turnstileResponse.Success {
+ _ = ctx.Error(fmt.Errorf("%w: %s", ErrTurnstileFailed, string(turnstileResponse.Raw)))
+ ctx.AbortWithStatusJSON(
+ http.StatusForbidden,
+ gin.H{
+ "error": fmt.Sprintf(
+ "failed to pass turnstile: %v",
+ turnstileResponse.Messages,
+ ),
+ },
+ )
+ return
+ }
+
+ ctx.Next()
+ }
+}