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 +4 -4
- data/README.md +42 -3
- data/app/assets/javascripts/active_canvas/editor/asset_manager.js +37 -14
- data/app/models/active_canvas/current.rb +9 -0
- data/app/models/active_canvas/media.rb +38 -11
- data/app/models/active_canvas/page.rb +1 -1
- data/app/models/active_canvas/setting.rb +30 -14
- data/app/services/active_canvas/content_renderer.rb +27 -0
- data/app/services/active_canvas/media_ref_backfill.rb +77 -0
- data/db/migrate/20260613000002_backfill_active_canvas_media_refs.rb +9 -0
- data/lib/active_canvas/configuration.rb +15 -3
- data/lib/active_canvas/version.rb +1 -1
- data/lib/generators/active_canvas/install/install_generator.rb +31 -5
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6178be7aae3ee9f6da228b4d3aa8e0927c4faf622a7215b313616f4fda4d87d
|
|
4
|
+
data.tar.gz: 64b25a9e874d070f4289013c8d66cc1a3896fbf35bab1189f80ab84e01790593
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 69e71f2fea7ea67db2f3ae3dabb864b1f6c8234b45a7e3529315c0b65075b31ed7fa9837528e1fda7072a841987437f3a7a0f006c1b5a3bdcf3a3b8c26b2e660
|
|
7
|
+
data.tar.gz: a5853a4a2d3b35422238b281a44951a90fbff0d92e245546df70c06e2a618e5c1941cbce7b9e5ca2209b4dd4d5f4be9efd37e22cc25c22c9f9fa54a15aee7bc5
|
data/README.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
A mountable Rails engine that turns any Rails app into a full-featured CMS. Includes a visual drag-and-drop editor (
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
409
|
+
attributes: attributes,
|
|
387
410
|
style: { 'max-width': '100%' }
|
|
388
411
|
};
|
|
389
412
|
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -29,29 +29,45 @@ module ActiveCanvas
|
|
|
29
29
|
|
|
30
30
|
class << self
|
|
31
31
|
def get(key)
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
key = key.to_s
|
|
33
|
+
cache = Current.settings_cache
|
|
34
|
+
return cache[key] if cache.key?(key)
|
|
34
35
|
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
@@ -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 =
|
|
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
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|