aris 1.3.0
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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +342 -0
- data/lib/aris/adapters/base.rb +25 -0
- data/lib/aris/adapters/joys_integration.rb +94 -0
- data/lib/aris/adapters/mock/adapter.rb +141 -0
- data/lib/aris/adapters/mock/request.rb +81 -0
- data/lib/aris/adapters/mock/response.rb +17 -0
- data/lib/aris/adapters/rack/adapter.rb +117 -0
- data/lib/aris/adapters/rack/request.rb +66 -0
- data/lib/aris/adapters/rack/response.rb +16 -0
- data/lib/aris/core.rb +931 -0
- data/lib/aris/discovery.rb +312 -0
- data/lib/aris/locale_injector.rb +39 -0
- data/lib/aris/pipeline_runner.rb +100 -0
- data/lib/aris/plugins/api_key_auth.rb +61 -0
- data/lib/aris/plugins/basic_auth.rb +68 -0
- data/lib/aris/plugins/bearer_auth.rb +64 -0
- data/lib/aris/plugins/cache.rb +120 -0
- data/lib/aris/plugins/compression.rb +96 -0
- data/lib/aris/plugins/cookies.rb +46 -0
- data/lib/aris/plugins/cors.rb +81 -0
- data/lib/aris/plugins/csrf.rb +48 -0
- data/lib/aris/plugins/etag.rb +90 -0
- data/lib/aris/plugins/flash.rb +124 -0
- data/lib/aris/plugins/form_parser.rb +46 -0
- data/lib/aris/plugins/health_check.rb +62 -0
- data/lib/aris/plugins/json.rb +32 -0
- data/lib/aris/plugins/multipart.rb +160 -0
- data/lib/aris/plugins/rate_limiter.rb +60 -0
- data/lib/aris/plugins/request_id.rb +38 -0
- data/lib/aris/plugins/request_logger.rb +43 -0
- data/lib/aris/plugins/security_headers.rb +99 -0
- data/lib/aris/plugins/session.rb +175 -0
- data/lib/aris/plugins.rb +23 -0
- data/lib/aris/response_helpers.rb +156 -0
- data/lib/aris/route_helpers.rb +141 -0
- data/lib/aris/utils/redirects.rb +44 -0
- data/lib/aris/utils/sitemap.rb +84 -0
- data/lib/aris/version.rb +3 -0
- data/lib/aris.rb +35 -0
- metadata +151 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lib/aris/plugins/cache.rb
|
|
2
|
+
require 'digest'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Plugins
|
|
6
|
+
class Cache
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(**config)
|
|
10
|
+
@config = config
|
|
11
|
+
@ttl = config[:ttl] || 60 # Default 1 minute
|
|
12
|
+
@store = config[:store] || {} # In-memory hash
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@skip_paths = Array(config[:skip_paths] || [])
|
|
15
|
+
@cache_control = config[:cache_control]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(request, response)
|
|
19
|
+
# Only cache GET requests
|
|
20
|
+
return nil unless request.method == 'GET'
|
|
21
|
+
|
|
22
|
+
# Skip if path matches skip pattern
|
|
23
|
+
return nil if @skip_paths.any? { |pattern| request.path.match?(pattern) }
|
|
24
|
+
|
|
25
|
+
# Check if client sent Cache-Control: no-cache
|
|
26
|
+
cache_control = request.headers['HTTP_CACHE_CONTROL']
|
|
27
|
+
return nil if cache_control&.include?('no-cache')
|
|
28
|
+
|
|
29
|
+
# Generate cache key
|
|
30
|
+
cache_key = generate_cache_key(request)
|
|
31
|
+
|
|
32
|
+
# Try to get from cache
|
|
33
|
+
cached = get_from_cache(cache_key)
|
|
34
|
+
if cached
|
|
35
|
+
# Cache hit - restore response from cache
|
|
36
|
+
response.status = cached[:status]
|
|
37
|
+
response.headers.merge!(cached[:headers])
|
|
38
|
+
response.body = cached[:body]
|
|
39
|
+
response.headers['X-Cache'] = 'HIT'
|
|
40
|
+
|
|
41
|
+
return response # Halt pipeline
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Cache miss - store request info for response phase
|
|
45
|
+
request.instance_variable_set(:@cache_key, cache_key)
|
|
46
|
+
|
|
47
|
+
nil # Continue to handler
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Response plugin - cache the result
|
|
51
|
+
def call_response(request, response)
|
|
52
|
+
# Only cache successful GET responses
|
|
53
|
+
return unless request.method == 'GET'
|
|
54
|
+
return unless response.status == 200
|
|
55
|
+
|
|
56
|
+
cache_key = request.instance_variable_get(:@cache_key)
|
|
57
|
+
return unless cache_key
|
|
58
|
+
|
|
59
|
+
# Store in cache
|
|
60
|
+
set_in_cache(cache_key, {
|
|
61
|
+
status: response.status,
|
|
62
|
+
headers: response.headers.dup,
|
|
63
|
+
body: response.body.dup
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Add cache headers
|
|
67
|
+
response.headers['X-Cache'] = 'MISS'
|
|
68
|
+
if @cache_control
|
|
69
|
+
response.headers['Cache-Control'] = @cache_control
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.build(**config)
|
|
74
|
+
new(**config)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# For testing: clear cache
|
|
78
|
+
def clear!
|
|
79
|
+
@mutex.synchronize { @store.clear }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def generate_cache_key(request)
|
|
85
|
+
# Include domain, path, and query string
|
|
86
|
+
key_string = "#{request.domain}:#{request.path}"
|
|
87
|
+
key_string += "?#{request.query}" unless request.query.nil? || request.query.empty?
|
|
88
|
+
|
|
89
|
+
Digest::MD5.hexdigest(key_string)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def get_from_cache(key)
|
|
93
|
+
@mutex.synchronize do
|
|
94
|
+
entry = @store[key]
|
|
95
|
+
return nil unless entry
|
|
96
|
+
|
|
97
|
+
# Check if expired
|
|
98
|
+
if Time.now > entry[:expires_at]
|
|
99
|
+
@store.delete(key)
|
|
100
|
+
return nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
entry[:value]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def set_in_cache(key, value)
|
|
108
|
+
@mutex.synchronize do
|
|
109
|
+
@store[key] = {
|
|
110
|
+
value: value,
|
|
111
|
+
expires_at: Time.now + @ttl
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
Aris.register_plugin(:cache, plugin_class: Aris::Plugins::Cache)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# lib/aris/plugins/compression.rb
|
|
2
|
+
require 'zlib'
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Aris
|
|
6
|
+
module Plugins
|
|
7
|
+
class Compression
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
# Compressible content types
|
|
11
|
+
COMPRESSIBLE_TYPES = [
|
|
12
|
+
'text/',
|
|
13
|
+
'application/json',
|
|
14
|
+
'application/javascript',
|
|
15
|
+
'application/xml',
|
|
16
|
+
'application/xhtml+xml'
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(**config)
|
|
20
|
+
@config = config
|
|
21
|
+
@level = config[:level] || Zlib::DEFAULT_COMPRESSION
|
|
22
|
+
@min_size = config[:min_size] || 1024 # Don't compress < 1KB
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Request hook - not used, but kept for compatibility
|
|
26
|
+
def call(request, response)
|
|
27
|
+
nil # Do nothing on request phase
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Response hook - runs after handler
|
|
31
|
+
def call_response(request, response)
|
|
32
|
+
# Only compress if client accepts gzip
|
|
33
|
+
accept_encoding = request.headers['HTTP_ACCEPT_ENCODING'] || ''
|
|
34
|
+
return unless accept_encoding.include?('gzip')
|
|
35
|
+
|
|
36
|
+
# Only compress if we have a body
|
|
37
|
+
return if response.body.nil? || response.body.empty?
|
|
38
|
+
|
|
39
|
+
# Get body as string
|
|
40
|
+
body_string = response.body.join
|
|
41
|
+
|
|
42
|
+
# Skip if too small
|
|
43
|
+
return if body_string.bytesize < @min_size
|
|
44
|
+
|
|
45
|
+
# Only compress compressible content types
|
|
46
|
+
content_type = response.headers['content-type'] || ''
|
|
47
|
+
return unless compressible?(content_type)
|
|
48
|
+
|
|
49
|
+
# Compress the body
|
|
50
|
+
compressed = compress_gzip(body_string)
|
|
51
|
+
|
|
52
|
+
# Only use compression if it actually saves space
|
|
53
|
+
if compressed.bytesize < body_string.bytesize
|
|
54
|
+
response.body = [compressed]
|
|
55
|
+
response.headers['Content-Encoding'] = 'gzip'
|
|
56
|
+
response.headers['Vary'] = add_vary_header(response.headers['Vary'])
|
|
57
|
+
response.headers.delete('Content-Length') # Will be recalculated by server
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.build(**config)
|
|
62
|
+
new(**config)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def compressible?(content_type)
|
|
68
|
+
COMPRESSIBLE_TYPES.any? { |type| content_type.start_with?(type) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def compress_gzip(data)
|
|
72
|
+
output = StringIO.new
|
|
73
|
+
output.set_encoding('ASCII-8BIT')
|
|
74
|
+
|
|
75
|
+
gz = Zlib::GzipWriter.new(output, @level)
|
|
76
|
+
gz.write(data)
|
|
77
|
+
gz.close
|
|
78
|
+
|
|
79
|
+
output.string
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def add_vary_header(existing_vary)
|
|
83
|
+
if existing_vary.nil? || existing_vary.empty?
|
|
84
|
+
'Accept-Encoding'
|
|
85
|
+
elsif existing_vary.include?('Accept-Encoding')
|
|
86
|
+
existing_vary
|
|
87
|
+
else
|
|
88
|
+
"#{existing_vary}, Accept-Encoding"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Self-register
|
|
96
|
+
Aris.register_plugin(:compression, plugin_class: Aris::Plugins::Compression)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# lib/aris/plugins/cookies.rb
|
|
2
|
+
module Aris
|
|
3
|
+
module Plugins
|
|
4
|
+
class Cookies
|
|
5
|
+
def self.call(request, response)
|
|
6
|
+
# Only add cookie helpers when plugin is used
|
|
7
|
+
add_cookie_helpers(request, response)
|
|
8
|
+
nil # Continue pipeline
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.build(**config)
|
|
12
|
+
self
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def self.add_cookie_helpers(request, response)
|
|
18
|
+
# Add cookie writing methods to response
|
|
19
|
+
response.define_singleton_method(:set_cookie) do |name, value, options = {}|
|
|
20
|
+
default_options = Aris::Config.cookie_options || {}
|
|
21
|
+
merged_options = default_options.merge(options)
|
|
22
|
+
|
|
23
|
+
cookie_parts = ["#{name}=#{value}"]
|
|
24
|
+
cookie_parts << "Path=#{merged_options[:path]}" if merged_options[:path]
|
|
25
|
+
cookie_parts << "HttpOnly" if merged_options[:httponly]
|
|
26
|
+
cookie_parts << "Secure" if merged_options[:secure]
|
|
27
|
+
cookie_parts << "Max-Age=#{merged_options[:max_age]}" if merged_options[:max_age]
|
|
28
|
+
cookie_parts << "SameSite=#{merged_options[:same_site]}" if merged_options[:same_site]
|
|
29
|
+
|
|
30
|
+
cookie_string = cookie_parts.join("; ")
|
|
31
|
+
|
|
32
|
+
if headers['Set-Cookie']
|
|
33
|
+
headers['Set-Cookie'] = [headers['Set-Cookie'], cookie_string].join(", ")
|
|
34
|
+
else
|
|
35
|
+
headers['Set-Cookie'] = cookie_string
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
response.define_singleton_method(:delete_cookie) do |name, options = {}|
|
|
40
|
+
set_cookie(name, "", options.merge(max_age: 0))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
Aris.register_plugin(:cookies, plugin_class: Aris::Plugins::Cookies)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# lib/aris/plugins/cors.rb
|
|
2
|
+
|
|
3
|
+
module Aris
|
|
4
|
+
module Plugins
|
|
5
|
+
class Cors
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
def initialize(**config)
|
|
9
|
+
@config = config
|
|
10
|
+
@origins = normalize_origins(config[:origins] || '*')
|
|
11
|
+
@methods = config[:methods] || ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
|
12
|
+
@headers = config[:headers] || ['content-type', 'Authorization']
|
|
13
|
+
@credentials = config[:credentials] || false
|
|
14
|
+
@max_age = config[:max_age] || 86400 # 24 hours
|
|
15
|
+
@expose_headers = config[:expose_headers] || []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(request, response)
|
|
19
|
+
origin = request.headers['HTTP_ORIGIN']
|
|
20
|
+
|
|
21
|
+
# No Origin header = not a CORS request
|
|
22
|
+
return nil unless origin
|
|
23
|
+
|
|
24
|
+
# Check if origin is allowed
|
|
25
|
+
unless origin_allowed?(origin)
|
|
26
|
+
return nil # Don't set CORS headers for disallowed origins
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Set CORS headers
|
|
30
|
+
response.headers['Access-Control-Allow-Origin'] = allowed_origin_header(origin)
|
|
31
|
+
response.headers['Access-Control-Allow-Methods'] = @methods.join(', ')
|
|
32
|
+
response.headers['Access-Control-Allow-Headers'] = @headers.join(', ')
|
|
33
|
+
response.headers['Access-Control-Max-Age'] = @max_age.to_s
|
|
34
|
+
|
|
35
|
+
if @credentials
|
|
36
|
+
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
if @expose_headers.any?
|
|
40
|
+
response.headers['Access-Control-Expose-Headers'] = @expose_headers.join(', ')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Handle preflight OPTIONS request
|
|
44
|
+
if request.method == 'OPTIONS'
|
|
45
|
+
response.status = 204
|
|
46
|
+
response.body = []
|
|
47
|
+
return response # Halt - don't proceed to handler
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
nil # Continue for actual requests
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.build(**config)
|
|
54
|
+
new(**config)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def normalize_origins(origins)
|
|
60
|
+
return '*' if origins == '*'
|
|
61
|
+
Array(origins)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def origin_allowed?(origin)
|
|
65
|
+
return true if @origins == '*'
|
|
66
|
+
@origins.include?(origin)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def allowed_origin_header(origin)
|
|
70
|
+
# If credentials true, must echo specific origin (can't use *)
|
|
71
|
+
if @credentials && @origins == '*'
|
|
72
|
+
origin
|
|
73
|
+
elsif @origins == '*'
|
|
74
|
+
'*'
|
|
75
|
+
else
|
|
76
|
+
origin
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require 'securerandom'
|
|
2
|
+
module Aris
|
|
3
|
+
module Plugins
|
|
4
|
+
module CsrfUtility
|
|
5
|
+
extend self
|
|
6
|
+
def generate_token
|
|
7
|
+
SecureRandom.urlsafe_base64(32)
|
|
8
|
+
end
|
|
9
|
+
def validate_token(expected, provided)
|
|
10
|
+
expected && provided && expected == provided
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
CSRF_THREAD_KEY = :aris_csrf_token
|
|
15
|
+
FORM_METHODS = %w[POST PUT PATCH DELETE].freeze
|
|
16
|
+
|
|
17
|
+
class CsrfTokenGenerator
|
|
18
|
+
def self.call(request, response)
|
|
19
|
+
if request.method == 'GET' || request.method == 'HEAD'
|
|
20
|
+
token = CsrfUtility.generate_token
|
|
21
|
+
Thread.current[CSRF_THREAD_KEY] = token
|
|
22
|
+
end
|
|
23
|
+
nil # Continue pipeline
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class CsrfProtection
|
|
28
|
+
def self.call(request, response)
|
|
29
|
+
return nil unless FORM_METHODS.include?(request.method)
|
|
30
|
+
expected = Thread.current[CSRF_THREAD_KEY]
|
|
31
|
+
provided = request.headers['HTTP_X_CSRF_TOKEN']
|
|
32
|
+
unless CsrfUtility.validate_token(expected, provided)
|
|
33
|
+
response.status = 403
|
|
34
|
+
response.headers['content-type'] = 'text/plain'
|
|
35
|
+
response.body = ['CSRF token validation failed']
|
|
36
|
+
return response
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
Aris.register_plugin(:csrf,
|
|
46
|
+
generator: Aris::Plugins::CsrfTokenGenerator,
|
|
47
|
+
protection: Aris::Plugins::CsrfProtection
|
|
48
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# lib/aris/plugins/etag.rb
|
|
2
|
+
require 'digest'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Plugins
|
|
6
|
+
class ETag
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(**config)
|
|
10
|
+
@config = config
|
|
11
|
+
@cache_control = config[:cache_control] || 'max-age=0, private, must-revalidate'
|
|
12
|
+
@strong = config[:strong].nil? ? true : config[:strong]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Request hook - check If-None-Match
|
|
16
|
+
def call(request, response)
|
|
17
|
+
if_none_match = request.headers['HTTP_IF_NONE_MATCH']
|
|
18
|
+
|
|
19
|
+
# Store for later comparison in response phase
|
|
20
|
+
request.instance_variable_set(:@if_none_match, if_none_match)
|
|
21
|
+
|
|
22
|
+
nil # Continue to handler
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Response hook - generate ETag and check match
|
|
26
|
+
def call_response(request, response)
|
|
27
|
+
# Only generate ETags for successful GET/HEAD requests
|
|
28
|
+
return unless %w[GET HEAD].include?(request.method)
|
|
29
|
+
return unless response.status == 200
|
|
30
|
+
|
|
31
|
+
# Skip if response already has ETag
|
|
32
|
+
return if response.headers['ETag']
|
|
33
|
+
|
|
34
|
+
# Generate ETag from body
|
|
35
|
+
body_string = response.body.join
|
|
36
|
+
etag = generate_etag(body_string)
|
|
37
|
+
|
|
38
|
+
# Set ETag header
|
|
39
|
+
response.headers['ETag'] = etag
|
|
40
|
+
|
|
41
|
+
# Set Cache-Control if not already set
|
|
42
|
+
unless response.headers['Cache-Control']
|
|
43
|
+
response.headers['Cache-Control'] = @cache_control
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if client's ETag matches
|
|
47
|
+
if_none_match = request.instance_variable_get(:@if_none_match)
|
|
48
|
+
if if_none_match && etag_match?(if_none_match, etag)
|
|
49
|
+
# Return 304 Not Modified
|
|
50
|
+
response.status = 304
|
|
51
|
+
response.body = []
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.build(**config)
|
|
56
|
+
new(**config)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def generate_etag(body)
|
|
62
|
+
hash = Digest::MD5.hexdigest(body)
|
|
63
|
+
if @strong
|
|
64
|
+
%("#{hash}") # Strong ETag with quotes
|
|
65
|
+
else
|
|
66
|
+
%(W/"#{hash}") # Weak ETag
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def etag_match?(if_none_match, etag)
|
|
71
|
+
# Handle multiple ETags in If-None-Match
|
|
72
|
+
client_etags = if_none_match.split(',').map(&:strip)
|
|
73
|
+
|
|
74
|
+
# Check if any client ETag matches
|
|
75
|
+
client_etags.any? do |client_etag|
|
|
76
|
+
# Strip W/ prefix for weak comparison
|
|
77
|
+
normalize_etag(client_etag) == normalize_etag(etag)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def normalize_etag(etag)
|
|
82
|
+
# Remove W/ prefix and quotes for comparison
|
|
83
|
+
etag.sub(/^W\//, '').gsub('"', '')
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Self-register
|
|
90
|
+
Aris.register_plugin(:etag, plugin_class: Aris::Plugins::ETag)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# lib/aris/plugins/flash.rb - Complete rewrite of FlashData
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module Aris
|
|
7
|
+
module Plugins
|
|
8
|
+
class Flash
|
|
9
|
+
def self.call(request, response)
|
|
10
|
+
# Load flash data from cookie and initialize flash object
|
|
11
|
+
flash_data = load_flash_from_cookie(request)
|
|
12
|
+
|
|
13
|
+
# Define flash method on request
|
|
14
|
+
request.define_singleton_method(:flash) do
|
|
15
|
+
@flash ||= flash_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
nil # Continue pipeline
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.call_response(request, response)
|
|
22
|
+
return unless request.respond_to?(:flash)
|
|
23
|
+
|
|
24
|
+
flash = request.flash
|
|
25
|
+
data_to_store = flash.to_store
|
|
26
|
+
|
|
27
|
+
if data_to_store.any?
|
|
28
|
+
# Store flash for next request
|
|
29
|
+
encoded = Base64.urlsafe_encode64(data_to_store.to_json)
|
|
30
|
+
response.set_cookie('_aris_flash', encoded, {
|
|
31
|
+
httponly: true,
|
|
32
|
+
path: '/'
|
|
33
|
+
})
|
|
34
|
+
elsif request.cookies && request.cookies['_aris_flash']
|
|
35
|
+
# Clear flash cookie if no data to store
|
|
36
|
+
response.delete_cookie('_aris_flash')
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.build(**config)
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def self.load_flash_from_cookie(request)
|
|
47
|
+
return FlashData.new unless request.respond_to?(:cookies)
|
|
48
|
+
|
|
49
|
+
cookie_value = request.cookies['_aris_flash']
|
|
50
|
+
return FlashData.new unless cookie_value && !cookie_value.empty?
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
decoded = Base64.urlsafe_decode64(cookie_value)
|
|
54
|
+
data = JSON.parse(decoded, symbolize_names: true)
|
|
55
|
+
FlashData.new(data)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
# If cookie is invalid, start with empty flash
|
|
58
|
+
FlashData.new
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Internal flash data storage
|
|
63
|
+
# Alternative FlashData implementation with more explicit tracking
|
|
64
|
+
class FlashData
|
|
65
|
+
def initialize(initial_data = {})
|
|
66
|
+
@current = initial_data || {}
|
|
67
|
+
@next = {}
|
|
68
|
+
@now = {}
|
|
69
|
+
# Use a simple array to track reads
|
|
70
|
+
@read_keys = []
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def [](key)
|
|
74
|
+
key = key.to_sym
|
|
75
|
+
|
|
76
|
+
# Check now first
|
|
77
|
+
return @now[key] if @now.key?(key)
|
|
78
|
+
|
|
79
|
+
# Check current
|
|
80
|
+
if @current.key?(key) && !@read_keys.include?(key)
|
|
81
|
+
value = @current[key]
|
|
82
|
+
@read_keys << key
|
|
83
|
+
return value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Check next
|
|
87
|
+
@next[key]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def []=(key, value)
|
|
91
|
+
@next[key.to_sym] = value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def now
|
|
95
|
+
FlashNow.new(@now)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def any?
|
|
99
|
+
@current.any? || @next.any? || @now.any?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_store
|
|
103
|
+
# Remove any keys that have been read
|
|
104
|
+
unused_current = @current.reject { |k, _| @read_keys.include?(k) }
|
|
105
|
+
unused_current.merge(@next)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
# Flash.now proxy - only modifies current request data
|
|
109
|
+
class FlashNow
|
|
110
|
+
def initialize(now_data)
|
|
111
|
+
@now_data = now_data
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def [](key)
|
|
115
|
+
@now_data[key.to_sym]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def []=(key, value)
|
|
119
|
+
@now_data[key.to_sym] = value
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# lib/aris/plugins/form_parser.rb
|
|
2
|
+
require 'rack/utils'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Plugins
|
|
6
|
+
class FormParser
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(**config)
|
|
12
|
+
@config = config
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(request, response)
|
|
16
|
+
return nil unless PARSEABLE_METHODS.include?(request.method)
|
|
17
|
+
|
|
18
|
+
# Check content-type - access from env, not headers
|
|
19
|
+
content_type = request.env['CONTENT_TYPE']
|
|
20
|
+
return nil unless content_type&.include?('application/x-www-form-urlencoded')
|
|
21
|
+
|
|
22
|
+
raw_body = request.body
|
|
23
|
+
return nil if raw_body.nil? || raw_body.empty?
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
# Parse form data
|
|
27
|
+
data = ::Rack::Utils.parse_nested_query(raw_body)
|
|
28
|
+
|
|
29
|
+
# Attach parsed data to request
|
|
30
|
+
request.instance_variable_set(:@form_data, data)
|
|
31
|
+
rescue => e
|
|
32
|
+
response.status = 400
|
|
33
|
+
response.headers['content-type'] = 'text/plain'
|
|
34
|
+
response.body = ['Invalid form data']
|
|
35
|
+
return response
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
nil # Continue pipeline
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.build(**config)
|
|
42
|
+
new(**config)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|