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,62 @@
|
|
|
1
|
+
# lib/aris/plugins/health_check.rb
|
|
2
|
+
module Aris
|
|
3
|
+
module Plugins
|
|
4
|
+
class HealthCheck
|
|
5
|
+
attr_reader :config
|
|
6
|
+
|
|
7
|
+
def initialize(**config)
|
|
8
|
+
@config = config
|
|
9
|
+
@path = config[:path] || '/health'
|
|
10
|
+
@checks = config[:checks] || {}
|
|
11
|
+
@name = config[:name] || 'app'
|
|
12
|
+
@version = config[:version]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(request, response)
|
|
16
|
+
# Only handle health check path
|
|
17
|
+
return nil unless request.path == @path
|
|
18
|
+
|
|
19
|
+
# Run health checks
|
|
20
|
+
results = {}
|
|
21
|
+
overall_healthy = true
|
|
22
|
+
|
|
23
|
+
@checks.each do |name, check_proc|
|
|
24
|
+
begin
|
|
25
|
+
check_result = check_proc.call
|
|
26
|
+
results[name] = check_result ? 'ok' : 'fail'
|
|
27
|
+
overall_healthy = false unless check_result
|
|
28
|
+
rescue => e
|
|
29
|
+
results[name] = "error: #{e.message}"
|
|
30
|
+
overall_healthy = false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Build response
|
|
35
|
+
health_data = {
|
|
36
|
+
status: overall_healthy ? 'ok' : 'degraded',
|
|
37
|
+
name: @name,
|
|
38
|
+
checks: results
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
health_data[:version] = @version if @version
|
|
42
|
+
health_data[:timestamp] = Time.now.iso8601
|
|
43
|
+
|
|
44
|
+
# Set status code
|
|
45
|
+
status = overall_healthy ? 200 : 503
|
|
46
|
+
|
|
47
|
+
response.status = status
|
|
48
|
+
response.headers['content-type'] = 'application/json'
|
|
49
|
+
response.body = [health_data.to_json]
|
|
50
|
+
|
|
51
|
+
response # Halt pipeline
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.build(**config)
|
|
55
|
+
new(**config)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Self-register
|
|
62
|
+
Aris.register_plugin(:health_check, plugin_class: Aris::Plugins::HealthCheck)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module Aris
|
|
4
|
+
module Plugins
|
|
5
|
+
class Json
|
|
6
|
+
PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
|
|
7
|
+
|
|
8
|
+
def self.call(request, response)
|
|
9
|
+
return nil unless PARSEABLE_METHODS.include?(request.method)
|
|
10
|
+
|
|
11
|
+
raw_body = request.body
|
|
12
|
+
return nil if raw_body.nil? || raw_body.empty?
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
data = JSON.parse(raw_body)
|
|
16
|
+
# Attach parsed data to request
|
|
17
|
+
request.json_body = data
|
|
18
|
+
rescue JSON::ParserError => e
|
|
19
|
+
response.status = 400
|
|
20
|
+
response.headers['content-type'] = 'application/json'
|
|
21
|
+
response.body = [JSON.generate({ error: 'Invalid JSON', message: e.message })]
|
|
22
|
+
return response # Halt pipeline
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
nil # Continue pipeline
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Self-register
|
|
32
|
+
Aris.register_plugin(:json, plugin_class: Aris::Plugins::Json)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# lib/aris/plugins/multipart.rb
|
|
2
|
+
require 'tempfile'
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Aris
|
|
6
|
+
module Plugins
|
|
7
|
+
class Multipart
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
PARSEABLE_METHODS = %w[POST PUT PATCH].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(**config)
|
|
13
|
+
@config = config
|
|
14
|
+
@max_file_size = config[:max_file_size] || 10_485_760 # 10MB default
|
|
15
|
+
@max_files = config[:max_files] || 10
|
|
16
|
+
@allowed_extensions = config[:allowed_extensions] # nil = all allowed
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(request, response)
|
|
20
|
+
return nil unless PARSEABLE_METHODS.include?(request.method)
|
|
21
|
+
|
|
22
|
+
content_type = request.env['CONTENT_TYPE']
|
|
23
|
+
return nil unless content_type&.include?('multipart/form-data')
|
|
24
|
+
|
|
25
|
+
# Extract boundary from content type
|
|
26
|
+
boundary = extract_boundary(content_type)
|
|
27
|
+
unless boundary
|
|
28
|
+
response.status = 400
|
|
29
|
+
response.headers['content-type'] = 'text/plain'
|
|
30
|
+
response.body = ['Missing boundary in multipart request']
|
|
31
|
+
return response
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raw_body = request.body
|
|
35
|
+
return nil if raw_body.nil? || raw_body.empty?
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
# Parse multipart data
|
|
39
|
+
parts = parse_multipart(raw_body, boundary)
|
|
40
|
+
|
|
41
|
+
# Validate file count
|
|
42
|
+
files = parts.select { |p| p[:filename] }
|
|
43
|
+
if files.size > @max_files
|
|
44
|
+
response.status = 413
|
|
45
|
+
response.headers['content-type'] = 'text/plain'
|
|
46
|
+
response.body = ["Too many files (max #{@max_files})"]
|
|
47
|
+
return response
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Validate file sizes and extensions
|
|
51
|
+
files.each do |file|
|
|
52
|
+
if file[:data].bytesize > @max_file_size
|
|
53
|
+
response.status = 413
|
|
54
|
+
response.headers['content-type'] = 'text/plain'
|
|
55
|
+
response.body = ["File '#{file[:filename]}' exceeds maximum size (#{@max_file_size} bytes)"]
|
|
56
|
+
return response
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if @allowed_extensions
|
|
60
|
+
ext = File.extname(file[:filename]).downcase
|
|
61
|
+
unless @allowed_extensions.include?(ext)
|
|
62
|
+
response.status = 400
|
|
63
|
+
response.headers['content-type'] = 'text/plain'
|
|
64
|
+
response.body = ["File type '#{ext}' not allowed"]
|
|
65
|
+
return response
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Attach parsed data to request
|
|
71
|
+
request.instance_variable_set(:@multipart_data, parts)
|
|
72
|
+
|
|
73
|
+
rescue => e
|
|
74
|
+
response.status = 400
|
|
75
|
+
response.headers['content-type'] = 'text/plain'
|
|
76
|
+
response.body = ['Invalid multipart data']
|
|
77
|
+
return response
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
nil # Continue pipeline
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def self.build(**config)
|
|
84
|
+
new(**config)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def extract_boundary(content_type)
|
|
90
|
+
match = content_type.match(/boundary=(?:"([^"]+)"|([^;]+))/)
|
|
91
|
+
match ? (match[1] || match[2]) : nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_multipart(body, boundary)
|
|
95
|
+
parts = []
|
|
96
|
+
delimiter = "--#{boundary}"
|
|
97
|
+
|
|
98
|
+
# Split by boundary
|
|
99
|
+
sections = body.split(delimiter)
|
|
100
|
+
|
|
101
|
+
# Skip first (empty) and last (closing) sections
|
|
102
|
+
sections[1..-2]&.each do |section|
|
|
103
|
+
next if section.strip.empty?
|
|
104
|
+
|
|
105
|
+
# Split headers from content
|
|
106
|
+
header_end = section.index("\r\n\r\n") || section.index("\n\n")
|
|
107
|
+
next unless header_end
|
|
108
|
+
|
|
109
|
+
headers_raw = section[0...header_end]
|
|
110
|
+
content = section[(header_end + 4)..-1]
|
|
111
|
+
|
|
112
|
+
# Remove trailing CRLF
|
|
113
|
+
content = content.chomp("\r\n").chomp("\n")
|
|
114
|
+
|
|
115
|
+
# Parse Content-Disposition header
|
|
116
|
+
disposition = headers_raw.match(/Content-Disposition:\s*(.+?)(?:\r?\n|$)/i)
|
|
117
|
+
next unless disposition
|
|
118
|
+
|
|
119
|
+
disposition_value = disposition[1]
|
|
120
|
+
|
|
121
|
+
# Extract field name
|
|
122
|
+
name_match = disposition_value.match(/name="([^"]+)"/)
|
|
123
|
+
name = name_match ? name_match[1] : nil
|
|
124
|
+
next unless name
|
|
125
|
+
|
|
126
|
+
# Extract filename if present (indicates file upload)
|
|
127
|
+
filename_match = disposition_value.match(/filename="([^"]+)"/)
|
|
128
|
+
filename = filename_match ? filename_match[1] : nil
|
|
129
|
+
|
|
130
|
+
# Extract content type if present
|
|
131
|
+
content_type_match = headers_raw.match(/content-type:\s*(.+?)(?:\r?\n|$)/i)
|
|
132
|
+
content_type = content_type_match ? content_type_match[1].strip : nil
|
|
133
|
+
|
|
134
|
+
if filename
|
|
135
|
+
# File upload
|
|
136
|
+
parts << {
|
|
137
|
+
name: name,
|
|
138
|
+
filename: filename,
|
|
139
|
+
content_type: content_type || 'application/octet-stream',
|
|
140
|
+
data: content,
|
|
141
|
+
type: :file
|
|
142
|
+
}
|
|
143
|
+
else
|
|
144
|
+
# Regular form field
|
|
145
|
+
parts << {
|
|
146
|
+
name: name,
|
|
147
|
+
data: content,
|
|
148
|
+
type: :field
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
parts
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Self-register
|
|
160
|
+
Aris.register_plugin(:multipart, plugin_class: Aris::Plugins::Multipart)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Aris
|
|
2
|
+
module Plugins
|
|
3
|
+
class RateLimiter
|
|
4
|
+
LIMIT_WINDOW = 60
|
|
5
|
+
|
|
6
|
+
# In-memory store (replace with Redis in production)
|
|
7
|
+
@@store = {}
|
|
8
|
+
@@mutex = Mutex.new
|
|
9
|
+
|
|
10
|
+
def self.call(request, response)
|
|
11
|
+
key = request.headers['HTTP_X_API_KEY'] || request.host
|
|
12
|
+
|
|
13
|
+
if limit_exceeded?(key)
|
|
14
|
+
response.status = 429
|
|
15
|
+
response.headers['content-type'] = 'text/plain'
|
|
16
|
+
response.headers['Retry-After'] = LIMIT_WINDOW.to_s
|
|
17
|
+
response.body = ['Rate limit exceeded. Try again later.']
|
|
18
|
+
return response # Halt pipeline
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
increment_count(key)
|
|
22
|
+
nil # Continue pipeline
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def self.limit_exceeded?(key)
|
|
28
|
+
@@mutex.synchronize do
|
|
29
|
+
entry = @@store[key]
|
|
30
|
+
return false unless entry
|
|
31
|
+
|
|
32
|
+
# Check if we're still in the window and over limit
|
|
33
|
+
entry[:count] >= 100 && (Time.now - entry[:window_start]) < LIMIT_WINDOW
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.increment_count(key)
|
|
38
|
+
@@mutex.synchronize do
|
|
39
|
+
entry = @@store[key] ||= { count: 0, window_start: Time.now }
|
|
40
|
+
|
|
41
|
+
# Reset window if expired
|
|
42
|
+
if (Time.now - entry[:window_start]) >= LIMIT_WINDOW
|
|
43
|
+
entry[:count] = 0
|
|
44
|
+
entry[:window_start] = Time.now
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
entry[:count] += 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# For testing: clear the store
|
|
52
|
+
def self.reset!
|
|
53
|
+
@@mutex.synchronize { @@store.clear }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Self-register
|
|
60
|
+
Aris.register_plugin(:rate_limit, plugin_class: Aris::Plugins::RateLimiter)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# lib/aris/plugins/request_id.rb
|
|
2
|
+
require 'securerandom'
|
|
3
|
+
|
|
4
|
+
module Aris
|
|
5
|
+
module Plugins
|
|
6
|
+
class RequestId
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(**config)
|
|
10
|
+
@config = config
|
|
11
|
+
@header_name = config[:header_name] || 'X-Request-ID'
|
|
12
|
+
@generator = config[:generator] || -> { SecureRandom.uuid }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(request, response)
|
|
16
|
+
request_id = request.headers["HTTP_#{header_to_env(@header_name)}"]
|
|
17
|
+
request_id ||= @generator.call
|
|
18
|
+
request.instance_variable_set(:@request_id, request_id)
|
|
19
|
+
response.headers[@header_name] = request_id
|
|
20
|
+
|
|
21
|
+
nil # Continue pipeline
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.build(**config)
|
|
25
|
+
new(**config)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def header_to_env(header_name)
|
|
31
|
+
# Convert X-Request-ID → X_REQUEST_ID
|
|
32
|
+
header_name.upcase.gsub('-', '_')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Aris.register_plugin(:request_id, plugin_class: Aris::Plugins::RequestId)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# lib/aris/plugins/request_logger.rb
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Aris
|
|
6
|
+
module Plugins
|
|
7
|
+
class RequestLogger
|
|
8
|
+
attr_reader :config
|
|
9
|
+
|
|
10
|
+
def initialize(**config)
|
|
11
|
+
@config = config
|
|
12
|
+
@format = config[:format] || :text
|
|
13
|
+
@exclude = Array(config[:exclude] || [])
|
|
14
|
+
@logger = config[:logger] || ::Logger.new(STDOUT)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(request, response)
|
|
18
|
+
# Skip excluded paths
|
|
19
|
+
return nil if @exclude.include?(request.path)
|
|
20
|
+
|
|
21
|
+
# Log the request
|
|
22
|
+
entry = {
|
|
23
|
+
method: request.method,
|
|
24
|
+
path: request.path,
|
|
25
|
+
host: request.host,
|
|
26
|
+
timestamp: Time.now.iso8601
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if @format == :json
|
|
30
|
+
@logger.info(JSON.generate(entry))
|
|
31
|
+
else
|
|
32
|
+
@logger.info("#{entry[:method]} #{entry[:path]}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
nil # Continue pipeline
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.build(**config)
|
|
39
|
+
new(**config)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# security_headers.rb
|
|
2
|
+
|
|
3
|
+
module Aris
|
|
4
|
+
module Plugins
|
|
5
|
+
class SecurityHeaders
|
|
6
|
+
attr_reader :config
|
|
7
|
+
|
|
8
|
+
# Default secure headers
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
'X-Frame-Options' => 'SAMEORIGIN',
|
|
11
|
+
'X-content-type-Options' => 'nosniff',
|
|
12
|
+
'X-XSS-Protection' => '0', # Modern browsers ignore this, disabled preferred
|
|
13
|
+
'Referrer-Policy' => 'strict-origin-when-cross-origin'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(**config)
|
|
17
|
+
@config = config
|
|
18
|
+
@headers = build_headers(config)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(request, response)
|
|
22
|
+
# Set all configured headers
|
|
23
|
+
@headers.each do |key, value|
|
|
24
|
+
response.headers[key] = value
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
nil # Continue pipeline
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build(**config)
|
|
31
|
+
new(**config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def build_headers(config)
|
|
37
|
+
headers = {}
|
|
38
|
+
|
|
39
|
+
# Start with defaults unless disabled
|
|
40
|
+
unless config[:defaults] == false
|
|
41
|
+
headers.merge!(DEFAULTS)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# X-Frame-Options
|
|
45
|
+
if config.key?(:x_frame_options)
|
|
46
|
+
if config[:x_frame_options]
|
|
47
|
+
headers['X-Frame-Options'] = config[:x_frame_options]
|
|
48
|
+
else
|
|
49
|
+
headers.delete('X-Frame-Options') # ✅ Explicitly remove when nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# X-content-type-Options
|
|
54
|
+
if config.key?(:x_content_type_options)
|
|
55
|
+
if config[:x_content_type_options]
|
|
56
|
+
headers['X-content-type-Options'] = config[:x_content_type_options]
|
|
57
|
+
else
|
|
58
|
+
headers.delete('X-content-type-Options') # ✅ Explicitly remove when nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Referrer-Policy
|
|
63
|
+
if config.key?(:referrer_policy)
|
|
64
|
+
if config[:referrer_policy]
|
|
65
|
+
headers['Referrer-Policy'] = config[:referrer_policy]
|
|
66
|
+
else
|
|
67
|
+
headers.delete('Referrer-Policy') # ✅ Explicitly remove when nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# HSTS (same as before)
|
|
72
|
+
if config[:hsts]
|
|
73
|
+
hsts_value = if config[:hsts].is_a?(Hash)
|
|
74
|
+
max_age = config[:hsts][:max_age] || 31536000
|
|
75
|
+
directives = ["max-age=#{max_age}"]
|
|
76
|
+
directives << 'includeSubDomains' if config[:hsts][:include_subdomains]
|
|
77
|
+
directives << 'preload' if config[:hsts][:preload]
|
|
78
|
+
directives.join('; ')
|
|
79
|
+
else
|
|
80
|
+
'max-age=31536000; includeSubDomains'
|
|
81
|
+
end
|
|
82
|
+
headers['Strict-Transport-Security'] = hsts_value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# CSP (same as before)
|
|
86
|
+
if config[:csp]
|
|
87
|
+
headers['Content-Security-Policy'] = config[:csp]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Permissions-Policy (same as before)
|
|
91
|
+
if config[:permissions_policy]
|
|
92
|
+
headers['Permissions-Policy'] = config[:permissions_policy]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
headers
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# lib/aris/plugins/session.rb
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Aris
|
|
7
|
+
module Plugins
|
|
8
|
+
class Session
|
|
9
|
+
@default_config = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
store: :cookie,
|
|
12
|
+
key: '_aris_session',
|
|
13
|
+
expire_after: 14 * 24 * 3600, # 2 weeks in seconds
|
|
14
|
+
secret: nil
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
attr_accessor :default_config
|
|
19
|
+
|
|
20
|
+
def call(request, response)
|
|
21
|
+
load_session(request)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call_response(request, response)
|
|
26
|
+
return unless request.respond_to?(:session)
|
|
27
|
+
|
|
28
|
+
store_session(request, response)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build(**config)
|
|
32
|
+
config = default_config.merge(config)
|
|
33
|
+
config[:secret] ||= Aris::Config.secret_key_base
|
|
34
|
+
new(config)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def load_session(request)
|
|
40
|
+
session_data = load_from_store(request)
|
|
41
|
+
|
|
42
|
+
request.define_singleton_method(:session) do
|
|
43
|
+
@session ||= SessionData.new(session_data)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def store_session(request, response)
|
|
48
|
+
return unless request.session.changed? || request.session.destroyed?
|
|
49
|
+
|
|
50
|
+
if request.session.destroyed?
|
|
51
|
+
clear_from_store(request, response)
|
|
52
|
+
else
|
|
53
|
+
save_to_store(request, response, request.session.to_hash)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_from_store(request)
|
|
58
|
+
case default_config[:store]
|
|
59
|
+
when :cookie
|
|
60
|
+
load_from_cookie(request)
|
|
61
|
+
else
|
|
62
|
+
{} # Default empty session
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def save_to_store(request, response, data)
|
|
67
|
+
case default_config[:store]
|
|
68
|
+
when :cookie
|
|
69
|
+
save_to_cookie(request, response, data)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def clear_from_store(request, response)
|
|
74
|
+
case default_config[:store]
|
|
75
|
+
when :cookie
|
|
76
|
+
clear_cookie(response)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def load_from_cookie(request)
|
|
81
|
+
return {} unless request.respond_to?(:cookies)
|
|
82
|
+
|
|
83
|
+
cookie_value = request.cookies[default_config[:key]]
|
|
84
|
+
return {} unless cookie_value
|
|
85
|
+
|
|
86
|
+
begin
|
|
87
|
+
# For encrypted sessions
|
|
88
|
+
decrypt_session(cookie_value)
|
|
89
|
+
rescue
|
|
90
|
+
{} # Invalid session, start fresh
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def save_to_cookie(request, response, data)
|
|
95
|
+
return if data.empty?
|
|
96
|
+
|
|
97
|
+
encrypted_data = encrypt_session(data)
|
|
98
|
+
response.set_cookie(default_config[:key], encrypted_data, {
|
|
99
|
+
httponly: true,
|
|
100
|
+
secure: (ENV['RACK_ENV'] == 'production'),
|
|
101
|
+
path: '/',
|
|
102
|
+
max_age: default_config[:expire_after]
|
|
103
|
+
})
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def clear_cookie(response)
|
|
107
|
+
response.delete_cookie(default_config[:key])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def encrypt_session(data)
|
|
111
|
+
# Simple encryption for demo - use proper encryption in production
|
|
112
|
+
json_data = data.to_json
|
|
113
|
+
Base64.urlsafe_encode64(json_data)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def decrypt_session(encrypted_data)
|
|
117
|
+
json_data = Base64.urlsafe_decode64(encrypted_data)
|
|
118
|
+
JSON.parse(json_data, symbolize_names: true)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def initialize(config)
|
|
123
|
+
@config = config
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Session data container
|
|
127
|
+
class SessionData
|
|
128
|
+
def initialize(initial_data = {})
|
|
129
|
+
@data = initial_data || {}
|
|
130
|
+
@changed = false
|
|
131
|
+
@destroyed = false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def [](key)
|
|
135
|
+
@data[key.to_sym]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def []=(key, value)
|
|
139
|
+
@data[key.to_sym] = value
|
|
140
|
+
@changed = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def delete(key)
|
|
144
|
+
@data.delete(key.to_sym)
|
|
145
|
+
@changed = true
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def clear
|
|
149
|
+
@data.clear
|
|
150
|
+
@changed = true
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def destroy
|
|
154
|
+
clear
|
|
155
|
+
@destroyed = true
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def to_hash
|
|
159
|
+
@data.dup
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def changed?
|
|
163
|
+
@changed
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def destroyed?
|
|
167
|
+
@destroyed
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Register the plugin
|
|
175
|
+
Aris.register_plugin(:session, plugin_class: Aris::Plugins::Session)
|
data/lib/aris/plugins.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Aris
|
|
2
|
+
module Plugins
|
|
3
|
+
@@registry = {}
|
|
4
|
+
|
|
5
|
+
def self.register(name, *classes)
|
|
6
|
+
raise ArgumentError, "Plugin '#{name}' requires at least one class" if classes.empty?
|
|
7
|
+
@@registry[name.to_sym] = classes.flatten
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.resolve(name)
|
|
11
|
+
@@registry[name.to_sym] || raise(ArgumentError, "Unknown plugin :#{name}")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.register_plugin(name, **options)
|
|
16
|
+
classes = [options[:generator], options[:protection], options[:plugin_class]].flatten.compact
|
|
17
|
+
Plugins.register(name, *classes)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.resolve_plugin(name)
|
|
21
|
+
Plugins.resolve(name)
|
|
22
|
+
end
|
|
23
|
+
end
|