active_canvas 0.0.2 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 379f677cf18be6230e9307e1380a024d6252a4add2b2cd954dfa6769a91b2541
4
- data.tar.gz: fb55ab3e7801bae61423982e903cf61d35c396fb523209154a730e67b6fe93ff
3
+ metadata.gz: b6178be7aae3ee9f6da228b4d3aa8e0927c4faf622a7215b313616f4fda4d87d
4
+ data.tar.gz: 64b25a9e874d070f4289013c8d66cc1a3896fbf35bab1189f80ab84e01790593
5
5
  SHA512:
6
- metadata.gz: f68e873c9f4950c4b11ecf7a673d4edf911a719bbea2f163e9da45809facebf2a5ec822293dd1077922106a507f4e017b1d1312ca7ab99a207a5212cae8fda2e
7
- data.tar.gz: cfcb65632db1f023976acf5699880d615120f6ae669922c4f5c5d39cb0b61ce1daf0fed55a136e9b3b9d6e585d08143eca45b8446602f30b2dfa03686904eac2
6
+ metadata.gz: 69e71f2fea7ea67db2f3ae3dabb864b1f6c8234b45a7e3529315c0b65075b31ed7fa9837528e1fda7072a841987437f3a7a0f006c1b5a3bdcf3a3b8c26b2e660
7
+ data.tar.gz: a5853a4a2d3b35422238b281a44951a90fbff0d92e245546df70c06e2a618e5c1941cbce7b9e5ca2209b4dd4d5f4be9efd37e22cc25c22c9f9fa54a15aee7bc5
data/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  ![ActiveCanvas Demo](docs/images/active-canvas-demo.gif)
4
4
 
5
- A mountable Rails engine that turns any Rails app into a full-featured CMS. Includes a visual drag-and-drop editor (GrapeJS), AI-powered content generation, Tailwind CSS compilation, media management, page versioning, and SEO controls -- all behind an admin interface that works out of the box.
5
+ A mountable Rails engine that turns any Rails app into a full-featured CMS. Includes a visual drag-and-drop editor (GrapesJS), AI-powered content generation, Tailwind CSS compilation, media management, page versioning, and SEO controls -- all behind an admin interface that works out of the box.
6
6
 
7
7
  ## Features
8
8
 
9
- - **Visual Editor** -- Drag-and-drop page builder powered by GrapeJS
9
+ - **Visual Editor** -- Drag-and-drop page builder powered by GrapesJS
10
10
  - **AI Content Generation** -- Text, images, and screenshot-to-code via OpenAI, Anthropic, or OpenRouter
11
11
  - **Tailwind CSS Compilation** -- Per-page compiled CSS for production (no CDN dependency)
12
12
  - **Media Library** -- Upload and manage images/files with Active Storage
@@ -56,7 +56,7 @@ Then visit `/canvas/admin` to start building pages.
56
56
 
57
57
  ## Visual Editor
58
58
 
59
- The GrapeJS editor provides:
59
+ The GrapesJS editor provides:
60
60
 
61
61
  - Drag-and-drop blocks (text, images, columns, forms, etc.)
62
62
  - Code editor panel for direct HTML/CSS editing
@@ -161,6 +161,45 @@ Upload and manage images directly from the admin or from within the editor's ass
161
161
  - Works with any Active Storage backend (local, S3, GCS, etc.)
162
162
  - Public or signed URL modes
163
163
 
