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,141 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class Media < ApplicationRecord
|
|
3
|
+
# Use configured storage service if specified, otherwise default
|
|
4
|
+
# Host app can set ActiveCanvas.config.storage_service = :public
|
|
5
|
+
# and define a 'public' service in config/storage.yml for cloud storage
|
|
6
|
+
has_one_attached :file, service: (ActiveCanvas.config.storage_service rescue nil) do |attachable|
|
|
7
|
+
attachable.variant :thumb, resize_to_limit: [200, 200]
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
serialize :metadata, coder: JSON
|
|
11
|
+
|
|
12
|
+
validates :filename, presence: true
|
|
13
|
+
validates :file, presence: true, on: :create
|
|
14
|
+
|
|
15
|
+
validate :acceptable_file, on: :create
|
|
16
|
+
|
|
17
|
+
before_save :set_file_attributes, if: -> { file.attached? && file.blob.present? }
|
|
18
|
+
after_commit :make_blob_public, on: [:create, :update], if: :should_make_public?
|
|
19
|
+
|
|
20
|
+
scope :images, -> { where(content_type: ActiveCanvas.config.allowed_content_types) }
|
|
21
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
22
|
+
|
|
23
|
+
def metadata
|
|
24
|
+
super || {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def url
|
|
28
|
+
return nil unless file.attached?
|
|
29
|
+
|
|
30
|
+
# If public_uploads is enabled and blob service supports public URLs
|
|
31
|
+
if ActiveCanvas.config.public_uploads &&
|
|
32
|
+
file.blob.service.respond_to?(:public?) &&
|
|
33
|
+
file.blob.service.public?
|
|
34
|
+
file.url
|
|
35
|
+
else
|
|
36
|
+
# Use signed URL with expiration for better security
|
|
37
|
+
Rails.application.routes.url_helpers.rails_blob_url(
|
|
38
|
+
file,
|
|
39
|
+
expires_in: 1.hour,
|
|
40
|
+
only_path: true
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
# Fallback for services that don't support expires_in
|
|
45
|
+
Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def public_url
|
|
49
|
+
return nil unless file.attached?
|
|
50
|
+
|
|
51
|
+
# Returns the direct public URL for cloud storage
|
|
52
|
+
# or the rails blob URL for local storage
|
|
53
|
+
if file.blob.service.respond_to?(:url)
|
|
54
|
+
file.url(expires_in: nil) rescue file.url
|
|
55
|
+
else
|
|
56
|
+
url
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def as_json_for_editor
|
|
61
|
+
{
|
|
62
|
+
id: id,
|
|
63
|
+
src: url,
|
|
64
|
+
name: filename,
|
|
65
|
+
type: content_type,
|
|
66
|
+
width: metadata["width"],
|
|
67
|
+
height: metadata["height"]
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def set_file_attributes
|
|
74
|
+
self.content_type = file.blob.content_type
|
|
75
|
+
self.byte_size = file.blob.byte_size
|
|
76
|
+
self.filename = file.blob.filename.to_s if filename.blank?
|
|
77
|
+
|
|
78
|
+
# Store blob metadata (dimensions for images) if available
|
|
79
|
+
if file.blob.metadata.present?
|
|
80
|
+
self.metadata = file.blob.metadata
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def acceptable_file
|
|
85
|
+
return unless file.attached?
|
|
86
|
+
|
|
87
|
+
content_type = file.content_type
|
|
88
|
+
config = ActiveCanvas.config
|
|
89
|
+
|
|
90
|
+
# Check against dangerous content types first
|
|
91
|
+
if ActiveCanvas::Configuration::DANGEROUS_CONTENT_TYPES.include?(content_type)
|
|
92
|
+
errors.add(:file, "type is not allowed for security reasons")
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Special handling for SVG
|
|
97
|
+
if content_type == "image/svg+xml"
|
|
98
|
+
unless config.allow_svg_uploads
|
|
99
|
+
errors.add(:file, "SVG uploads are disabled for security reasons. Contact your administrator if you need to upload SVG files.")
|
|
100
|
+
return
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check against allowed content types
|
|
105
|
+
unless config.effective_allowed_content_types.include?(content_type)
|
|
106
|
+
allowed = config.effective_allowed_content_types.map { |t| t.split("/").last.upcase }.join(", ")
|
|
107
|
+
errors.add(:file, "type is not allowed. Allowed types: #{allowed}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if file.blob.byte_size > config.max_upload_size
|
|
111
|
+
errors.add(:file, "is too large (maximum is #{config.max_upload_size / 1.megabyte}MB)")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def should_make_public?
|
|
116
|
+
file.attached? && ActiveCanvas.config.public_uploads
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def make_blob_public
|
|
120
|
+
return unless file.attached? && file.blob.present?
|
|
121
|
+
|
|
122
|
+
blob = file.blob
|
|
123
|
+
|
|
124
|
+
# For S3 and compatible services, set the blob to public-read ACL
|
|
125
|
+
if blob.service.respond_to?(:bucket)
|
|
126
|
+
begin
|
|
127
|
+
# AWS S3
|
|
128
|
+
if blob.service.respond_to?(:object_for)
|
|
129
|
+
object = blob.service.object_for(blob.key)
|
|
130
|
+
object.acl.put(acl: "public-read") if object.respond_to?(:acl)
|
|
131
|
+
Rails.logger.info "ActiveCanvas: Set public-read ACL for #{blob.key}"
|
|
132
|
+
end
|
|
133
|
+
rescue Aws::S3::Errors::AccessDenied => e
|
|
134
|
+
Rails.logger.warn "ActiveCanvas: Access denied setting public ACL. Ensure your S3 bucket allows public ACLs: #{e.message}"
|
|
135
|
+
rescue => e
|
|
136
|
+
Rails.logger.warn "ActiveCanvas: Could not set public ACL for blob #{blob.key}: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class Page < ApplicationRecord
|
|
3
|
+
belongs_to :page_type
|
|
4
|
+
has_many :versions, class_name: "ActiveCanvas::PageVersion", dependent: :destroy
|
|
5
|
+
|
|
6
|
+
validates :title, presence: true
|
|
7
|
+
validates :slug, uniqueness: true, allow_blank: true
|
|
8
|
+
|
|
9
|
+
before_save :set_default_slug
|
|
10
|
+
before_save :normalize_slug
|
|
11
|
+
before_save :sanitize_content_if_enabled
|
|
12
|
+
after_update :create_version_if_content_changed
|
|
13
|
+
|
|
14
|
+
scope :published, -> { where(published: true) }
|
|
15
|
+
scope :draft, -> { where(published: false) }
|
|
16
|
+
|
|
17
|
+
# Thread-local storage for tracking who made the change
|
|
18
|
+
thread_cattr_accessor :current_editor
|
|
19
|
+
|
|
20
|
+
def to_param
|
|
21
|
+
id&.to_s
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def rendered_content
|
|
25
|
+
content.to_s.html_safe
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def current_version_number
|
|
29
|
+
versions.maximum(:version_number) || 0
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Header/footer display (with fallback for when columns don't exist yet)
|
|
33
|
+
def show_header?
|
|
34
|
+
return true unless self.class.column_names.include?("show_header")
|
|
35
|
+
show_header != false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_footer?
|
|
39
|
+
return true unless self.class.column_names.include?("show_footer")
|
|
40
|
+
show_footer != false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def set_default_slug
|
|
46
|
+
self.slug = "active_canvas_id_#{id}" if slug.blank? && persisted?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def normalize_slug
|
|
50
|
+
self.slug = slug.parameterize if slug.present?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sanitize_content_if_enabled
|
|
54
|
+
return unless ActiveCanvas.config.sanitize_content
|
|
55
|
+
|
|
56
|
+
if content_changed?
|
|
57
|
+
self.content = ContentSanitizer.sanitize_html(content)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if content_css_changed?
|
|
61
|
+
self.content_css = ContentSanitizer.sanitize_css(content_css)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def create_version_if_content_changed
|
|
66
|
+
return unless saved_change_to_content? || saved_change_to_content_css?
|
|
67
|
+
|
|
68
|
+
versions.create!(
|
|
69
|
+
content_before: content_before_last_save,
|
|
70
|
+
content_after: content,
|
|
71
|
+
css_before: content_css_before_last_save,
|
|
72
|
+
css_after: content_css,
|
|
73
|
+
changed_by: self.class.current_editor,
|
|
74
|
+
change_summary: generate_change_summary
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def generate_change_summary
|
|
79
|
+
changes = []
|
|
80
|
+
changes << "content updated" if saved_change_to_content?
|
|
81
|
+
changes << "CSS updated" if saved_change_to_content_css?
|
|
82
|
+
changes.join(", ").presence || "Updated"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class PageType < ApplicationRecord
|
|
3
|
+
has_many :pages, dependent: :restrict_with_error
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :key, presence: true, uniqueness: true
|
|
7
|
+
|
|
8
|
+
before_validation :generate_key, on: :create
|
|
9
|
+
|
|
10
|
+
def self.default
|
|
11
|
+
find_or_create_by!(key: "page") do |pt|
|
|
12
|
+
pt.name = "Page"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def generate_key
|
|
19
|
+
self.key ||= name&.parameterize&.underscore
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class PageVersion < ApplicationRecord
|
|
3
|
+
belongs_to :page
|
|
4
|
+
|
|
5
|
+
validates :version_number, presence: true, uniqueness: { scope: :page_id }
|
|
6
|
+
|
|
7
|
+
before_validation :set_version_number, on: :create
|
|
8
|
+
before_create :compute_diff
|
|
9
|
+
before_create :set_content_sizes
|
|
10
|
+
|
|
11
|
+
scope :recent, -> { order(version_number: :desc) }
|
|
12
|
+
scope :oldest_first, -> { order(version_number: :asc) }
|
|
13
|
+
|
|
14
|
+
def content_changed?
|
|
15
|
+
content_before != content_after
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def css_changed?
|
|
19
|
+
css_before != css_after
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def size_difference
|
|
23
|
+
(content_size_after || 0) - (content_size_before || 0)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def size_difference_formatted
|
|
27
|
+
diff = size_difference
|
|
28
|
+
return "no change" if diff == 0
|
|
29
|
+
diff > 0 ? "+#{diff} bytes" : "#{diff} bytes"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def changes_description
|
|
33
|
+
changes = []
|
|
34
|
+
changes << "content" if content_changed?
|
|
35
|
+
changes << "CSS" if css_changed?
|
|
36
|
+
changes.empty? ? "No changes" : "Changed: #{changes.join(', ')}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def set_version_number
|
|
42
|
+
return if version_number.present?
|
|
43
|
+
max_version = page.versions.maximum(:version_number) || 0
|
|
44
|
+
self.version_number = max_version + 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def compute_diff
|
|
48
|
+
return unless content_before.present? && content_after.present?
|
|
49
|
+
self.content_diff = generate_unified_diff(content_before, content_after)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def set_content_sizes
|
|
53
|
+
self.content_size_before = content_before&.bytesize || 0
|
|
54
|
+
self.content_size_after = content_after&.bytesize || 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def generate_unified_diff(before, after)
|
|
58
|
+
before_lines = before.to_s.lines
|
|
59
|
+
after_lines = after.to_s.lines
|
|
60
|
+
|
|
61
|
+
# Simple line-by-line diff
|
|
62
|
+
diff_lines = []
|
|
63
|
+
max_lines = [before_lines.size, after_lines.size].max
|
|
64
|
+
|
|
65
|
+
max_lines.times do |i|
|
|
66
|
+
before_line = before_lines[i]
|
|
67
|
+
after_line = after_lines[i]
|
|
68
|
+
|
|
69
|
+
if before_line == after_line
|
|
70
|
+
diff_lines << " #{before_line}"
|
|
71
|
+
else
|
|
72
|
+
diff_lines << "- #{before_line}" if before_line
|
|
73
|
+
diff_lines << "+ #{after_line}" if after_line
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
diff_lines.join
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class Partial < ApplicationRecord
|
|
3
|
+
TYPES = %w[header footer].freeze
|
|
4
|
+
|
|
5
|
+
validates :name, presence: true
|
|
6
|
+
validates :partial_type, presence: true, inclusion: { in: TYPES }, uniqueness: true
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where(active: true) }
|
|
9
|
+
|
|
10
|
+
after_save :compile_tailwind_css, if: :should_compile_css?
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def header
|
|
14
|
+
find_by(partial_type: "header")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def footer
|
|
18
|
+
find_by(partial_type: "footer")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def active_header
|
|
22
|
+
active.find_by(partial_type: "header")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active_footer
|
|
26
|
+
active.find_by(partial_type: "footer")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Ensure both partials exist
|
|
30
|
+
def ensure_defaults!
|
|
31
|
+
find_or_create_by!(partial_type: "header") do |p|
|
|
32
|
+
p.name = "Header"
|
|
33
|
+
end
|
|
34
|
+
find_or_create_by!(partial_type: "footer") do |p|
|
|
35
|
+
p.name = "Footer"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def header?
|
|
41
|
+
partial_type == "header"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def footer?
|
|
45
|
+
partial_type == "footer"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rendered_content
|
|
49
|
+
content.to_s.html_safe
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def full_css
|
|
53
|
+
[compiled_css, content_css].compact.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def should_compile_css?
|
|
59
|
+
saved_change_to_content? && content.present? && TailwindCompiler.available?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def compile_tailwind_css
|
|
63
|
+
return unless TailwindCompiler.available?
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
compiled = TailwindCompiler.compile(content.to_s, identifier: "partial ##{id} (#{name})")
|
|
67
|
+
update_column(:compiled_css, compiled)
|
|
68
|
+
rescue TailwindCompiler::CompilationError => e
|
|
69
|
+
Rails.logger.error "[ActiveCanvas] Failed to compile Tailwind CSS for partial #{id}: #{e.message}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
module ActiveCanvas
|
|
2
|
+
class Setting < ApplicationRecord
|
|
3
|
+
validates :key, presence: true, uniqueness: true
|
|
4
|
+
|
|
5
|
+
# Keys that should be encrypted in the database
|
|
6
|
+
ENCRYPTED_KEYS = %w[
|
|
7
|
+
ai_openai_api_key
|
|
8
|
+
ai_anthropic_api_key
|
|
9
|
+
ai_openrouter_api_key
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
CSS_FRAMEWORKS = {
|
|
13
|
+
"tailwind" => {
|
|
14
|
+
name: "Tailwind CSS",
|
|
15
|
+
url: "https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4",
|
|
16
|
+
type: :script
|
|
17
|
+
},
|
|
18
|
+
"bootstrap5" => {
|
|
19
|
+
name: "Bootstrap 5",
|
|
20
|
+
url: "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css",
|
|
21
|
+
type: :stylesheet
|
|
22
|
+
},
|
|
23
|
+
"custom" => {
|
|
24
|
+
name: "Custom CSS (no framework)",
|
|
25
|
+
url: nil,
|
|
26
|
+
type: nil
|
|
27
|
+
}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def get(key)
|
|
32
|
+
setting = find_by(key: key)
|
|
33
|
+
return nil unless setting
|
|
34
|
+
|
|
35
|
+
if ENCRYPTED_KEYS.include?(key.to_s) && setting.encrypted_value.present?
|
|
36
|
+
decrypt_value(setting.encrypted_value)
|
|
37
|
+
else
|
|
38
|
+
setting.value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set(key, value)
|
|
43
|
+
setting = find_or_initialize_by(key: key)
|
|
44
|
+
|
|
45
|
+
if ENCRYPTED_KEYS.include?(key.to_s) && value.present?
|
|
46
|
+
setting.encrypted_value = encrypt_value(value)
|
|
47
|
+
setting.value = nil # Clear plain text value
|
|
48
|
+
else
|
|
49
|
+
setting.value = value
|
|
50
|
+
setting.encrypted_value = nil if ENCRYPTED_KEYS.include?(key.to_s)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
setting.save!
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if an API key is configured (without revealing the value)
|
|
58
|
+
def api_key_configured?(key)
|
|
59
|
+
setting = find_by(key: key)
|
|
60
|
+
return false unless setting
|
|
61
|
+
|
|
62
|
+
setting.encrypted_value.present? || setting.value.present?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get a masked version of the API key for display (last 4 chars only)
|
|
66
|
+
def masked_api_key(key)
|
|
67
|
+
value = get(key)
|
|
68
|
+
return nil if value.blank?
|
|
69
|
+
|
|
70
|
+
mask_value(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def encrypt_value(value)
|
|
76
|
+
return nil if value.blank?
|
|
77
|
+
|
|
78
|
+
encryptor.encrypt_and_sign(value)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def decrypt_value(encrypted_value)
|
|
82
|
+
return nil if encrypted_value.blank?
|
|
83
|
+
|
|
84
|
+
encryptor.decrypt_and_verify(encrypted_value)
|
|
85
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage => e
|
|
86
|
+
Rails.logger.error "[ActiveCanvas] Failed to decrypt setting: #{e.message}"
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def encryptor
|
|
91
|
+
@encryptor ||= begin
|
|
92
|
+
secret = secret_key_base
|
|
93
|
+
key = ActiveSupport::KeyGenerator.new(secret).generate_key("active_canvas_settings", 32)
|
|
94
|
+
ActiveSupport::MessageEncryptor.new(key)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def secret_key_base
|
|
99
|
+
if Rails.application.respond_to?(:secret_key_base)
|
|
100
|
+
Rails.application.secret_key_base
|
|
101
|
+
elsif Rails.application.respond_to?(:credentials) && Rails.application.credentials.secret_key_base
|
|
102
|
+
Rails.application.credentials.secret_key_base
|
|
103
|
+
else
|
|
104
|
+
raise "Rails secret_key_base not available for encryption"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mask_value(value)
|
|
109
|
+
return nil if value.blank?
|
|
110
|
+
return "****" if value.length <= 8
|
|
111
|
+
|
|
112
|
+
"****#{value[-4..]}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
public
|
|
116
|
+
|
|
117
|
+
def homepage_page_id
|
|
118
|
+
get("homepage_page_id")&.to_i
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def homepage_page_id=(page_id)
|
|
122
|
+
set("homepage_page_id", page_id.presence)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def homepage
|
|
126
|
+
page_id = homepage_page_id
|
|
127
|
+
return nil unless page_id&.positive?
|
|
128
|
+
|
|
129
|
+
Page.published.find_by(id: page_id)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def css_framework
|
|
133
|
+
get("css_framework") || ActiveCanvas.config.css_framework.to_s
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def css_framework=(framework)
|
|
137
|
+
set("css_framework", framework.presence)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def css_framework_config
|
|
141
|
+
CSS_FRAMEWORKS[css_framework] || CSS_FRAMEWORKS["custom"]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def css_framework_url
|
|
145
|
+
css_framework_config[:url]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def css_framework_type
|
|
149
|
+
css_framework_config[:type]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def global_css
|
|
153
|
+
get("global_css") || ""
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def global_css=(css)
|
|
157
|
+
set("global_css", css)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def global_js
|
|
161
|
+
get("global_js") || ""
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def global_js=(js)
|
|
165
|
+
set("global_js", js)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# AI API Keys
|
|
169
|
+
def ai_openai_api_key
|
|
170
|
+
get("ai_openai_api_key")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def ai_openai_api_key=(key)
|
|
174
|
+
set("ai_openai_api_key", key.presence)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def ai_anthropic_api_key
|
|
178
|
+
get("ai_anthropic_api_key")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def ai_anthropic_api_key=(key)
|
|
182
|
+
set("ai_anthropic_api_key", key.presence)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def ai_openrouter_api_key
|
|
186
|
+
get("ai_openrouter_api_key")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def ai_openrouter_api_key=(key)
|
|
190
|
+
set("ai_openrouter_api_key", key.presence)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# AI Default Models
|
|
194
|
+
def ai_default_text_model
|
|
195
|
+
get("ai_default_text_model") || "gpt-4o-mini"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def ai_default_text_model=(model)
|
|
199
|
+
set("ai_default_text_model", model.presence)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def ai_default_image_model
|
|
203
|
+
get("ai_default_image_model") || "dall-e-3"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def ai_default_image_model=(model)
|
|
207
|
+
set("ai_default_image_model", model.presence)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ai_default_vision_model
|
|
211
|
+
get("ai_default_vision_model") || "gpt-4o"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def ai_default_vision_model=(model)
|
|
215
|
+
set("ai_default_vision_model", model.presence)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# AI Connection Mode
|
|
219
|
+
def ai_connection_mode
|
|
220
|
+
get("ai_connection_mode") || "server"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def ai_connection_mode=(value)
|
|
224
|
+
set("ai_connection_mode", value.presence || "server")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# AI Feature Toggles
|
|
228
|
+
def ai_text_enabled?
|
|
229
|
+
get("ai_text_enabled") != "false"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def ai_text_enabled=(enabled)
|
|
233
|
+
set("ai_text_enabled", enabled.to_s)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def ai_image_enabled?
|
|
237
|
+
get("ai_image_enabled") != "false"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def ai_image_enabled=(enabled)
|
|
241
|
+
set("ai_image_enabled", enabled.to_s)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def ai_screenshot_enabled?
|
|
245
|
+
get("ai_screenshot_enabled") != "false"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def ai_screenshot_enabled=(enabled)
|
|
249
|
+
set("ai_screenshot_enabled", enabled.to_s)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Tailwind Configuration
|
|
253
|
+
DEFAULT_TAILWIND_CONFIG = {
|
|
254
|
+
theme: {
|
|
255
|
+
extend: {
|
|
256
|
+
colors: {},
|
|
257
|
+
fontFamily: {}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}.freeze
|
|
261
|
+
|
|
262
|
+
def tailwind_compiled_mode?
|
|
263
|
+
css_framework == "tailwind" && ActiveCanvas::TailwindCompiler.available?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def tailwind_config
|
|
267
|
+
raw = get("tailwind_config")
|
|
268
|
+
return DEFAULT_TAILWIND_CONFIG.deep_dup if raw.blank?
|
|
269
|
+
|
|
270
|
+
JSON.parse(raw).deep_symbolize_keys
|
|
271
|
+
rescue JSON::ParserError
|
|
272
|
+
DEFAULT_TAILWIND_CONFIG.deep_dup
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def tailwind_config=(config)
|
|
276
|
+
value = case config
|
|
277
|
+
when String
|
|
278
|
+
config
|
|
279
|
+
when Hash
|
|
280
|
+
config.to_json
|
|
281
|
+
else
|
|
282
|
+
config.to_s
|
|
283
|
+
end
|
|
284
|
+
set("tailwind_config", value)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def tailwind_config_js
|
|
288
|
+
tailwind_config.to_json
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|