active_canvas 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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +318 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
- data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
- data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
- data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
- data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
- data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
- data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
- data/app/assets/javascripts/active_canvas/editor.js +295 -0
- data/app/assets/stylesheets/active_canvas/application.css +15 -0
- data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
- data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
- data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
- data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
- data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
- data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
- data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
- data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
- data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
- data/app/controllers/active_canvas/application_controller.rb +20 -0
- data/app/controllers/active_canvas/pages_controller.rb +18 -0
- data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
- data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
- data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
- data/app/helpers/active_canvas/application_helper.rb +4 -0
- data/app/jobs/active_canvas/application_job.rb +4 -0
- data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
- data/app/mailers/active_canvas/application_mailer.rb +6 -0
- data/app/models/active_canvas/ai_model.rb +136 -0
- data/app/models/active_canvas/application_record.rb +5 -0
- data/app/models/active_canvas/media.rb +141 -0
- data/app/models/active_canvas/page.rb +85 -0
- data/app/models/active_canvas/page_type.rb +22 -0
- data/app/models/active_canvas/page_version.rb +80 -0
- data/app/models/active_canvas/partial.rb +73 -0
- data/app/models/active_canvas/setting.rb +292 -0
- data/app/services/active_canvas/ai_configuration.rb +40 -0
- data/app/services/active_canvas/ai_models.rb +128 -0
- data/app/services/active_canvas/ai_service.rb +289 -0
- data/app/services/active_canvas/content_sanitizer.rb +112 -0
- data/app/services/active_canvas/tailwind_compiler.rb +156 -0
- data/app/views/active_canvas/admin/media/index.html.erb +401 -0
- data/app/views/active_canvas/admin/media/show.html.erb +297 -0
- data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
- data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
- data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
- data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
- data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
- data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
- data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
- data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
- data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
- data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
- data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
- data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
- data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
- data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
- data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
- data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
- data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
- data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
- data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
- data/app/views/active_canvas/pages/show.html.erb +113 -0
- data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
- data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
- data/app/views/layouts/active_canvas/application.html.erb +55 -0
- data/config/routes.rb +48 -0
- data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
- data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
- data/lib/active_canvas/configuration.rb +232 -0
- data/lib/active_canvas/engine.rb +44 -0
- data/lib/active_canvas/version.rb +3 -0
- data/lib/active_canvas.rb +26 -0
- data/lib/generators/active_canvas/install/install_generator.rb +263 -0
- data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
- data/lib/tasks/active_canvas_tasks.rake +69 -0
- metadata +150 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class AiConfiguration
|
|
3
|
+
class << self
|
|
4
|
+
def configure_ruby_llm!
|
|
5
|
+
RubyLLM.configure do |config|
|
|
6
|
+
config.openai_api_key = Setting.ai_openai_api_key
|
|
7
|
+
config.anthropic_api_key = Setting.ai_anthropic_api_key
|
|
8
|
+
config.openrouter_api_key = Setting.ai_openrouter_api_key
|
|
9
|
+
config.request_timeout = 180
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def configured?
|
|
14
|
+
Setting.ai_openai_api_key.present? ||
|
|
15
|
+
Setting.ai_anthropic_api_key.present? ||
|
|
16
|
+
Setting.ai_openrouter_api_key.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def text_enabled?
|
|
20
|
+
configured? && Setting.ai_text_enabled?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def image_enabled?
|
|
24
|
+
configured? && Setting.ai_image_enabled?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def screenshot_enabled?
|
|
28
|
+
configured? && Setting.ai_screenshot_enabled?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configured_providers
|
|
32
|
+
providers = []
|
|
33
|
+
providers << "openai" if Setting.ai_openai_api_key.present?
|
|
34
|
+
providers << "anthropic" if Setting.ai_anthropic_api_key.present?
|
|
35
|
+
providers << "openrouter" if Setting.ai_openrouter_api_key.present?
|
|
36
|
+
providers
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class AiModels
|
|
3
|
+
# Fallback models when database is empty
|
|
4
|
+
DEFAULT_TEXT_MODELS = [
|
|
5
|
+
{ id: "gpt-4o", name: "GPT-4o", provider: "openai",
|
|
6
|
+
input_modalities: %w[text image], output_modalities: %w[text] },
|
|
7
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "openai",
|
|
8
|
+
input_modalities: %w[text image], output_modalities: %w[text] },
|
|
9
|
+
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic",
|
|
10
|
+
input_modalities: %w[text image], output_modalities: %w[text] },
|
|
11
|
+
{ id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", provider: "anthropic",
|
|
12
|
+
input_modalities: %w[text], output_modalities: %w[text] }
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_IMAGE_MODELS = [
|
|
16
|
+
{ id: "dall-e-3", name: "DALL-E 3", provider: "openai",
|
|
17
|
+
input_modalities: %w[text], output_modalities: %w[image] },
|
|
18
|
+
{ id: "gpt-image-1", name: "GPT Image 1", provider: "openai",
|
|
19
|
+
input_modalities: %w[text], output_modalities: %w[image] }
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_VISION_MODELS = [
|
|
23
|
+
{ id: "gpt-4o", name: "GPT-4o", provider: "openai",
|
|
24
|
+
input_modalities: %w[text image], output_modalities: %w[text] },
|
|
25
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", provider: "openai",
|
|
26
|
+
input_modalities: %w[text image], output_modalities: %w[text] },
|
|
27
|
+
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic",
|
|
28
|
+
input_modalities: %w[text image], output_modalities: %w[text] }
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def refresh!
|
|
33
|
+
AiModel.refresh_from_ruby_llm!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def text_models
|
|
37
|
+
models = fetch_text_models_from_db
|
|
38
|
+
return models if models.any?
|
|
39
|
+
|
|
40
|
+
filter_by_configured_providers(DEFAULT_TEXT_MODELS)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def image_models
|
|
44
|
+
models = fetch_image_models_from_db
|
|
45
|
+
return models if models.any?
|
|
46
|
+
|
|
47
|
+
filter_by_configured_providers(DEFAULT_IMAGE_MODELS)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def vision_models
|
|
51
|
+
models = fetch_vision_models_from_db
|
|
52
|
+
return models if models.any?
|
|
53
|
+
|
|
54
|
+
filter_by_configured_providers(DEFAULT_VISION_MODELS)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def all_text_models
|
|
58
|
+
models = AiModel.active.text_models.order(:provider, :name)
|
|
59
|
+
return models.map(&:as_json_for_editor) if models.any?
|
|
60
|
+
|
|
61
|
+
DEFAULT_TEXT_MODELS
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def all_image_models
|
|
65
|
+
models = AiModel.active.image_models.order(:provider, :name)
|
|
66
|
+
return models.map(&:as_json_for_editor) if models.any?
|
|
67
|
+
|
|
68
|
+
DEFAULT_IMAGE_MODELS
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def all_vision_models
|
|
72
|
+
models = AiModel.active.vision_models.order(:provider, :name)
|
|
73
|
+
return models.map(&:as_json_for_editor) if models.any?
|
|
74
|
+
|
|
75
|
+
DEFAULT_VISION_MODELS
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def find_by_id(model_id)
|
|
79
|
+
AiModel.find_by(model_id: model_id)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def models_synced?
|
|
83
|
+
AiModel.exists?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def last_synced_at
|
|
87
|
+
AiModel.maximum(:updated_at)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def fetch_text_models_from_db
|
|
93
|
+
providers = configured_providers
|
|
94
|
+
return [] if providers.empty?
|
|
95
|
+
|
|
96
|
+
models = AiModel.text_models_for_providers(providers)
|
|
97
|
+
models.map(&:as_json_for_editor)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fetch_image_models_from_db
|
|
101
|
+
providers = configured_providers
|
|
102
|
+
return [] if providers.empty?
|
|
103
|
+
|
|
104
|
+
models = AiModel.image_models_for_providers(providers)
|
|
105
|
+
models.map(&:as_json_for_editor)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def fetch_vision_models_from_db
|
|
109
|
+
providers = configured_providers
|
|
110
|
+
return [] if providers.empty?
|
|
111
|
+
|
|
112
|
+
models = AiModel.vision_models_for_providers(providers)
|
|
113
|
+
models.map(&:as_json_for_editor)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def configured_providers
|
|
117
|
+
AiConfiguration.configured_providers
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def filter_by_configured_providers(models)
|
|
121
|
+
providers = configured_providers
|
|
122
|
+
return [] if providers.empty?
|
|
123
|
+
|
|
124
|
+
models.select { |m| providers.include?(m[:provider]) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class AiService
|
|
3
|
+
class ImageValidationError < StandardError; end
|
|
4
|
+
class ScreenshotValidationError < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Allowed image types for screenshot-to-code
|
|
7
|
+
ALLOWED_SCREENSHOT_TYPES = %w[png jpeg jpg webp gif].freeze
|
|
8
|
+
|
|
9
|
+
# Magic bytes for image type validation
|
|
10
|
+
IMAGE_MAGIC_BYTES = {
|
|
11
|
+
"png" => "\x89PNG".b,
|
|
12
|
+
"jpeg" => "\xFF\xD8\xFF".b,
|
|
13
|
+
"jpg" => "\xFF\xD8\xFF".b,
|
|
14
|
+
"webp" => "RIFF".b,
|
|
15
|
+
"gif" => "GIF8".b
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# Maximum download size for AI-generated images
|
|
19
|
+
MAX_IMAGE_DOWNLOAD_SIZE = 10.megabytes
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def generate_text(prompt:, model: nil, context: nil, &block)
|
|
23
|
+
AiConfiguration.configure_ruby_llm!
|
|
24
|
+
model ||= Setting.ai_default_text_model
|
|
25
|
+
|
|
26
|
+
chat = RubyLLM.chat(model: model)
|
|
27
|
+
chat.with_instructions(build_system_prompt(context)) if context.present?
|
|
28
|
+
|
|
29
|
+
if block_given?
|
|
30
|
+
chat.ask(prompt, &block)
|
|
31
|
+
else
|
|
32
|
+
chat.ask(prompt)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def generate_image(prompt:, model: nil)
|
|
37
|
+
AiConfiguration.configure_ruby_llm!
|
|
38
|
+
model ||= Setting.ai_default_image_model
|
|
39
|
+
|
|
40
|
+
image = RubyLLM.paint(prompt, model: model)
|
|
41
|
+
store_generated_image(image.url, prompt)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def screenshot_to_code(image_data:, model: nil, additional_prompt: nil)
|
|
45
|
+
AiConfiguration.configure_ruby_llm!
|
|
46
|
+
model ||= Setting.ai_default_vision_model
|
|
47
|
+
framework = Setting.css_framework
|
|
48
|
+
|
|
49
|
+
prompt = build_screenshot_prompt(framework, additional_prompt)
|
|
50
|
+
|
|
51
|
+
# RubyLLM expects a file path, not a data URL
|
|
52
|
+
# Save base64 image to a temp file
|
|
53
|
+
tempfile = save_base64_to_tempfile(image_data)
|
|
54
|
+
|
|
55
|
+
begin
|
|
56
|
+
chat = RubyLLM.chat(model: model)
|
|
57
|
+
response = chat.ask(prompt, with: { image: tempfile.path })
|
|
58
|
+
extract_html(response.content)
|
|
59
|
+
ensure
|
|
60
|
+
tempfile.close
|
|
61
|
+
tempfile.unlink
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_system_prompt(context)
|
|
68
|
+
framework = Setting.css_framework
|
|
69
|
+
<<~PROMPT
|
|
70
|
+
You are an expert web designer creating content for a visual page builder.
|
|
71
|
+
Generate clean, semantic HTML.
|
|
72
|
+
|
|
73
|
+
#{framework_guidelines(framework)}
|
|
74
|
+
|
|
75
|
+
General guidelines:
|
|
76
|
+
- Use proper semantic HTML5 elements (section, article, header, nav, etc.)
|
|
77
|
+
- Include responsive design patterns
|
|
78
|
+
- Return ONLY the HTML code, no explanations or markdown code blocks
|
|
79
|
+
- Do not include <html>, <head>, or <body> tags - just the content
|
|
80
|
+
- Use placeholder images from https://placehold.co/ when images are needed
|
|
81
|
+
|
|
82
|
+
#{context}
|
|
83
|
+
PROMPT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_screenshot_prompt(framework, additional)
|
|
87
|
+
base = <<~PROMPT
|
|
88
|
+
Convert this screenshot into clean HTML.
|
|
89
|
+
|
|
90
|
+
#{framework_guidelines(framework)}
|
|
91
|
+
|
|
92
|
+
Requirements:
|
|
93
|
+
- Create semantic, accessible HTML5 structure
|
|
94
|
+
- Make it fully responsive
|
|
95
|
+
- Use placeholder images from https://placehold.co/ for any images
|
|
96
|
+
- Match the layout, colors, and typography as closely as possible
|
|
97
|
+
- Return ONLY the HTML code, no explanations or markdown code blocks
|
|
98
|
+
- Do not include <html>, <head>, or <body> tags - just the content
|
|
99
|
+
PROMPT
|
|
100
|
+
|
|
101
|
+
additional.present? ? "#{base}\n\nAdditional instructions: #{additional}" : base
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def framework_guidelines(framework)
|
|
105
|
+
case framework.to_s
|
|
106
|
+
when "tailwind"
|
|
107
|
+
<<~GUIDELINES
|
|
108
|
+
CSS Framework: Tailwind CSS v4
|
|
109
|
+
|
|
110
|
+
You MUST use Tailwind CSS utility classes exclusively for all styling. Do NOT use inline styles or custom CSS.
|
|
111
|
+
|
|
112
|
+
Tailwind v4 rules:
|
|
113
|
+
- Use slash syntax for opacity: bg-blue-500/50, text-black/75 (NOT bg-opacity-50 or text-opacity-75)
|
|
114
|
+
- Use modern color syntax: bg-red-500/20 instead of bg-red-500 bg-opacity-20
|
|
115
|
+
- Use arbitrary values with square brackets when needed: w-[72rem], text-[#1a2b3c]
|
|
116
|
+
- Use the new shadow and ring syntax: shadow-sm, ring-1 ring-gray-200
|
|
117
|
+
- Prefer gap-* over space-x-*/space-y-* for flex and grid layouts
|
|
118
|
+
- Use size-* for equal width and height: size-8 instead of w-8 h-8
|
|
119
|
+
- Use grid with grid-cols-subgrid where appropriate
|
|
120
|
+
- All legacy utilities removed in v4 are forbidden (bg-opacity-*, text-opacity-*, divide-opacity-*, etc.)
|
|
121
|
+
GUIDELINES
|
|
122
|
+
when "bootstrap5"
|
|
123
|
+
<<~GUIDELINES
|
|
124
|
+
CSS Framework: Bootstrap 5
|
|
125
|
+
|
|
126
|
+
Use Bootstrap 5 classes exclusively for all styling. Do NOT use inline styles or custom CSS.
|
|
127
|
+
|
|
128
|
+
Bootstrap 5 rules:
|
|
129
|
+
- Use the grid system: container, row, col-*, col-md-*, col-lg-*
|
|
130
|
+
- Use Bootstrap utility classes: d-flex, justify-content-center, align-items-center, p-3, m-2, etc.
|
|
131
|
+
- Use Bootstrap components: card, btn, navbar, alert, badge, etc.
|
|
132
|
+
- Use responsive breakpoints: sm, md, lg, xl, xxl
|
|
133
|
+
- Use spacing utilities: p-*, m-*, gap-*
|
|
134
|
+
- Use text utilities: text-center, fw-bold, fs-*, text-muted
|
|
135
|
+
- Use background utilities: bg-primary, bg-light, bg-dark, etc.
|
|
136
|
+
GUIDELINES
|
|
137
|
+
else
|
|
138
|
+
<<~GUIDELINES
|
|
139
|
+
CSS Framework: None (vanilla CSS)
|
|
140
|
+
|
|
141
|
+
Use inline styles for all styling since no CSS framework is loaded.
|
|
142
|
+
|
|
143
|
+
Vanilla CSS rules:
|
|
144
|
+
- Apply all styles via the style attribute directly on HTML elements
|
|
145
|
+
- Use modern CSS: flexbox, grid, clamp(), min(), max()
|
|
146
|
+
- Ensure responsive behavior with relative units (%, rem, vw) and media queries via <style> blocks when necessary
|
|
147
|
+
- Use CSS custom properties (variables) in a <style> block for consistent theming
|
|
148
|
+
GUIDELINES
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def store_generated_image(url, prompt)
|
|
153
|
+
require "open-uri"
|
|
154
|
+
|
|
155
|
+
uri = URI.parse(url)
|
|
156
|
+
validate_image_url!(uri)
|
|
157
|
+
|
|
158
|
+
filename = "ai_generated_#{Time.current.to_i}_#{SecureRandom.hex(4)}.png"
|
|
159
|
+
|
|
160
|
+
# Download with timeout and size limits
|
|
161
|
+
tempfile = uri.open(
|
|
162
|
+
read_timeout: 30,
|
|
163
|
+
open_timeout: 10,
|
|
164
|
+
"User-Agent" => "ActiveCanvas/#{ActiveCanvas::VERSION rescue '1.0'}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Check downloaded size
|
|
168
|
+
if tempfile.size > MAX_IMAGE_DOWNLOAD_SIZE
|
|
169
|
+
tempfile.close
|
|
170
|
+
raise ImageValidationError, "Downloaded image too large: #{tempfile.size} bytes (max #{MAX_IMAGE_DOWNLOAD_SIZE})"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Validate content type from response
|
|
174
|
+
content_type = tempfile.content_type rescue "image/png"
|
|
175
|
+
unless content_type&.start_with?("image/")
|
|
176
|
+
tempfile.close
|
|
177
|
+
raise ImageValidationError, "Invalid content type: #{content_type}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
media = Media.new(filename: filename)
|
|
181
|
+
media.file.attach(io: tempfile, filename: filename, content_type: content_type)
|
|
182
|
+
media.metadata = { "ai_generated" => true, "ai_prompt" => prompt.truncate(500) }
|
|
183
|
+
media.save!
|
|
184
|
+
media
|
|
185
|
+
ensure
|
|
186
|
+
tempfile&.close
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def validate_image_url!(uri)
|
|
190
|
+
allowed_hosts = ActiveCanvas.config.allowed_ai_image_hosts
|
|
191
|
+
|
|
192
|
+
unless uri.scheme.in?(%w[http https])
|
|
193
|
+
raise ImageValidationError, "Invalid URL scheme: #{uri.scheme}"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check if host is in allowed list (supports subdomain matching)
|
|
197
|
+
host_allowed = allowed_hosts.any? do |allowed|
|
|
198
|
+
uri.host == allowed || uri.host&.end_with?(".#{allowed}")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
unless host_allowed
|
|
202
|
+
Rails.logger.warn "[ActiveCanvas] AI image from untrusted host: #{uri.host}"
|
|
203
|
+
# For now, log a warning but allow the download
|
|
204
|
+
# Uncomment the line below to enforce strict host validation:
|
|
205
|
+
# raise ImageValidationError, "Untrusted image host: #{uri.host}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def extract_html(content)
|
|
210
|
+
# Remove markdown code blocks if present
|
|
211
|
+
html = content.to_s
|
|
212
|
+
html = html.gsub(/```html\n?/i, "")
|
|
213
|
+
html = html.gsub(/```\n?/, "")
|
|
214
|
+
html.strip
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def save_base64_to_tempfile(data_url)
|
|
218
|
+
max_size = ActiveCanvas.config.max_screenshot_size
|
|
219
|
+
|
|
220
|
+
# Check size before decoding (base64 is ~33% larger than binary)
|
|
221
|
+
if data_url.bytesize > (max_size * 1.4)
|
|
222
|
+
raise ScreenshotValidationError, "Screenshot data too large (max #{max_size / 1.megabyte}MB)"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Extract base64 data from data URL (data:image/png;base64,...)
|
|
226
|
+
if data_url.start_with?("data:")
|
|
227
|
+
unless data_url.start_with?("data:image/")
|
|
228
|
+
raise ScreenshotValidationError, "Invalid image data URL: must be an image"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Parse the data URL with strict type matching
|
|
232
|
+
pattern = %r{^data:image/(#{ALLOWED_SCREENSHOT_TYPES.join('|')});base64,(.+)$}i
|
|
233
|
+
matches = data_url.match(pattern)
|
|
234
|
+
|
|
235
|
+
unless matches
|
|
236
|
+
raise ScreenshotValidationError, "Invalid or unsupported image format. Allowed: #{ALLOWED_SCREENSHOT_TYPES.join(', ')}"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
extension = matches[1].downcase
|
|
240
|
+
base64_data = matches[2]
|
|
241
|
+
else
|
|
242
|
+
# Assume raw base64 data (default to PNG)
|
|
243
|
+
extension = "png"
|
|
244
|
+
base64_data = data_url
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Decode base64
|
|
248
|
+
begin
|
|
249
|
+
image_binary = Base64.strict_decode64(base64_data)
|
|
250
|
+
rescue ArgumentError => e
|
|
251
|
+
raise ScreenshotValidationError, "Invalid base64 data: #{e.message}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Check decoded size
|
|
255
|
+
if image_binary.bytesize > max_size
|
|
256
|
+
raise ScreenshotValidationError, "Screenshot too large: #{image_binary.bytesize} bytes (max #{max_size})"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Validate magic bytes match declared type
|
|
260
|
+
validate_image_magic_bytes!(image_binary, extension)
|
|
261
|
+
|
|
262
|
+
# Create temp file with proper extension
|
|
263
|
+
tempfile = Tempfile.new(["screenshot", ".#{extension}"])
|
|
264
|
+
tempfile.binmode
|
|
265
|
+
tempfile.write(image_binary)
|
|
266
|
+
tempfile.rewind
|
|
267
|
+
|
|
268
|
+
tempfile
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def validate_image_magic_bytes!(data, expected_type)
|
|
272
|
+
expected_magic = IMAGE_MAGIC_BYTES[expected_type]
|
|
273
|
+
return unless expected_magic # Unknown type, skip validation
|
|
274
|
+
|
|
275
|
+
# Special handling for WebP (RIFF....WEBP)
|
|
276
|
+
if expected_type == "webp"
|
|
277
|
+
unless data[0..3] == "RIFF".b && data[8..11] == "WEBP".b
|
|
278
|
+
raise ScreenshotValidationError, "File content doesn't match declared type (expected WebP)"
|
|
279
|
+
end
|
|
280
|
+
return
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
unless data.byteslice(0, expected_magic.bytesize) == expected_magic
|
|
284
|
+
raise ScreenshotValidationError, "File content doesn't match declared type (expected #{expected_type.upcase})"
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class ContentSanitizer
|
|
3
|
+
class << self
|
|
4
|
+
# Sanitize HTML content using Rails' built-in sanitizer
|
|
5
|
+
def sanitize_html(content)
|
|
6
|
+
return content if content.blank?
|
|
7
|
+
return content unless ActiveCanvas.config.sanitize_content
|
|
8
|
+
|
|
9
|
+
config = ActiveCanvas.config
|
|
10
|
+
|
|
11
|
+
# Use Rails' SafeListSanitizer with our allowed tags/attributes
|
|
12
|
+
sanitizer = Rails::HTML5::SafeListSanitizer.new
|
|
13
|
+
|
|
14
|
+
# Build scrubber for data-* and aria-* attributes
|
|
15
|
+
scrubber = PermissiveAttributeScrubber.new(
|
|
16
|
+
allowed_tags: config.allowed_html_tags,
|
|
17
|
+
allowed_attributes: config.allowed_html_attributes
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
sanitizer.sanitize(content, scrubber: scrubber)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Sanitize CSS content (basic XSS protection)
|
|
24
|
+
def sanitize_css(css)
|
|
25
|
+
return css if css.blank?
|
|
26
|
+
return css unless ActiveCanvas.config.sanitize_content
|
|
27
|
+
|
|
28
|
+
# Remove potentially dangerous CSS patterns
|
|
29
|
+
sanitized = css.dup
|
|
30
|
+
|
|
31
|
+
# Remove JavaScript URLs
|
|
32
|
+
sanitized.gsub!(/url\s*\(\s*["']?\s*javascript:/i, "url(blocked:")
|
|
33
|
+
|
|
34
|
+
# Remove expression() (IE-specific XSS vector)
|
|
35
|
+
sanitized.gsub!(/expression\s*\(/i, "blocked(")
|
|
36
|
+
|
|
37
|
+
# Remove behavior: (IE-specific XSS vector)
|
|
38
|
+
sanitized.gsub!(/behavior\s*:/i, "blocked:")
|
|
39
|
+
|
|
40
|
+
# Remove -moz-binding (Firefox XSS vector)
|
|
41
|
+
sanitized.gsub!(/-moz-binding\s*:/i, "blocked:")
|
|
42
|
+
|
|
43
|
+
# Remove @import with javascript
|
|
44
|
+
sanitized.gsub!(/@import\s+["']?\s*javascript:/i, "/* blocked */")
|
|
45
|
+
|
|
46
|
+
sanitized
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Sanitize JavaScript (very restrictive - mainly for tracking scripts)
|
|
50
|
+
def sanitize_js(js)
|
|
51
|
+
return js if js.blank?
|
|
52
|
+
|
|
53
|
+
# For now, we just return the JS as-is
|
|
54
|
+
# Users who enable JS are accepting responsibility
|
|
55
|
+
# In the future, could add CSP nonce support
|
|
56
|
+
js
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Custom scrubber that allows data-* and aria-* attributes
|
|
61
|
+
class PermissiveAttributeScrubber < Rails::HTML::PermitScrubber
|
|
62
|
+
def initialize(allowed_tags:, allowed_attributes:)
|
|
63
|
+
super()
|
|
64
|
+
self.tags = allowed_tags
|
|
65
|
+
@allowed_attributes = allowed_attributes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def allowed_node?(node)
|
|
69
|
+
return false unless super
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def scrub_attribute(node, attr_node)
|
|
74
|
+
attr_name = attr_node.name.downcase
|
|
75
|
+
|
|
76
|
+
# Allow explicitly listed attributes
|
|
77
|
+
return if @allowed_attributes.include?(attr_name)
|
|
78
|
+
|
|
79
|
+
# Allow data-* attributes
|
|
80
|
+
return if attr_name.start_with?("data-")
|
|
81
|
+
|
|
82
|
+
# Allow aria-* attributes
|
|
83
|
+
return if attr_name.start_with?("aria-")
|
|
84
|
+
|
|
85
|
+
# Check for dangerous attribute values (javascript: URLs, event handlers)
|
|
86
|
+
if dangerous_attribute?(attr_name, attr_node.value)
|
|
87
|
+
attr_node.remove
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Remove unlisted attributes
|
|
92
|
+
attr_node.remove
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def dangerous_attribute?(name, value)
|
|
98
|
+
# Event handlers
|
|
99
|
+
return true if name.start_with?("on")
|
|
100
|
+
|
|
101
|
+
# JavaScript URLs in href, src, etc.
|
|
102
|
+
if %w[href src action formaction].include?(name)
|
|
103
|
+
return true if value.to_s.strip.downcase.start_with?("javascript:")
|
|
104
|
+
return true if value.to_s.strip.downcase.start_with?("data:text/html")
|
|
105
|
+
return true if value.to_s.strip.downcase.start_with?("vbscript:")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|