164
+ ## Media & storage
165
+
166
+ ### Stable media references
167
+
168
+ Images inserted via the editor are stored as `data-ac-media-id` attribute references in page content rather than raw URLs. At render time, `ContentRenderer` resolves each reference to a fresh URL, so time-limited signed URLs are never persisted in the database. Existing pages gain this behaviour automatically after running the backfill migration:
169
+
170
+ ```bash
171
+ bin/rails active_canvas:install:migrations
172
+ bin/rails db:migrate
173
+ ```
174
+
175
+ Note: blobs that were already deleted from Active Storage cannot be recovered by the backfill.
176
+
177
+ ### public_uploads (#4)
178
+
179
+ Setting `config.public_uploads = true` only takes effect when the Active Storage service is **also** declared `public: true` in `config/storage.yml`. If the service is not public, ActiveCanvas logs a warning and falls back to signed URLs automatically.
180
+
181
+ For a public Disk service used outside a request context (e.g. background jobs), you must set:
182
+
183
+ ```ruby
184
+ Rails.application.routes.default_url_options[:host] = "https://yourapp.example.com"
185
+ ```
186
+
187
+ ### Public S3 buckets (#5)
188
+
189
+ For S3, make the bucket publicly readable via a **bucket policy**, not per-object ACLs. Modern S3 buckets have Object Ownership set to "Bucket owner enforced" and Block Public Access enabled, which means per-object `public-read` ACLs are silently ignored. A bucket policy that allows `s3:GetObject` for `"Principal": "*"` is the supported path.
190
+
191
+ Declare the service `public: true` in `config/storage.yml` and set `config.storage_service` to its name -- that combination is what ActiveCanvas checks before switching to public URLs.
192
+
193
+ ### SVG uploads (#6)
194
+
195
+ SVG uploads are disabled by default because SVGs can contain `<script>` tags and event-handler attributes (stored-XSS).
196
+
197
+ When you enable them with `config.allow_svg_uploads = true`, ActiveCanvas serves uploaded SVGs with `Content-Disposition: attachment` on the **signed-URL path**, which causes browsers to download the file rather than render it, neutralizing top-level execution.
198
+
199
+ **Known limitation:** this disposition is **not** applied when `public_uploads` is `true` and the storage service is public. In that configuration the SVG is served inline directly from the public bucket/origin, bypassing the disposition header. If you need SVG uploads with `public_uploads`, serve media uploads from a **separate origin or bucket** (a different domain from your application) so that any injected scripts cannot access your app's cookies or local storage.
200
+
201
+ Also note: the admin "Open Original" link will trigger a download (not an inline display) for SVG files because of the `attachment` disposition.
202
+
164
203
  ## Page Versioning
165
204
 
166
205
  Every content change creates a version automatically. View the version history from the page admin to see:
@@ -7,6 +7,20 @@
7
7
 
8
8
  window.ActiveCanvasEditor = window.ActiveCanvasEditor || {};
9
9
 
