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.
Files changed (80) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +318 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/active_canvas/editor/ai_panel.js +1607 -0
  6. data/app/assets/javascripts/active_canvas/editor/asset_manager.js +498 -0
  7. data/app/assets/javascripts/active_canvas/editor/blocks.js +1083 -0
  8. data/app/assets/javascripts/active_canvas/editor/code_panel.js +572 -0
  9. data/app/assets/javascripts/active_canvas/editor/component_toolbar.js +394 -0
  10. data/app/assets/javascripts/active_canvas/editor/panels.js +460 -0
  11. data/app/assets/javascripts/active_canvas/editor/utils.js +56 -0
  12. data/app/assets/javascripts/active_canvas/editor.js +295 -0
  13. data/app/assets/stylesheets/active_canvas/application.css +15 -0
  14. data/app/assets/stylesheets/active_canvas/editor.css +2929 -0
  15. data/app/controllers/active_canvas/admin/ai_controller.rb +181 -0
  16. data/app/controllers/active_canvas/admin/application_controller.rb +56 -0
  17. data/app/controllers/active_canvas/admin/media_controller.rb +61 -0
  18. data/app/controllers/active_canvas/admin/page_types_controller.rb +57 -0
  19. data/app/controllers/active_canvas/admin/page_versions_controller.rb +23 -0
  20. data/app/controllers/active_canvas/admin/pages_controller.rb +133 -0
  21. data/app/controllers/active_canvas/admin/partials_controller.rb +88 -0
  22. data/app/controllers/active_canvas/admin/settings_controller.rb +256 -0
  23. data/app/controllers/active_canvas/application_controller.rb +20 -0
  24. data/app/controllers/active_canvas/pages_controller.rb +18 -0
  25. data/app/controllers/concerns/active_canvas/current_user.rb +12 -0
  26. data/app/controllers/concerns/active_canvas/rate_limitable.rb +75 -0
  27. data/app/controllers/concerns/active_canvas/tailwind_compilation.rb +39 -0
  28. data/app/helpers/active_canvas/application_helper.rb +4 -0
  29. data/app/jobs/active_canvas/application_job.rb +4 -0
  30. data/app/jobs/active_canvas/compile_tailwind_job.rb +64 -0
  31. data/app/mailers/active_canvas/application_mailer.rb +6 -0
  32. data/app/models/active_canvas/ai_model.rb +136 -0
  33. data/app/models/active_canvas/application_record.rb +5 -0
  34. data/app/models/active_canvas/media.rb +141 -0
  35. data/app/models/active_canvas/page.rb +85 -0
  36. data/app/models/active_canvas/page_type.rb +22 -0
  37. data/app/models/active_canvas/page_version.rb +80 -0
  38. data/app/models/active_canvas/partial.rb +73 -0
  39. data/app/models/active_canvas/setting.rb +292 -0
  40. data/app/services/active_canvas/ai_configuration.rb +40 -0
  41. data/app/services/active_canvas/ai_models.rb +128 -0
  42. data/app/services/active_canvas/ai_service.rb +289 -0
  43. data/app/services/active_canvas/content_sanitizer.rb +112 -0
  44. data/app/services/active_canvas/tailwind_compiler.rb +156 -0
  45. data/app/views/active_canvas/admin/media/index.html.erb +401 -0
  46. data/app/views/active_canvas/admin/media/show.html.erb +297 -0
  47. data/app/views/active_canvas/admin/page_types/_form.html.erb +25 -0
  48. data/app/views/active_canvas/admin/page_types/edit.html.erb +13 -0
  49. data/app/views/active_canvas/admin/page_types/index.html.erb +29 -0
  50. data/app/views/active_canvas/admin/page_types/new.html.erb +9 -0
  51. data/app/views/active_canvas/admin/page_types/show.html.erb +18 -0
  52. data/app/views/active_canvas/admin/page_versions/show.html.erb +469 -0
  53. data/app/views/active_canvas/admin/pages/_form.html.erb +62 -0
  54. data/app/views/active_canvas/admin/pages/content.html.erb +139 -0
  55. data/app/views/active_canvas/admin/pages/edit.html.erb +335 -0
  56. data/app/views/active_canvas/admin/pages/editor.html.erb +710 -0
  57. data/app/views/active_canvas/admin/pages/index.html.erb +149 -0
  58. data/app/views/active_canvas/admin/pages/new.html.erb +19 -0
  59. data/app/views/active_canvas/admin/pages/show.html.erb +258 -0
  60. data/app/views/active_canvas/admin/pages/versions.html.erb +333 -0
  61. data/app/views/active_canvas/admin/partials/edit.html.erb +182 -0
  62. data/app/views/active_canvas/admin/partials/editor.html.erb +703 -0
  63. data/app/views/active_canvas/admin/partials/index.html.erb +131 -0
  64. data/app/views/active_canvas/admin/settings/show.html.erb +1864 -0
  65. data/app/views/active_canvas/pages/no_homepage.html.erb +45 -0
  66. data/app/views/active_canvas/pages/show.html.erb +113 -0
  67. data/app/views/layouts/active_canvas/admin/application.html.erb +960 -0
  68. data/app/views/layouts/active_canvas/admin/editor.html.erb +826 -0
  69. data/app/views/layouts/active_canvas/application.html.erb +55 -0
  70. data/config/routes.rb +48 -0
  71. data/db/migrate/20260202000001_create_active_canvas_tables.rb +113 -0
  72. data/db/migrate/20260202000002_create_active_canvas_ai_models.rb +26 -0
  73. data/lib/active_canvas/configuration.rb +232 -0
  74. data/lib/active_canvas/engine.rb +44 -0
  75. data/lib/active_canvas/version.rb +3 -0
  76. data/lib/active_canvas.rb +26 -0
  77. data/lib/generators/active_canvas/install/install_generator.rb +263 -0
  78. data/lib/generators/active_canvas/install/templates/initializer.rb.tt +163 -0
  79. data/lib/tasks/active_canvas_tasks.rake +69 -0
  80. 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