private_captcha 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8d114ffe4accff4d744e8a31c2a4db2e19a8415536f90edb03646c84a7d00c16
4
+ data.tar.gz: ac3306f495d28e9af4fde26cc041384ae6fe921755df621dbccca5691235fd17
5
+ SHA512:
6
+ metadata.gz: 3c649faf68705a6eda2bb2e3a93337b2b30269cd91f48b629dd704f9e950b9f882f63b69ff57287e32c540be78b8c35d7714f3f763c7983e95675790106bc014
7
+ data.tar.gz: 4e8aa3fc7a92b88f83085f950438a3cae213c8d41c454b19f947b67531ba3e21f75cc3f3a1b8ea49a139e594272c60b0f7c943b774a2d4bb62765f77db204e69
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 PrivateCaptcha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,165 @@
1
+ # private-captcha-ruby
2
+
3
+ ![CI](https://github.com/PrivateCaptcha/private-captcha-ruby/actions/workflows/ci.yaml/badge.svg)
4
+
5
+ Ruby client for server-side verification of Private Captcha solutions.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your application's Gemfile:
10
+
11
+ ```ruby
12
+ gem 'private_captcha'
13
+ ```
14
+
15
+ And then execute:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ Or install it yourself as:
22
+
23
+ ```bash
24
+ gem install private_captcha
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```ruby
30
+ require 'private_captcha'
31
+
32
+ # Initialize the client with your API key
33
+ client = PrivateCaptcha::Client.new do |config|
34
+ config.api_key = 'your-api-key-here'
35
+ end
36
+
37
+ # Verify a captcha solution
38
+ begin
39
+ result = client.verify('user-solution-from-frontend')
40
+ if result.success
41
+ puts 'Captcha verified successfully!'
42
+ else
43
+ puts "Verification failed: #{result.error_message}"
44
+ end
45
+ rescue PrivateCaptcha::Error => e
46
+ puts "Error: #{e.message}"
47
+ end
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### Web Framework Integration
53
+
54
+ #### Sinatra Example
55
+
56
+ ```ruby
57
+ require 'sinatra'
58
+ require 'private_captcha'
59
+
60
+ client = PrivateCaptcha::Client.new do |config|
61
+ config.api_key = 'your-api-key'
62
+ end
63
+
64
+ post '/submit' do
65
+ begin
66
+ # Verify captcha from form data
67
+ client.verify_request(request)
68
+
69
+ # Process your form data here
70
+ 'Form submitted successfully!'
71
+ rescue PrivateCaptcha::Error
72
+ status 400
73
+ 'Captcha verification failed'
74
+ end
75
+ end
76
+ ```
77
+
78
+ #### Rails Example
79
+
80
+ ```ruby
81
+ class FormsController < ApplicationController
82
+ def submit
83
+ client = PrivateCaptcha::Client.new do |config|
84
+ config.api_key = 'your-api-key'
85
+ end
86
+
87
+ begin
88
+ client.verify_request(request)
89
+ # Process form data
90
+ render plain: 'Success!'
91
+ rescue PrivateCaptcha::Error
92
+ render plain: 'Captcha failed', status: :bad_request
93
+ end
94
+ end
95
+ end
96
+ ```
97
+
98
+ #### Rack Middleware
99
+
100
+ ```ruby
101
+ require 'private_captcha'
102
+
103
+ use PrivateCaptcha::Middleware,
104
+ api_key: 'your-api-key',
105
+ failed_status_code: 403
106
+ ```
107
+
108
+ ## Configuration
109
+
110
+ ### Client Options
111
+
112
+ ```ruby
113
+ require 'private_captcha'
114
+
115
+ client = PrivateCaptcha::Client.new do |config|
116
+ config.api_key = 'your-api-key'
117
+ config.domain = PrivateCaptcha::Configuration::EU_DOMAIN # replace domain for self-hosting or EU isolation
118
+ config.form_field = 'private-captcha-solution' # custom form field name
119
+ config.max_backoff_seconds = 20 # maximum wait between retries
120
+ config.attempts = 5 # number of retry attempts
121
+ config.logger = Logger.new(STDOUT) # optional logger
122
+ end
123
+ ```
124
+
125
+ ### Non-standard backend domains
126
+
127
+ ```ruby
128
+ require 'private_captcha'
129
+
130
+ # Use EU domain
131
+ eu_client = PrivateCaptcha::Client.new do |config|
132
+ config.api_key = 'your-api-key'
133
+ config.domain = PrivateCaptcha::Configuration::EU_DOMAIN # api.eu.privatecaptcha.com
134
+ end
135
+
136
+ # Or specify custom domain in case of self-hosting
137
+ custom_client = PrivateCaptcha::Client.new do |config|
138
+ config.api_key = 'your-api-key'
139
+ config.domain = 'your-custom-domain.com'
140
+ end
141
+ ```
142
+
143
+ ### Retry Configuration
144
+
145
+ ```ruby
146
+ result = client.verify(
147
+ 'solution',
148
+ max_backoff_seconds: 15, # maximum wait between retries
149
+ attempts: 3 # number of retry attempts
150
+ )
151
+ ```
152
+
153
+ ## Requirements
154
+
155
+ - Ruby 3.0+
156
+ - No external dependencies (uses only standard library)
157
+
158
+ ## License
159
+
160
+ This project is licensed under the MIT License - see the LICENSE file for details.
161
+
162
+ ## Support
163
+
164
+ For issues with this Ruby client, please open an issue on GitHub.
165
+ For Private Captcha service questions, visit [privatecaptcha.com](https://privatecaptcha.com).
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'logger'
7
+ require 'timeout'
8
+
9
+ module PrivateCaptcha
10
+ # Client is the main class for verifying Private Captcha solutions
11
+ class Client # rubocop:disable Metrics/ClassLength
12
+ MIN_BACKOFF_MILLIS = 500
13
+
14
+ attr_reader :config
15
+
16
+ def initialize
17
+ @config = Configuration.new
18
+ yield(@config) if block_given?
19
+
20
+ raise EmptyAPIKeyError if @config.api_key.nil? || @config.api_key.empty?
21
+
22
+ @config.domain = normalize_domain(@config.domain)
23
+ @endpoint = URI("https://#{@config.domain}/verify")
24
+ @logger = @config.logger || Logger.new(IO::NULL)
25
+ end
26
+
27
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
28
+ def verify(solution, max_backoff_seconds: nil, attempts: nil)
29
+ raise EmptySolutionError if solution.nil? || solution.empty?
30
+
31
+ max_backoff = max_backoff_seconds || @config.max_backoff_seconds
32
+ max_attempts = attempts || @config.attempts
33
+
34
+ @logger.debug('About to start verifying solution') do
35
+ "maxAttempts=#{max_attempts} maxBackoff=#{max_backoff} solution_length=#{solution.length}"
36
+ end
37
+
38
+ response = nil
39
+ error = nil
40
+ attempt = 0
41
+
42
+ max_attempts.times do |i|
43
+ attempt = i + 1
44
+
45
+ if i.positive?
46
+ backoff_duration = calculate_backoff(i, max_backoff, error)
47
+ @logger.debug('Failed to send verify request') do
48
+ "attempt=#{attempt} backoff=#{backoff_duration}s error=#{error&.message}"
49
+ end
50
+ sleep(backoff_duration)
51
+ end
52
+
53
+ begin
54
+ response = do_verify(solution)
55
+ error = nil
56
+ break
57
+ rescue RetriableError => e
58
+ error = e.original_error
59
+ end
60
+ end
61
+
62
+ @logger.debug('Finished verifying solution') do
63
+ "attempts=#{attempt} success=#{error.nil?}"
64
+ end
65
+
66
+ if error
67
+ @logger.error("Failed to verify solution after #{attempt} attempts")
68
+ raise VerificationFailedError.new("Failed to verify solution after #{attempt} attempts", attempt)
69
+ end
70
+
71
+ response
72
+ end
73
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
74
+
75
+ def verify_request(request, form_field: nil)
76
+ field = form_field || @config.form_field
77
+ solution = extract_form_value(request, field)
78
+
79
+ output = verify(solution)
80
+
81
+ raise Error, "captcha verification failed: #{output.error_message}" unless output.success
82
+
83
+ output
84
+ end
85
+
86
+ private
87
+
88
+ def normalize_domain(domain)
89
+ return Configuration::GLOBAL_DOMAIN if domain.nil? || domain.empty?
90
+
91
+ domain = domain.delete_prefix('https://')
92
+ domain = domain.delete_prefix('http://')
93
+ domain.delete_suffix('/')
94
+ end
95
+
96
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
97
+ def do_verify(solution)
98
+ request = Net::HTTP::Post.new(@endpoint)
99
+ request['X-Api-Key'] = @config.api_key
100
+ request['User-Agent'] = "private-captcha-ruby/#{VERSION}"
101
+ request['Content-Type'] = 'text/plain'
102
+ request.body = solution
103
+
104
+ @logger.debug('Sending HTTP request') { "path=#{@endpoint.path} method=POST" }
105
+
106
+ response = nil
107
+ begin
108
+ response = Net::HTTP.start(@endpoint.hostname, @endpoint.port, use_ssl: true) do |http|
109
+ http.request(request)
110
+ end
111
+ rescue SocketError, IOError, Timeout::Error, SystemCallError => e
112
+ @logger.debug('Failed to send HTTP request') { "error=#{e.message}" }
113
+ raise RetriableError, e
114
+ end
115
+
116
+ @logger.debug('HTTP request finished') do
117
+ "path=#{@endpoint.path} status=#{response.code}"
118
+ end
119
+
120
+ status_code = response.code.to_i
121
+ request_id = response['X-Trace-ID']
122
+
123
+ case status_code
124
+ when 429
125
+ retry_after = parse_retry_after(response['Retry-After'])
126
+ @logger.debug('Rate limited') do
127
+ "retryAfter=#{retry_after} rateLimit=#{response['X-RateLimit-Limit']}"
128
+ end
129
+ raise RetriableError, HTTPError.new(status_code, retry_after)
130
+ when 500, 502, 503, 504, 408, 425
131
+ raise RetriableError, HTTPError.new(status_code)
132
+ when 300..599
133
+ raise HTTPError, status_code
134
+ end
135
+
136
+ begin
137
+ json_data = JSON.parse(response.body)
138
+ VerifyOutput.from_json(json_data, request_id: request_id)
139
+ rescue JSON::ParserError => e
140
+ @logger.debug('Failed to parse response') { "error=#{e.message}" }
141
+ raise RetriableError, e
142
+ end
143
+ end
144
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
145
+
146
+ def parse_retry_after(header)
147
+ return nil if header.nil? || header.empty?
148
+
149
+ Integer(header)
150
+ rescue ArgumentError
151
+ nil
152
+ end
153
+
154
+ def calculate_backoff(attempt, max_backoff, error)
155
+ backoff = (MIN_BACKOFF_MILLIS / 1000.0) * (2**attempt)
156
+
157
+ backoff = [backoff, error.seconds].max if error.is_a?(HTTPError) && error.seconds
158
+
159
+ [backoff, max_backoff].min
160
+ end
161
+
162
+ def extract_form_value(request, field)
163
+ # Support for Rack::Request
164
+ if request.respond_to?(:params)
165
+ request.params[field]
166
+ # Support for Rails ActionDispatch::Request
167
+ elsif request.respond_to?(:[])
168
+ request[field]
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrivateCaptcha
4
+ # Configuration holds the settings for the Private Captcha client
5
+ class Configuration
6
+ GLOBAL_DOMAIN = 'api.privatecaptcha.com'
7
+ EU_DOMAIN = 'api.eu.privatecaptcha.com'
8
+ DEFAULT_FORM_FIELD = 'private-captcha-solution'
9
+ DEFAULT_FAILED_STATUS_CODE = 403
10
+
11
+ attr_accessor :domain, :api_key, :form_field, :failed_status_code,
12
+ :max_backoff_seconds, :attempts, :logger
13
+
14
+ def initialize
15
+ @domain = GLOBAL_DOMAIN
16
+ @api_key = nil
17
+ @form_field = DEFAULT_FORM_FIELD
18
+ @failed_status_code = DEFAULT_FAILED_STATUS_CODE
19
+ @max_backoff_seconds = 20
20
+ @attempts = 5
21
+ @logger = nil
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrivateCaptcha
4
+ class Error < StandardError; end
5
+
6
+ # EmptyAPIKeyError is raised when the API key is not provided or is empty
7
+ class EmptyAPIKeyError < Error
8
+ def initialize(msg = 'API key is empty')
9
+ super
10
+ end
11
+ end
12
+
13
+ # EmptySolutionError is raised when the solution is not provided or is empty
14
+ class EmptySolutionError < Error
15
+ def initialize(msg = 'solution is empty')
16
+ super
17
+ end
18
+ end
19
+
20
+ # HTTPError is raised when an HTTP error occurs during verification
21
+ class HTTPError < Error
22
+ attr_reader :status_code, :seconds
23
+
24
+ def initialize(status_code, seconds = nil)
25
+ @status_code = status_code
26
+ @seconds = seconds
27
+ super("HTTP error #{status_code}")
28
+ end
29
+ end
30
+
31
+ # RetriableError wraps errors that can be retried
32
+ class RetriableError < Error
33
+ attr_reader :original_error
34
+
35
+ def initialize(error)
36
+ @original_error = error
37
+ super(error.message)
38
+ end
39
+ end
40
+
41
+ # VerificationFailedError is raised when verification fails after all retry attempts
42
+ class VerificationFailedError < Error
43
+ attr_reader :attempts
44
+
45
+ def initialize(message, attempts)
46
+ @attempts = attempts
47
+ super(message)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack'
4
+
5
+ module PrivateCaptcha
6
+ # Middleware provides Rack middleware for automatic captcha verification
7
+ class Middleware
8
+ # rubocop:disable Metrics/AbcSize
9
+ def initialize(app, api_key:, **options)
10
+ @app = app
11
+ @client = Client.new do |config|
12
+ config.api_key = api_key
13
+ config.domain = options[:domain] if options[:domain]
14
+ config.form_field = options[:form_field] if options[:form_field]
15
+ config.failed_status_code = options[:failed_status_code] if options[:failed_status_code]
16
+ config.max_backoff_seconds = options[:max_backoff_seconds] if options[:max_backoff_seconds]
17
+ config.attempts = options[:attempts] if options[:attempts]
18
+ config.logger = options[:logger] if options[:logger]
19
+ end
20
+ end
21
+ # rubocop:enable Metrics/AbcSize
22
+
23
+ # rubocop:disable Metrics/MethodLength
24
+ def call(env)
25
+ request = Rack::Request.new(env)
26
+
27
+ begin
28
+ @client.verify_request(request)
29
+ rescue Error
30
+ return [
31
+ @client.config.failed_status_code,
32
+ { 'Content-Type' => 'text/plain' },
33
+ [Rack::Utils::HTTP_STATUS_CODES[@client.config.failed_status_code]]
34
+ ]
35
+ end
36
+
37
+ @app.call(env)
38
+ end
39
+ # rubocop:enable Metrics/MethodLength
40
+ end
41
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrivateCaptcha
4
+ # VerifyOutput represents the result of a captcha verification
5
+ class VerifyOutput
6
+ VERIFY_NO_ERROR = 0
7
+ VERIFY_ERROR_OTHER = 1
8
+ DUPLICATE_SOLUTIONS_ERROR = 2
9
+ INVALID_SOLUTION_ERROR = 3
10
+ PARSE_RESPONSE_ERROR = 4
11
+ PUZZLE_EXPIRED_ERROR = 5
12
+ INVALID_PROPERTY_ERROR = 6
13
+ WRONG_OWNER_ERROR = 7
14
+ VERIFIED_BEFORE_ERROR = 8
15
+ MAINTENANCE_MODE_ERROR = 9
16
+ TEST_PROPERTY_ERROR = 10
17
+ INTEGRITY_ERROR = 11
18
+ VERIFY_CODES_COUNT = 12
19
+
20
+ ERROR_MESSAGES = {
21
+ VERIFY_NO_ERROR => '',
22
+ VERIFY_ERROR_OTHER => 'error-other',
23
+ DUPLICATE_SOLUTIONS_ERROR => 'solution-duplicates',
24
+ INVALID_SOLUTION_ERROR => 'solution-invalid',
25
+ PARSE_RESPONSE_ERROR => 'solution-bad-format',
26
+ PUZZLE_EXPIRED_ERROR => 'puzzle-expired',
27
+ INVALID_PROPERTY_ERROR => 'property-invalid',
28
+ WRONG_OWNER_ERROR => 'property-owner-mismatch',
29
+ VERIFIED_BEFORE_ERROR => 'solution-verified-before',
30
+ MAINTENANCE_MODE_ERROR => 'maintenance-mode',
31
+ TEST_PROPERTY_ERROR => 'property-test',
32
+ INTEGRITY_ERROR => 'integrity-error'
33
+ }.freeze
34
+
35
+ attr_accessor :success, :code, :origin, :timestamp
36
+ attr_reader :request_id, :attempt
37
+
38
+ def initialize(success: false, code: VERIFY_NO_ERROR, origin: nil, timestamp: nil, request_id: nil, attempt: 0)
39
+ @success = success
40
+ @code = code
41
+ @origin = origin
42
+ @timestamp = timestamp
43
+ @request_id = request_id
44
+ @attempt = attempt
45
+ end
46
+
47
+ def error_message
48
+ ERROR_MESSAGES.fetch(@code, 'error')
49
+ end
50
+
51
+ def self.from_json(json_data, request_id: nil, attempt: 0)
52
+ new(
53
+ success: json_data['success'],
54
+ code: json_data['code'] || VERIFY_NO_ERROR,
55
+ origin: json_data['origin'],
56
+ timestamp: json_data['timestamp'],
57
+ request_id: request_id,
58
+ attempt: attempt
59
+ )
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrivateCaptcha
4
+ VERSION = '0.0.1'
5
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'private_captcha/version'
4
+ require_relative 'private_captcha/errors'
5
+ require_relative 'private_captcha/verify_output'
6
+ require_relative 'private_captcha/configuration'
7
+ require_relative 'private_captcha/client'
8
+ require_relative 'private_captcha/middleware'
9
+
10
+ # PrivateCaptcha is a Ruby client library for integrating Private Captcha
11
+ # verification into your applications. It provides a simple API for verifying
12
+ # captcha solutions and can be used as Rack middleware.
13
+ module PrivateCaptcha
14
+ end
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: private_captcha
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Taras Kushnir
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-10-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ description: A Ruby library for integrating Private Captcha verification into your
28
+ applications
29
+ email:
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE
35
+ - README.md
36
+ - lib/private_captcha.rb
37
+ - lib/private_captcha/client.rb
38
+ - lib/private_captcha/configuration.rb
39
+ - lib/private_captcha/errors.rb
40
+ - lib/private_captcha/middleware.rb
41
+ - lib/private_captcha/verify_output.rb
42
+ - lib/private_captcha/version.rb
43
+ homepage: https://privatecaptcha.com
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://privatecaptcha.com
48
+ source_code_uri: https://github.com/PrivateCaptcha/private-captcha-ruby
49
+ rubygems_mfa_required: 'true'
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 2.7.0
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.4.20
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Ruby client for server-side Private Captcha API
69
+ test_files: []