10
+ /**
11
+ * Warn when a src is a relative asset path the engine cannot resolve.
12
+ * Absolute URLs, root-relative paths, and data: URIs are fine.
13
+ */
14
+ function warnIfUnresolvableSrc(src) {
15
+ const { showToast } = window.ActiveCanvasEditor;
16
+ if (!src) return;
17
+ const ok = /^(https?:)?\/\//i.test(src) || src.startsWith('/') || src.startsWith('data:');
18
+ if (!ok) {
19
+ console.warn(`[ActiveCanvas] "${src}" is a relative path the engine will not serve at runtime. Use the media library instead.`);
20
+ if (showToast) showToast('Relative asset paths won\'t resolve on the published page', 'error');
21
+ }
22
+ }
23
+
10
24
  /**
11
25
  * Setup the custom asset manager modal
12
26
  * @param {Object} editor - GrapeJS editor instance
@@ -168,7 +182,7 @@
168
182
 
169
183
  function renderMediaGrid(media, container) {
170
184
  container.innerHTML = media.map(item => `
171
- <div class="ac-asset-item" data-src="${item.src}" data-name="${item.name || ''}">
185
+ <div class="ac-asset-item" data-src="${item.src}" data-name="${item.name || ''}" data-media-id="${item.id || ''}">
172
186
  <div class="ac-asset-thumb">
173
187
  <img src="${item.src}" alt="${item.name || 'Image'}" loading="lazy">
174
188
  </div>
@@ -179,7 +193,7 @@
179
193
  // Add click handlers
180
194
  container.querySelectorAll('.ac-asset-item').forEach(item => {
181
195
  item.addEventListener('click', () => {
182
- selectAsset(item.dataset.src, item.dataset.name);
196
+ selectAsset(item.dataset.src, item.dataset.name, item.dataset.mediaId);
183
197
  });
184
198
  });
185
199
  }
@@ -221,13 +235,16 @@
221
235
  });
222
236
  }
223
237
 
224
- function selectAsset(src, name) {
238
+ function selectAsset(src, name, mediaId) {
225
239
  // Add to GrapeJS asset manager
226
- editor.AssetManager.add({ src, name, type: 'image' });
240
+ editor.AssetManager.add({ src, name, type: 'image', mediaId });
227
241
 
228
- // If there's a target component, set the image
242
+ // If there's a target component, set the image + stable media ref
229
243
  if (currentTarget) {
230
244
  currentTarget.set('src', src);
245
+ if (mediaId) {
246
+ currentTarget.addAttributes({ 'data-ac-media-id': mediaId });
247
+ }
231
248
  }
232
249
 
233
250
  // Close modal
@@ -333,10 +350,10 @@
333
350
  }
334
351
 
335
352
  grid.innerHTML = assets.map(asset => `
336
- <div class="asset-item" draggable="true" data-src="${asset.src}" data-name="${asset.name || 'Image'}" title="${asset.name || 'Image'}">
353
+ <div class="asset-item" draggable="true" data-src="${asset.src}" data-name="${asset.name || 'Image'}" data-media-id="${asset.id || asset.mediaId || ''}" title="${asset.name || 'Image'}">
337
354
  <img src="${asset.src}" alt="${asset.name || 'Image'}" loading="lazy">
338
355
  <div class="asset-item-overlay">
339
- <button class="asset-insert-btn" data-src="${asset.src}" title="Insert image">
356
+ <button class="asset-insert-btn" data-src="${asset.src}" data-media-id="${asset.id || asset.mediaId || ''}" title="Insert image">
340
357
  <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
341
358
  <line x1="12" y1="5" x2="12" y2="19"/>
342
359
  <line x1="5" y1="12" x2="19" y2="12"/>
@@ -351,7 +368,9 @@
351
368
  // Drag to canvas
352
369
  item.addEventListener('dragstart', (e) => {
353
370
  const src = item.dataset.src;
354
- e.dataTransfer.setData('text/html', `<img src="${src}" alt="Image" style="max-width: 100%;">`);
371
+ const mediaId = item.dataset.mediaId;
372
+ const idAttr = mediaId ? ` data-ac-media-id="${mediaId}"` : '';
373
+ e.dataTransfer.setData('text/html', `<img src="${src}"${idAttr} alt="Image" style="max-width: 100%;">`);
355
374
  e.dataTransfer.effectAllowed = 'copy';
356
375
  });
357
376
 
@@ -360,15 +379,13 @@
360
379
  if (insertBtn) {
361
380
  insertBtn.addEventListener('click', (e) => {
362
381
  e.stopPropagation();
363
- const src = insertBtn.dataset.src;
364
- insertImageToCanvas(editor, src);
382
+ insertImageToCanvas(editor, insertBtn.dataset.src, insertBtn.dataset.mediaId);
365
383
  });
366
384
  }
367
385
 
368
386
  // Double-click to insert
369
387
  item.addEventListener('dblclick', () => {
370
- const src = item.dataset.src;
371
- insertImageToCanvas(editor, src);
388
+ insertImageToCanvas(editor, item.dataset.src, item.dataset.mediaId);
372
389
  });
373
390
  });
374
391
  }
@@ -376,14 +393,20 @@
376
393
  /**
377
394
  * Insert an image into the canvas
378
395
  */
