rails_ai 0.1.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/.rspec_status +96 -0
- data/AGENT_GUIDE.md +513 -0
- data/Appraisals +49 -0
- data/COMMERCIAL_LICENSE_TEMPLATE.md +92 -0
- data/FEATURES.md +204 -0
- data/LEGAL_PROTECTION_GUIDE.md +222 -0
- data/LICENSE +62 -0
- data/LICENSE_SUMMARY.md +74 -0
- data/MIT-LICENSE +62 -0
- data/PERFORMANCE.md +300 -0
- data/PROVIDERS.md +495 -0
- data/README.md +454 -0
- data/Rakefile +11 -0
- data/SPEED_OPTIMIZATIONS.md +217 -0
- data/STRUCTURE.md +139 -0
- data/USAGE_GUIDE.md +288 -0
- data/app/channels/ai_stream_channel.rb +33 -0
- data/app/components/ai/prompt_component.rb +25 -0
- data/app/controllers/concerns/ai/context_aware.rb +77 -0
- data/app/controllers/concerns/ai/streaming.rb +41 -0
- data/app/helpers/ai_helper.rb +164 -0
- data/app/jobs/ai/generate_embedding_job.rb +25 -0
- data/app/jobs/ai/generate_summary_job.rb +25 -0
- data/app/models/concerns/ai/embeddable.rb +38 -0
- data/app/views/rails_ai/dashboard/index.html.erb +51 -0
- data/config/routes.rb +19 -0
- data/lib/generators/rails_ai/install/install_generator.rb +38 -0
- data/lib/rails_ai/agents/agent_manager.rb +258 -0
- data/lib/rails_ai/agents/agent_team.rb +243 -0
- data/lib/rails_ai/agents/base_agent.rb +331 -0
- data/lib/rails_ai/agents/collaboration.rb +238 -0
- data/lib/rails_ai/agents/memory.rb +116 -0
- data/lib/rails_ai/agents/message_bus.rb +95 -0
- data/lib/rails_ai/agents/specialized_agents.rb +391 -0
- data/lib/rails_ai/agents/task_queue.rb +111 -0
- data/lib/rails_ai/cache.rb +14 -0
- data/lib/rails_ai/config.rb +40 -0
- data/lib/rails_ai/context.rb +7 -0
- data/lib/rails_ai/context_analyzer.rb +86 -0
- data/lib/rails_ai/engine.rb +48 -0
- data/lib/rails_ai/events.rb +9 -0
- data/lib/rails_ai/image_context.rb +110 -0
- data/lib/rails_ai/performance.rb +231 -0
- data/lib/rails_ai/provider.rb +8 -0
- data/lib/rails_ai/providers/anthropic_adapter.rb +256 -0
- data/lib/rails_ai/providers/base.rb +60 -0
- data/lib/rails_ai/providers/dummy_adapter.rb +29 -0
- data/lib/rails_ai/providers/gemini_adapter.rb +509 -0
- data/lib/rails_ai/providers/openai_adapter.rb +535 -0
- data/lib/rails_ai/providers/secure_anthropic_adapter.rb +206 -0
- data/lib/rails_ai/providers/secure_openai_adapter.rb +284 -0
- data/lib/rails_ai/railtie.rb +48 -0
- data/lib/rails_ai/redactor.rb +12 -0
- data/lib/rails_ai/security/api_key_manager.rb +82 -0
- data/lib/rails_ai/security/audit_logger.rb +46 -0
- data/lib/rails_ai/security/error_handler.rb +62 -0
- data/lib/rails_ai/security/input_validator.rb +176 -0
- data/lib/rails_ai/security/secure_file_handler.rb +45 -0
- data/lib/rails_ai/security/secure_http_client.rb +177 -0
- data/lib/rails_ai/security.rb +0 -0
- data/lib/rails_ai/version.rb +5 -0
- data/lib/rails_ai/window_context.rb +103 -0
- data/lib/rails_ai.rb +502 -0
- data/monitoring/ci_setup_guide.md +214 -0
- data/monitoring/enhanced_monitoring_script.rb +237 -0
- data/monitoring/google_alerts_setup.md +42 -0
- data/monitoring_log_20250921.txt +0 -0
- data/monitoring_script.rb +161 -0
- data/rails_ai.gemspec +54 -0
- data/scripts/security_scanner.rb +353 -0
- data/setup_monitoring.sh +163 -0
- data/wiki/API-Documentation.md +734 -0
- data/wiki/Architecture-Overview.md +672 -0
- data/wiki/Contributing-Guide.md +407 -0
- data/wiki/Development-Setup.md +532 -0
- data/wiki/Home.md +278 -0
- data/wiki/Installation-Guide.md +527 -0
- data/wiki/Quick-Start.md +186 -0
- data/wiki/README.md +135 -0
- data/wiki/Release-Process.md +467 -0
- metadata +385 -0
@@ -0,0 +1,206 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "json"
|
5
|
+
require_relative "../security"
|
6
|
+
|
7
|
+
module RailsAi
|
8
|
+
module Providers
|
9
|
+
class SecureAnthropicAdapter < Base
|
10
|
+
ANTHROPIC_API_BASE = "https://api.anthropic.com/v1"
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
@api_key = Security::APIKeyManager.secure_fetch("ANTHROPIC_API_KEY")
|
14
|
+
@rate_limiter = Security::RateLimiter.new(limit: 50, window: 3600)
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def chat!(messages:, model:, **opts)
|
19
|
+
return "(stubbed) #{messages.last[:content]}" if RailsAi.config.stub_responses
|
20
|
+
|
21
|
+
# Security validations
|
22
|
+
Security::InputValidator.validate_messages(messages)
|
23
|
+
|
24
|
+
# Rate limiting
|
25
|
+
@rate_limiter.check_limit("chat_#{RailsAi::Context.user_id}")
|
26
|
+
|
27
|
+
# Sanitize content
|
28
|
+
sanitized_messages = messages.map do |message|
|
29
|
+
{
|
30
|
+
role: message[:role],
|
31
|
+
content: Security::ContentSanitizer.sanitize_content(message[:content])
|
32
|
+
}
|
33
|
+
end
|
34
|
+
|
35
|
+
response = make_request(
|
36
|
+
"messages",
|
37
|
+
{
|
38
|
+
model: model,
|
39
|
+
max_tokens: opts[:max_tokens] || RailsAi.config.token_limit,
|
40
|
+
messages: sanitized_messages,
|
41
|
+
**opts.except(:max_tokens)
|
42
|
+
}
|
43
|
+
)
|
44
|
+
|
45
|
+
response.dig("content", 0, "text")
|
46
|
+
end
|
47
|
+
|
48
|
+
def stream_chat!(messages:, model:, **opts, &on_token)
|
49
|
+
return on_token.call("(stubbed stream)") if RailsAi.config.stub_responses
|
50
|
+
|
51
|
+
# Security validations
|
52
|
+
Security::InputValidator.validate_messages(messages)
|
53
|
+
|
54
|
+
# Rate limiting
|
55
|
+
@rate_limiter.check_limit("stream_#{RailsAi::Context.user_id}")
|
56
|
+
|
57
|
+
# Sanitize content
|
58
|
+
sanitized_messages = messages.map do |message|
|
59
|
+
{
|
60
|
+
role: message[:role],
|
61
|
+
content: Security::ContentSanitizer.sanitize_content(message[:content])
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
make_streaming_request(
|
66
|
+
"messages",
|
67
|
+
{
|
68
|
+
model: model,
|
69
|
+
max_tokens: opts[:max_tokens] || RailsAi.config.token_limit,
|
70
|
+
messages: sanitized_messages,
|
71
|
+
stream: true,
|
72
|
+
**opts.except(:max_tokens, :stream)
|
73
|
+
},
|
74
|
+
&on_token
|
75
|
+
)
|
76
|
+
end
|
77
|
+
|
78
|
+
def analyze_image!(image:, prompt:, model: "claude-3-5-sonnet-20241022", **opts)
|
79
|
+
return "(stubbed analysis)" if RailsAi.config.stub_responses
|
80
|
+
|
81
|
+
# Security validations
|
82
|
+
Security::InputValidator.validate_text_input(prompt, max_length: 1000)
|
83
|
+
|
84
|
+
# Rate limiting
|
85
|
+
@rate_limiter.check_limit("vision_#{RailsAi::Context.user_id}")
|
86
|
+
|
87
|
+
# Sanitize prompt
|
88
|
+
sanitized_prompt = Security::ContentSanitizer.sanitize_content(prompt)
|
89
|
+
|
90
|
+
# Prepare image securely
|
91
|
+
image_data = prepare_image_securely(image)
|
92
|
+
|
93
|
+
response = make_request(
|
94
|
+
"messages",
|
95
|
+
{
|
96
|
+
model: model,
|
97
|
+
max_tokens: opts[:max_tokens] || 1000,
|
98
|
+
messages: [
|
99
|
+
{
|
100
|
+
role: "user",
|
101
|
+
content: [
|
102
|
+
{
|
103
|
+
type: "text",
|
104
|
+
text: sanitized_prompt
|
105
|
+
},
|
106
|
+
{
|
107
|
+
type: "image",
|
108
|
+
source: {
|
109
|
+
type: "base64",
|
110
|
+
media_type: "image/png",
|
111
|
+
data: image_data
|
112
|
+
}
|
113
|
+
}
|
114
|
+
]
|
115
|
+
}
|
116
|
+
],
|
117
|
+
**opts.except(:max_tokens)
|
118
|
+
}
|
119
|
+
)
|
120
|
+
|
121
|
+
response.dig("content", 0, "text")
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def make_request(endpoint, payload)
|
127
|
+
Security::SecureHTTPClient.make_request(
|
128
|
+
"#{ANTHROPIC_API_BASE}/#{endpoint}",
|
129
|
+
method: 'POST',
|
130
|
+
body: payload.to_json,
|
131
|
+
headers: {
|
132
|
+
'Authorization' => "Bearer #{@api_key}",
|
133
|
+
'Content-Type' => 'application/json',
|
134
|
+
'Anthropic-Version' => '2023-06-01'
|
135
|
+
}
|
136
|
+
)
|
137
|
+
end
|
138
|
+
|
139
|
+
def make_streaming_request(endpoint, payload, &on_token)
|
140
|
+
Security::SecureHTTPClient.make_streaming_request(
|
141
|
+
"#{ANTHROPIC_API_BASE}/#{endpoint}",
|
142
|
+
method: 'POST',
|
143
|
+
body: payload.to_json,
|
144
|
+
headers: {
|
145
|
+
'Authorization' => "Bearer #{@api_key}",
|
146
|
+
'Content-Type' => 'application/json',
|
147
|
+
'Anthropic-Version' => '2023-06-01'
|
148
|
+
}
|
149
|
+
) do |chunk|
|
150
|
+
parse_streaming_chunk(chunk, &on_token)
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def parse_streaming_chunk(chunk, &on_token)
|
155
|
+
return if chunk.strip.empty?
|
156
|
+
|
157
|
+
chunk.split("\n").each do |line|
|
158
|
+
next unless line.start_with?("data: ")
|
159
|
+
|
160
|
+
data = line[6..-1]
|
161
|
+
next if data == "[DONE]"
|
162
|
+
|
163
|
+
begin
|
164
|
+
parsed = JSON.parse(data)
|
165
|
+
content = parsed.dig("delta", "text")
|
166
|
+
on_token.call(content) if content
|
167
|
+
rescue JSON::ParserError
|
168
|
+
# Skip invalid JSON
|
169
|
+
end
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
def prepare_image_securely(image)
|
174
|
+
if image.is_a?(String)
|
175
|
+
if image.start_with?("data:image/")
|
176
|
+
# Validate base64 data
|
177
|
+
Security::InputValidator.validate_base64_data(image, expected_type: :image)
|
178
|
+
# Extract base64 data
|
179
|
+
image.split(",")[1]
|
180
|
+
elsif image.start_with?("http")
|
181
|
+
# Validate URL
|
182
|
+
Security::InputValidator.validate_url(image)
|
183
|
+
convert_url_to_base64_securely(image)
|
184
|
+
else
|
185
|
+
# Validate file path
|
186
|
+
Security::InputValidator.validate_file_path(image)
|
187
|
+
convert_file_to_base64_securely(image)
|
188
|
+
end
|
189
|
+
else
|
190
|
+
# File object - convert to base64
|
191
|
+
convert_file_to_base64_securely(image)
|
192
|
+
end
|
193
|
+
end
|
194
|
+
|
195
|
+
def convert_url_to_base64_securely(url)
|
196
|
+
response = Security::SecureHTTPClient.make_request(url, method: 'GET')
|
197
|
+
Base64.strict_encode64(response.body)
|
198
|
+
end
|
199
|
+
|
200
|
+
def convert_file_to_base64_securely(file_path)
|
201
|
+
file_content = Security::SecureFileHandler.safe_file_read(file_path)
|
202
|
+
Base64.strict_encode64(file_content)
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
@@ -0,0 +1,284 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require "json"
|
5
|
+
require "base64"
|
6
|
+
require_relative "../security"
|
7
|
+
|
8
|
+
module RailsAi
|
9
|
+
module Providers
|
10
|
+
class SecureOpenAIAdapter < Base
|
11
|
+
OPENAI_API_BASE = "https://api.openai.com/v1"
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@api_key = ENV.fetch("OPENAI_API_KEY")
|
15
|
+
@rate_limiter = Security::RateLimiter.new(limit: 100, window: 3600)
|
16
|
+
super
|
17
|
+
end
|
18
|
+
|
19
|
+
def chat!(messages:, model:, **opts)
|
20
|
+
return "(stubbed) #{messages.last[:content]}" if RailsAi.config.stub_responses
|
21
|
+
|
22
|
+
# Security validations
|
23
|
+
Security::InputValidator.validate_messages(messages)
|
24
|
+
|
25
|
+
# Rate limiting
|
26
|
+
@rate_limiter.check_limit("chat_#{RailsAi::Context.user_id}")
|
27
|
+
|
28
|
+
# Sanitize content
|
29
|
+
sanitized_messages = messages.map do |message|
|
30
|
+
{
|
31
|
+
role: message[:role],
|
32
|
+
content: Security::ContentSanitizer.sanitize_content(message[:content])
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
response = make_request(
|
37
|
+
"chat/completions",
|
38
|
+
{
|
39
|
+
model: model,
|
40
|
+
messages: sanitized_messages,
|
41
|
+
max_tokens: opts[:max_tokens] || RailsAi.config.token_limit,
|
42
|
+
temperature: opts[:temperature] || 0.7,
|
43
|
+
top_p: opts[:top_p] || 1.0,
|
44
|
+
frequency_penalty: opts[:frequency_penalty] || 0.0,
|
45
|
+
presence_penalty: opts[:presence_penalty] || 0.0,
|
46
|
+
**opts.except(:max_tokens, :temperature, :top_p, :frequency_penalty, :presence_penalty)
|
47
|
+
}
|
48
|
+
)
|
49
|
+
|
50
|
+
response.dig("choices", 0, "message", "content")
|
51
|
+
end
|
52
|
+
|
53
|
+
def stream_chat!(messages:, model:, **opts, &on_token)
|
54
|
+
return on_token.call("(stubbed stream)") if RailsAi.config.stub_responses
|
55
|
+
|
56
|
+
# Security validations
|
57
|
+
Security::InputValidator.validate_messages(messages)
|
58
|
+
|
59
|
+
# Rate limiting
|
60
|
+
@rate_limiter.check_limit("stream_#{RailsAi::Context.user_id}")
|
61
|
+
|
62
|
+
# Sanitize content
|
63
|
+
sanitized_messages = messages.map do |message|
|
64
|
+
{
|
65
|
+
role: message[:role],
|
66
|
+
content: Security::ContentSanitizer.sanitize_content(message[:content])
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
make_streaming_request(
|
71
|
+
"chat/completions",
|
72
|
+
{
|
73
|
+
model: model,
|
74
|
+
messages: sanitized_messages,
|
75
|
+
max_tokens: opts[:max_tokens] || RailsAi.config.token_limit,
|
76
|
+
temperature: opts[:temperature] || 0.7,
|
77
|
+
top_p: opts[:top_p] || 1.0,
|
78
|
+
frequency_penalty: opts[:frequency_penalty] || 0.0,
|
79
|
+
presence_penalty: opts[:presence_penalty] || 0.0,
|
80
|
+
stream: true,
|
81
|
+
**opts.except(:max_tokens, :temperature, :top_p, :frequency_penalty, :presence_penalty, :stream)
|
82
|
+
},
|
83
|
+
&on_token
|
84
|
+
)
|
85
|
+
end
|
86
|
+
|
87
|
+
def generate_image!(prompt:, model: "dall-e-3", size: "1024x1024", quality: "standard", **opts)
|
88
|
+
return "(stubbed image)" if RailsAi.config.stub_responses
|
89
|
+
|
90
|
+
# Security validations
|
91
|
+
Security::InputValidator.validate_text_input(prompt, max_length: 1000)
|
92
|
+
|
93
|
+
# Rate limiting
|
94
|
+
@rate_limiter.check_limit("image_#{RailsAi::Context.user_id}")
|
95
|
+
|
96
|
+
# Sanitize prompt
|
97
|
+
sanitized_prompt = Security::ContentSanitizer.sanitize_content(prompt)
|
98
|
+
|
99
|
+
response = make_request(
|
100
|
+
"images/generations",
|
101
|
+
{
|
102
|
+
model: model,
|
103
|
+
prompt: sanitized_prompt,
|
104
|
+
size: size,
|
105
|
+
quality: quality,
|
106
|
+
n: opts[:n] || 1,
|
107
|
+
**opts.except(:n)
|
108
|
+
}
|
109
|
+
)
|
110
|
+
|
111
|
+
response.dig("data", 0, "url")
|
112
|
+
end
|
113
|
+
|
114
|
+
def analyze_image!(image:, prompt:, model: "gpt-4o", **opts)
|
115
|
+
return "(stubbed analysis)" if RailsAi.config.stub_responses
|
116
|
+
|
117
|
+
# Security validations
|
118
|
+
Security::InputValidator.validate_text_input(prompt, max_length: 1000)
|
119
|
+
|
120
|
+
# Rate limiting
|
121
|
+
@rate_limiter.check_limit("vision_#{RailsAi::Context.user_id}")
|
122
|
+
|
123
|
+
# Sanitize prompt
|
124
|
+
sanitized_prompt = Security::ContentSanitizer.sanitize_content(prompt)
|
125
|
+
|
126
|
+
# Prepare image securely
|
127
|
+
image_data = prepare_image_securely(image)
|
128
|
+
|
129
|
+
response = make_request(
|
130
|
+
"chat/completions",
|
131
|
+
{
|
132
|
+
model: model,
|
133
|
+
messages: [
|
134
|
+
{
|
135
|
+
role: "user",
|
136
|
+
content: [
|
137
|
+
{
|
138
|
+
type: "text",
|
139
|
+
text: sanitized_prompt
|
140
|
+
},
|
141
|
+
{
|
142
|
+
type: "image_url",
|
143
|
+
image_url: {
|
144
|
+
url: image_data
|
145
|
+
}
|
146
|
+
}
|
147
|
+
]
|
148
|
+
}
|
149
|
+
],
|
150
|
+
max_tokens: opts[:max_tokens] || 1000,
|
151
|
+
**opts.except(:max_tokens)
|
152
|
+
}
|
153
|
+
)
|
154
|
+
|
155
|
+
response.dig("choices", 0, "message", "content")
|
156
|
+
end
|
157
|
+
|
158
|
+
private
|
159
|
+
|
160
|
+
def make_request(endpoint, payload)
|
161
|
+
uri = URI("#{OPENAI_API_BASE}/#{endpoint}")
|
162
|
+
|
163
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
164
|
+
http.use_ssl = true
|
165
|
+
http.read_timeout = 30
|
166
|
+
http.open_timeout = 10
|
167
|
+
|
168
|
+
request = Net::HTTP::Post.new(uri)
|
169
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
170
|
+
request["Content-Type"] = "application/json"
|
171
|
+
request.body = payload.to_json
|
172
|
+
|
173
|
+
response = http.request(request)
|
174
|
+
|
175
|
+
case response.code.to_i
|
176
|
+
when 200..299
|
177
|
+
JSON.parse(response.body)
|
178
|
+
when 429
|
179
|
+
raise Security::RateLimitError, "OpenAI API rate limit exceeded"
|
180
|
+
when 401
|
181
|
+
raise Security::SecurityError, "Invalid API key"
|
182
|
+
when 403
|
183
|
+
raise Security::SecurityError, "API access forbidden"
|
184
|
+
else
|
185
|
+
raise Security::SecurityError, "API request failed: #{response.code} #{response.message}"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def make_streaming_request(endpoint, payload, &on_token)
|
190
|
+
uri = URI("#{OPENAI_API_BASE}/#{endpoint}")
|
191
|
+
|
192
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
193
|
+
http.use_ssl = true
|
194
|
+
http.read_timeout = 60
|
195
|
+
http.open_timeout = 10
|
196
|
+
|
197
|
+
request = Net::HTTP::Post.new(uri)
|
198
|
+
request["Authorization"] = "Bearer #{@api_key}"
|
199
|
+
request["Content-Type"] = "application/json"
|
200
|
+
request.body = payload.to_json
|
201
|
+
|
202
|
+
http.request(request) do |response|
|
203
|
+
case response.code.to_i
|
204
|
+
when 200..299
|
205
|
+
response.read_body do |chunk|
|
206
|
+
next if chunk.strip.empty?
|
207
|
+
|
208
|
+
chunk.split("\n").each do |line|
|
209
|
+
next unless line.start_with?("data: ")
|
210
|
+
|
211
|
+
data = line[6..-1]
|
212
|
+
next if data == "[DONE]"
|
213
|
+
|
214
|
+
begin
|
215
|
+
parsed = JSON.parse(data)
|
216
|
+
content = parsed.dig("choices", 0, "delta", "content")
|
217
|
+
on_token.call(content) if content
|
218
|
+
rescue JSON::ParserError
|
219
|
+
# Skip invalid JSON
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
when 429
|
224
|
+
raise Security::RateLimitError, "OpenAI API rate limit exceeded"
|
225
|
+
when 401
|
226
|
+
raise Security::SecurityError, "Invalid API key"
|
227
|
+
when 403
|
228
|
+
raise Security::SecurityError, "API access forbidden"
|
229
|
+
else
|
230
|
+
raise Security::SecurityError, "API request failed: #{response.code} #{response.message}"
|
231
|
+
end
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def prepare_image_securely(image)
|
236
|
+
if image.is_a?(String)
|
237
|
+
if image.start_with?("data:image/")
|
238
|
+
# Validate base64 data
|
239
|
+
Security::InputValidator.validate_base64_data(image, expected_type: :image)
|
240
|
+
image
|
241
|
+
elsif image.start_with?("http")
|
242
|
+
# Validate URL
|
243
|
+
Security::InputValidator.validate_url(image)
|
244
|
+
convert_url_to_base64_securely(image)
|
245
|
+
else
|
246
|
+
# Validate file path
|
247
|
+
Security::InputValidator.validate_file_path(image)
|
248
|
+
convert_file_to_base64_securely(image)
|
249
|
+
end
|
250
|
+
else
|
251
|
+
# File object - convert to base64
|
252
|
+
convert_file_to_base64_securely(image)
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
def convert_url_to_base64_securely(url)
|
257
|
+
uri = URI(url)
|
258
|
+
|
259
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
260
|
+
http.use_ssl = true
|
261
|
+
http.read_timeout = 30
|
262
|
+
http.open_timeout = 10
|
263
|
+
|
264
|
+
request = Net::HTTP::Get.new(uri)
|
265
|
+
request["User-Agent"] = "RailsAI/1.0"
|
266
|
+
|
267
|
+
response = http.request(request)
|
268
|
+
|
269
|
+
if response.code == "200"
|
270
|
+
base64_data = Base64.strict_encode64(response.body)
|
271
|
+
"data:image/png;base64,#{base64_data}"
|
272
|
+
else
|
273
|
+
raise Security::SecurityError, "Failed to fetch image from URL: #{response.code}"
|
274
|
+
end
|
275
|
+
end
|
276
|
+
|
277
|
+
def convert_file_to_base64_securely(file_path)
|
278
|
+
file_content = Security::SecureFileHandler.safe_file_read(file_path)
|
279
|
+
base64_data = Base64.strict_encode64(file_content)
|
280
|
+
"data:image/png;base64,#{base64_data}"
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsAi
|
4
|
+
class Railtie < ::Rails::Railtie
|
5
|
+
config.rails_ai = ActiveSupport::OrderedOptions.new
|
6
|
+
|
7
|
+
# Rails version compatibility
|
8
|
+
if Rails.version >= "8.0"
|
9
|
+
# Rails 8 specific configuration
|
10
|
+
initializer "rails_ai.configure", before: :load_config_initializers do |app|
|
11
|
+
RailsAi.configure do |c|
|
12
|
+
c.provider ||= app.config.rails_ai.provider || :openai
|
13
|
+
c.default_model ||= app.config.rails_ai.default_model || "gpt-4o-mini"
|
14
|
+
c.token_limit ||= app.config.rails_ai.token_limit || 4000
|
15
|
+
c.cache_ttl ||= app.config.rails_ai.cache_ttl || 1.hour
|
16
|
+
c.stub_responses ||= app.config.rails_ai.stub_responses || false
|
17
|
+
end
|
18
|
+
end
|
19
|
+
elsif Rails.version >= "7.0"
|
20
|
+
# Rails 7 specific configuration
|
21
|
+
initializer "rails_ai.configure", before: :load_config_initializers do |app|
|
22
|
+
RailsAi.configure do |c|
|
23
|
+
c.provider ||= app.config.rails_ai.provider || :openai
|
24
|
+
c.default_model ||= app.config.rails_ai.default_model || "gpt-4o-mini"
|
25
|
+
c.token_limit ||= app.config.rails_ai.token_limit || 4000
|
26
|
+
c.cache_ttl ||= app.config.rails_ai.cache_ttl || 1.hour
|
27
|
+
c.stub_responses ||= app.config.rails_ai.stub_responses || false
|
28
|
+
end
|
29
|
+
end
|
30
|
+
else
|
31
|
+
# Rails 5.2+ and 6.x configuration
|
32
|
+
initializer "rails_ai.configure" do |app|
|
33
|
+
RailsAi.configure do |c|
|
34
|
+
c.provider ||= app.config.rails_ai.provider || :openai
|
35
|
+
c.default_model ||= app.config.rails_ai.default_model || "gpt-4o-mini"
|
36
|
+
c.token_limit ||= app.config.rails_ai.token_limit || 4000
|
37
|
+
c.cache_ttl ||= app.config.rails_ai.cache_ttl || 1.hour
|
38
|
+
c.stub_responses ||= app.config.rails_ai.stub_responses || false
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# Common configuration for all Rails versions
|
44
|
+
initializer "rails_ai.logger" do
|
45
|
+
Rails.logger.info "Rails AI #{RailsAi::VERSION} loaded for Rails #{Rails.version}"
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsAi
|
4
|
+
module Redactor
|
5
|
+
EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i
|
6
|
+
PHONE = /\+?\d[\d\s().-]{7,}\d/
|
7
|
+
|
8
|
+
def self.call(text)
|
9
|
+
text.to_s.gsub(EMAIL, "[email]").gsub(PHONE, "[phone]")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'openssl'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module RailsAi
|
7
|
+
module Security
|
8
|
+
class APIKeyManager
|
9
|
+
def self.encrypt_key(key)
|
10
|
+
return nil if key.nil? || key.empty?
|
11
|
+
|
12
|
+
cipher = OpenSSL::Cipher.new('AES-256-GCM')
|
13
|
+
cipher.encrypt
|
14
|
+
cipher.key = derive_key
|
15
|
+
cipher.iv = SecureRandom.random_bytes(12)
|
16
|
+
|
17
|
+
encrypted = cipher.update(key) + cipher.final
|
18
|
+
auth_tag = cipher.auth_tag
|
19
|
+
|
20
|
+
Base64.strict_encode64({
|
21
|
+
data: Base64.strict_encode64(encrypted),
|
22
|
+
iv: Base64.strict_encode64(cipher.iv),
|
23
|
+
auth_tag: Base64.strict_encode64(auth_tag)
|
24
|
+
}.to_json)
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.decrypt_key(encrypted_key)
|
28
|
+
return nil if encrypted_key.nil? || encrypted_key.empty?
|
29
|
+
|
30
|
+
begin
|
31
|
+
key_data = JSON.parse(Base64.strict_decode64(encrypted_key))
|
32
|
+
|
33
|
+
cipher = OpenSSL::Cipher.new('AES-256-GCM')
|
34
|
+
cipher.decrypt
|
35
|
+
cipher.key = derive_key
|
36
|
+
cipher.iv = Base64.strict_decode64(key_data['iv'])
|
37
|
+
cipher.auth_tag = Base64.strict_decode64(key_data['auth_tag'])
|
38
|
+
|
39
|
+
encrypted_data = Base64.strict_decode64(key_data['data'])
|
40
|
+
cipher.update(encrypted_data) + cipher.final
|
41
|
+
rescue => e
|
42
|
+
raise SecurityError, "Failed to decrypt API key: #{e.message}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.mask_key(key)
|
47
|
+
return nil if key.nil? || key.empty?
|
48
|
+
return "***" if key.length < 8
|
49
|
+
|
50
|
+
"#{key[0..3]}***#{key[-4..-1]}"
|
51
|
+
end
|
52
|
+
|
53
|
+
def self.secure_fetch(env_var, required: true)
|
54
|
+
key = ENV[env_var]
|
55
|
+
|
56
|
+
if key.nil? || key.empty?
|
57
|
+
if required
|
58
|
+
raise SecurityError, "Required environment variable #{env_var} is not set"
|
59
|
+
else
|
60
|
+
return nil
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Check if key is already encrypted
|
65
|
+
if key.start_with?('{') && key.include?('"data"')
|
66
|
+
decrypt_key(key)
|
67
|
+
else
|
68
|
+
key
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
def self.derive_key
|
75
|
+
secret = ENV['RAILS_AI_SECRET'] || Rails.application&.secret_key_base || 'default_secret'
|
76
|
+
salt = ENV['RAILS_AI_SALT'] || 'rails_ai_default_salt'
|
77
|
+
|
78
|
+
OpenSSL::PKCS5.pbkdf2_hmac_sha256(secret, salt, 100000, 32)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RailsAi
|
4
|
+
module Security
|
5
|
+
class AuditLogger
|
6
|
+
def self.log_security_event(event_type, details = {})
|
7
|
+
return unless defined?(Rails) && Rails.logger
|
8
|
+
|
9
|
+
log_entry = {
|
10
|
+
timestamp: Time.now.iso8601,
|
11
|
+
event_type: event_type,
|
12
|
+
details: details,
|
13
|
+
user_id: RailsAi::Context.user_id,
|
14
|
+
request_id: RailsAi::Context.request_id
|
15
|
+
}
|
16
|
+
|
17
|
+
Rails.logger.info("[SECURITY] #{log_entry.to_json}")
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.log_api_call(endpoint, user_id, success: true)
|
21
|
+
log_security_event(:api_call, {
|
22
|
+
endpoint: endpoint,
|
23
|
+
user_id: user_id,
|
24
|
+
success: success,
|
25
|
+
timestamp: Time.now
|
26
|
+
})
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.log_validation_error(error_type, details)
|
30
|
+
log_security_event(:validation_error, {
|
31
|
+
error_type: error_type,
|
32
|
+
details: details,
|
33
|
+
timestamp: Time.now
|
34
|
+
})
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.log_rate_limit_exceeded(identifier, limit)
|
38
|
+
log_security_event(:rate_limit_exceeded, {
|
39
|
+
identifier: identifier,
|
40
|
+
limit: limit,
|
41
|
+
timestamp: Time.now
|
42
|
+
})
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|