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 + + + + +
+

Sign Up

+ + +
+ +
+ + + + 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() + } +}