379
- function insertImageToCanvas(editor, src) {
396
+ function insertImageToCanvas(editor, src, mediaId) {
380
397
  const { showToast } = window.ActiveCanvasEditor;
398
+ warnIfUnresolvableSrc(src);
381
399
  const selected = editor.getSelected();
382
400
  const wrapper = editor.getWrapper();
383
401
 
402
+ const attributes = { src: src, alt: 'Image' };
403
+ if (mediaId) {
404
+ attributes['data-ac-media-id'] = mediaId;
405
+ }
406
+
384
407
  const imageComponent = {
385
408
  type: 'image',
386
- attributes: { src: src, alt: 'Image' },
409
+ attributes: attributes,
387
410
  style: { 'max-width': '100%' }
388
411
  };
389
412
 
@@ -0,0 +1,9 @@
1
+ module ActiveCanvas
2
+ class Current < ActiveSupport::CurrentAttributes
3
+ attribute :settings_cache
4
+
5
+ def settings_cache
6
+ super || (self.settings_cache = {})
7
+ end
8
+ end
9
+ end
@@ -9,15 +9,23 @@ module ActiveCanvas
9
9
 
10
10
  serialize :metadata, coder: JSON
11
11
 
12
+ # Tracks whether the public_uploads-misconfiguration warning was already
13
+ # logged this process (avoids log spam).
14
+ @public_uploads_warned = false
15
+ class << self
16
+ attr_accessor :public_uploads_warned
17
+ end
18
+
12
19
  validates :filename, presence: true
13
20
  validates :file, presence: true, on: :create
14
21
 
15
22
  validate :acceptable_file, on: :create
16
23
 
17
- before_save :set_file_attributes, if: -> { file.attached? && file.blob.present? }
24
+ before_validation :set_file_attributes, on: :create,
25
+ if: -> { file.attached? && file.blob.present? }
18
26
  after_commit :make_blob_public, on: [:create, :update], if: :should_make_public?
19
27
 
20
- scope :images, -> { where(content_type: ActiveCanvas.config.allowed_content_types) }
28
+ scope :images, -> { where(content_type: ActiveCanvas.config.effective_allowed_content_types) }
21
29
  scope :recent, -> { order(created_at: :desc) }
22
30
 
23
31
  def metadata
@@ -27,22 +35,19 @@ module ActiveCanvas
27
35
  def url
28
36
  return nil unless file.attached?
29
37
 
30
- # If public_uploads is enabled and blob service supports public URLs
31
38
  if ActiveCanvas.config.public_uploads &&
32
39
  file.blob.service.respond_to?(:public?) &&
33
40
  file.blob.service.public?
41
+ # NOTE: public services serve the blob directly; the SVG attachment
42
+ # disposition (see #svg_disposition_option) is NOT applied here. For
43
+ # public SVG serving, use a separate origin/bucket. See README "Media & storage".
34
44
  file.url
35
45
  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
- )
46
+ warn_if_public_uploads_misconfigured
47
+ Rails.application.routes.url_helpers.rails_blob_url(file, **blob_url_options)
42
48
  end
43
49
  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)
50
+ Rails.application.routes.url_helpers.rails_blob_url(file, only_path: true, **svg_disposition_option)
46
51
  end
47
52
 
48
53
  def public_url
@@ -112,6 +117,28 @@ module ActiveCanvas
112
117
  end
113
118
  end
114
119
 
120
+ def warn_if_public_uploads_misconfigured
121
+ return unless ActiveCanvas.config.public_uploads
122
+ return if self.class.public_uploads_warned
123
+
124
+ self.class.public_uploads_warned = true
125
+ Rails.logger.warn(
126
+ "[ActiveCanvas] config.public_uploads is true but the storage service " \
127
+ "(#{file.blob.service.name}) is not public. Falling back to signed URLs. " \
128
+ "Declare a `public: true` service (and set config.storage_service) to serve public URLs."
129
+ )
130
+ end
131
+
132
+ def blob_url_options
133
+ { expires_in: 1.hour, only_path: true }.merge(svg_disposition_option)
134
+ end
135
+
136
+ def svg_disposition_option
137
+ return {} unless file.attached? && file.blob.present?
138
+
139
+ file.blob.content_type == "image/svg+xml" ? { disposition: "attachment" } : {}
140
+ end
141
+
115
142
  def should_make_public?
116
143
  file.attached? && ActiveCanvas.config.public_uploads
117
144
  end
@@ -22,7 +22,7 @@ module ActiveCanvas
22
22
  end
23
23
 
24
24
  def rendered_content
25
- content.to_s.html_safe
25
+ ActiveCanvas::ContentRenderer.resolve(content).to_s.html_safe
26
26
  end
27
27
 
28
28
  def current_version_number
@@ -29,29 +29,45 @@ module ActiveCanvas
29
29
 
30
30
  class << self
31
31
  def get(key)
32
- setting = find_by(key: key)
33
- return nil unless setting
32
+ key = key.to_s
33
+ cache = Current.settings_cache
34
+ return cache[key] if cache.key?(key)
34
35
 
35
- if ENCRYPTED_KEYS.include?(key.to_s) && setting.encrypted_value.present?
36
+ setting = find_by(key: key)
37
+ value = if setting.nil?
38
+ nil
39
+ elsif ENCRYPTED_KEYS.include?(key) && setting.encrypted_value.present?
36
40
  decrypt_value(setting.encrypted_value)
37
41
  else
38
42
  setting.value
39
43
  end
44
+
45
+ cache[key] = value
40
46
  end
41
47
 
42
48
  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)
49
+ retried = false
50
+ begin
51
+ setting = find_or_initialize_by(key: key)
52
+
53
+ if ENCRYPTED_KEYS.include?(key.to_s) && value.present?
54
+ setting.encrypted_value = encrypt_value(value)
55
+ setting.value = nil # Clear plain text value
56
+ else
57
+ setting.value = value
58
+ setting.encrypted_value = nil if ENCRYPTED_KEYS.include?(key.to_s)
59
+ end
60
+
61
+ setting.save!
62
+ Current.settings_cache[key.to_s] = value
63
+ value
64
+ rescue ActiveRecord::RecordNotUnique
65
+ # Another process inserted the same key between find_or_initialize_by
66
+ # and save!. Reload and retry once; the second pass will UPDATE.
67
+ raise if retried
68
+ retried = true
69
+ retry
51
70
  end
52
-
53
- setting.save!
54
- value
55
71
  end
56
72
 
57
73
  # Check if an API key is configured (without revealing the value)
@@ -0,0 +1,27 @@
1
+ module ActiveCanvas
2
+ # Resolves persisted media references (<img data-ac-media-id="N">) to fresh
3
+ # URLs at render time. We persist a stable id, never a time-limited URL, so
4
+ # saved content never rots.
5
+ class ContentRenderer
6
+ def self.resolve(html)
7
+ return html if html.blank?
8
+
9
+ fragment = Nokogiri::HTML5.fragment(html)
10
+ nodes = fragment.css("img[data-ac-media-id]")
11
+ return html if nodes.empty?
12
+
13
+ ids = nodes.map { |n| n["data-ac-media-id"] }.uniq
14
+ media_by_id = Media.where(id: ids)
15
+ .includes(file_attachment: :blob)
16
+ .index_by { |m| m.id.to_s }
17
+
18
+ nodes.each do |node|
19
+ media = media_by_id[node["data-ac-media-id"].to_s]
20
+ fresh_url = media&.url
21
+ node["src"] = fresh_url if fresh_url # unknown id or orphaned media: leave the node as-is
22
+ end
23
+
24
+ fragment.to_html
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,77 @@
1
+ require "base64"
2
+ require "json"
3
+
4
+ module ActiveCanvas
5
+ # One-time, best-effort backfill: rewrites legacy <img> tags whose src is an
6
+ # Active Storage blob redirect URL into stable <img data-ac-media-id="N"> refs
7
+ # that ContentRenderer can resolve at render time. Idempotent; logs unmatched.
8
+ #
9
+ # Recovers the blob id by decoding the signed_id payload directly, bypassing
10
+ # the expiry check (this is our own data) so already-rotted URLs are healed.
11
+ class MediaRefBackfill
12
+ BLOB_URL_RE = %r{active_storage/blobs/(?:redirect|proxy)/([^/]+)/[^/]+\z}
13
+
14
+ def self.run(logger: Rails.logger)
15
+ new(logger).run
16
+ end
17
+
18
+ def initialize(logger)
19
+ @logger = logger
20
+ end
21
+
22
+ def run
23
+ ActiveCanvas::Page.find_each do |page|
24
+ content = page.content
25
+ next if content.blank?
26
+
27
+ fragment = Nokogiri::HTML5.fragment(content)
28
+ changed = false
29
+
30
+ fragment.css("img").each do |img|
31
+ next if img["data-ac-media-id"].present?
32
+
33
+ src = img["src"].to_s
34
+ next unless src.match?(BLOB_URL_RE)
35
+
36
+ media_id = media_id_for(src)
37
+ if media_id
38
+ img["data-ac-media-id"] = media_id.to_s
39
+ changed = true
40
+ else
41
+ @logger.warn("[ActiveCanvas] backfill: could not match media for #{src} on page #{page.id}")
42
+ end
43
+ end
44
+
45
+ # update_columns: skip sanitize + version callbacks; we only add a data-* attr.
46
+ page.update_columns(content: fragment.to_html) if changed
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def media_id_for(src)
53
+ match = src.match(BLOB_URL_RE)
54
+ return nil unless match
55
+
56
+ blob_id = blob_id_from_signed_id(match[1])
57
+ return nil unless blob_id
58
+
59
+ ActiveStorage::Attachment
60
+ .where(record_type: "ActiveCanvas::Media", name: "file", blob_id: blob_id)
61
+ .limit(1)
62
+ .pick(:record_id)
63
+ end
64
+
65
+ # Decodes "<base64 payload>--<hmac>" into the blob id. The payload base64
66
+ # decodes to {"_rails":{"data":<blob_id>,"exp":...,"pur":"blob_id"}}.
67
+ def blob_id_from_signed_id(signed_id)
68
+ payload = signed_id.to_s.split("--").first
69
+ return nil if payload.blank?
70
+
71
+ data = JSON.parse(Base64.decode64(payload))
72
+ data.dig("_rails", "data")
73
+ rescue StandardError
74
+ nil
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,9 @@
1
+ class BackfillActiveCanvasMediaRefs < ActiveRecord::Migration[8.1]
2
+ def up
3
+ ActiveCanvas::MediaRefBackfill.run
4
+ end
5
+
6
+ def down
7
+ # No-op: data-ac-media-id attributes are harmless to leave in place.
8
+ end
9
+ end
@@ -40,13 +40,25 @@ module ActiveCanvas
40
40
  # Allowed MIME types for uploads
41
41
  attr_accessor :allowed_content_types
42
42
 
43
- # Allow SVG uploads (disabled by default due to XSS risks)
43
+ # Allow SVG uploads (disabled by default due to XSS risks).
44
+ # SECURITY: SVGs can contain <script>/onload. ActiveCanvas serves uploaded
45
+ # SVGs with Content-Disposition: attachment on the signed-URL path to prevent
46
+ # top-level execution. NOTE: this disposition is NOT applied when
47
+ # public_uploads is true and the storage service is public (the file is served
48
+ # inline from the public bucket/origin). If you enable SVG uploads, prefer
49
+ # serving uploads from a SEPARATE origin/bucket, especially with public_uploads.
44
50
  attr_accessor :allow_svg_uploads
45
51
 
46
52
  # Active Storage service name (nil = default service)
47
53
  attr_accessor :storage_service
48
54
 
49
- # Make uploads publicly accessible (false = use signed URLs)
55
+ # Make uploads publicly accessible (false = signed, expiring URLs).
56
+ # NOTE: this only takes effect when the Active Storage service is ALSO
57
+ # declared `public: true` in config/storage.yml. For S3, make the bucket
58
+ # public via a BUCKET POLICY (per-object ACLs are ignored on modern buckets
59
+ # with Object Ownership = Bucket owner enforced / Block Public Access).
60
+ # For a public Disk service outside a request, set
61
+ # Rails.application.routes.default_url_options[:host].
50
62
  attr_accessor :public_uploads
51
63
 
52
64
  # ==> Editor Settings
@@ -180,7 +192,7 @@ module ActiveCanvas
180
192
  def effective_allowed_content_types
181
193
  types = allowed_content_types.dup
182
194
  types << "image/svg+xml" if allow_svg_uploads
183
- types - DANGEROUS_CONTENT_TYPES
195
+ (types - DANGEROUS_CONTENT_TYPES).uniq
184
196
  end
185
197
 
186
198
  # Check if authentication is properly configured for production
@@ -1,3 +1,3 @@
1
1
  module ActiveCanvas
2
- VERSION = "0.0.2"
2
+ VERSION = "0.0.3"
3
3
  end
@@ -5,6 +5,11 @@ module ActiveCanvas
5
5
 
6
6
  desc "Interactive setup wizard for ActiveCanvas"
7
7
 
8
+ class_option :defaults, type: :boolean, default: false,
9
+ desc: "Run non-interactively using default answers (CI-safe)"
10
+ class_option :skip_route, type: :boolean, default: false,
11
+ desc: "Do not mount ActiveCanvas::Engine in config/routes.rb"
12
+
8
13
  def welcome
9
14
  say ""
10
15
  say "=" * 60, :cyan
@@ -45,8 +50,7 @@ module ActiveCanvas
45
50
  say " 3. none - No framework"
46
51
  say ""
47
52
 
48
- framework = ask("Enter choice [tailwind]:")
49
- framework = "tailwind" if framework.blank?
53
+ framework = ask_value("Enter choice [tailwind]:", default: "tailwind")
50
54
  framework = framework.downcase.strip
51
55
 
52
56
  @css_framework = case framework
@@ -94,8 +98,7 @@ module ActiveCanvas
94
98
  say "Step 4: Configuration", :yellow
95
99
  say "-" * 40
96
100
 
97
- @mount_path = ask("Mount path [/canvas]:")
98
- @mount_path = "/canvas" if @mount_path.blank?
101
+ @mount_path = ask_value("Mount path [/canvas]:", default: "/canvas")
99
102
  @mount_path = "/#{@mount_path}" unless @mount_path.start_with?("/")
100
103
 
101
104
  template "initializer.rb", "config/initializers/active_canvas.rb"
@@ -104,6 +107,9 @@ module ActiveCanvas
104
107
  end
105
108
 
106
109
  def mount_engine
110
+ @mount_path ||= "/canvas"
111
+ return if options[:skip_route]
112
+
107
113
  say "Step 5: Routes", :yellow
108
114
  say "-" * 40
109
115
 
@@ -162,11 +168,24 @@ module ActiveCanvas
162
168
 
163
169
  private
164
170
 
171
+ def interactive?
172
+ !options[:defaults]
173
+ end
174
+
175
+ # Non-interactive: return the default instead of reading stdin.
176
+ def ask_value(question, default:)
177
+ return default unless interactive?
178
+ answer = ask(question)
179
+ answer.blank? ? default : answer
180
+ end
181
+
165
182
  # Helper for yes/no prompts with clear defaults
166
183
  # @param question [String] The question to ask
167
184
  # @param default [Boolean] The default answer (true = Y, false = N)
168
185
  # @return [Boolean]
169
186
  def yes_no?(question, default: true)
187
+ return default unless interactive?
188
+
170
189
  indicator = default ? "[Y/n]" : "[y/N]"
171
190
  answer = ask("#{question} #{indicator}")
172
191
 
@@ -176,7 +195,8 @@ module ActiveCanvas
176
195
  end
177
196
 
178
197
  def setup_tailwind
179
- gemfile_content = File.read(Rails.root.join("Gemfile"))
198
+ gemfile_path = Rails.root.join("Gemfile")
199
+ gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : ""
180
200
 
181
201
  if gemfile_content.include?("tailwindcss-rails")
182
202
  say "✓ tailwindcss-rails gem already installed", :green
@@ -202,6 +222,12 @@ module ActiveCanvas
202
222
  end
203
223
 
204
224
  def configure_ai_keys
225
+ unless interactive?
226
+ @openai_key = @anthropic_key = @openrouter_key = nil
227
+ say "→ Non-interactive: skipping AI key entry. Add keys later in Admin > Settings > AI", :yellow
228
+ return
229
+ end
230
+
205
231
  say ""
206
232
  @openai_key = ask("OpenAI API key (leave blank to skip):")
207
233
  @anthropic_key = ask("Anthropic API key (leave blank to skip):")
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_canvas
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giovanni Panasiti
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-04-30 00:00:00.000000000 Z
10
+ date: 2026-06-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -77,6 +77,7 @@ files:
77
77
  - app/mailers/active_canvas/application_mailer.rb
78
78
  - app/models/active_canvas/ai_model.rb
79
79
  - app/models/active_canvas/application_record.rb
80
+ - app/models/active_canvas/current.rb
80
81
  - app/models/active_canvas/media.rb
81
82
  - app/models/active_canvas/page.rb
82
83
  - app/models/active_canvas/page_type.rb
@@ -86,7 +87,9 @@ files:
86
87
  - app/services/active_canvas/ai_configuration.rb
87
88
  - app/services/active_canvas/ai_models.rb
88
89
  - app/services/active_canvas/ai_service.rb
90
+ - app/services/active_canvas/content_renderer.rb
89
91
  - app/services/active_canvas/content_sanitizer.rb
92
+ - app/services/active_canvas/media_ref_backfill.rb
90
93
  - app/services/active_canvas/tailwind_compiler.rb
91
94
  - app/views/active_canvas/admin/media/index.html.erb
92
95
  - app/views/active_canvas/admin/media/show.html.erb
@@ -116,6 +119,7 @@ files:
116
119
  - config/routes.rb
117
120
  - db/migrate/20260202000001_create_active_canvas_tables.rb
118
121
  - db/migrate/20260202000002_create_active_canvas_ai_models.rb
122
+ - db/migrate/20260613000002_backfill_active_canvas_media_refs.rb
119
123
  - lib/active_canvas.rb
120
124
  - lib/active_canvas/configuration.rb
121
125
  - lib/active_canvas/engine.rb