lato_cms 3.3.4 → 3.4.0
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 +8 -0
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_form_controller.js +2 -1
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_component_toggle_controller.js +2 -1
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_field_controller.js +23 -7
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_media_picker_controller.js +4 -2
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_text_field_controller.js +47 -2
- data/app/assets/javascripts/lato_cms/controllers/lato_cms_upload_controller.js +4 -1
- data/app/models/lato_cms/page.rb +2 -2
- data/app/models/lato_cms/page_field.rb +10 -0
- data/app/views/lato_cms/pages/fields/_text.html.erb +5 -3
- data/lib/lato_cms/version.rb +1 -1
- metadata +13 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 063d286f745ed3fb368105241f2b9cc7f753e153620c63bb9020fea5946a7fad
|
|
4
|
+
data.tar.gz: 8e29ca9e75f3a2e1644facb4e534c21f0fe821c7ddcd72d2bb9bc4a0eb4a8921
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a94293a1a09313278619701879933a56c2312ac39984a529e5c1b057f3d1bbb3ba0e24e62b23bd4cdd71366c0c25ef305d98c95da57b416b5033fe120773ae7d
|
|
7
|
+
data.tar.gz: 822724a30d867951066e212a052535899e0f88b9b073667c0b0b8def5f996c86bfccd9c10f28ee6005b1f6747fbd68f713b3bdcc36d2950e4e6ce81563e7f1cb
|
data/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Manage application content on Lato projects.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
|
+
|
|
7
|
+
Requires Rails 8.1.3.1 or newer in the 8.1 series and Lato 3.25.0 or newer. Existing applications must install the new Lato session migration before deployment; previous sessions are invalidated.
|
|
6
8
|
Add required dependencies to your application's Gemfile:
|
|
7
9
|
|
|
8
10
|
```ruby
|
|
@@ -43,6 +45,12 @@ import "lato_cms/application";
|
|
|
43
45
|
// ....
|
|
44
46
|
```
|
|
45
47
|
|
|
48
|
+
## Content security
|
|
49
|
+
|
|
50
|
+
Rich text fields preserve common formatting, links, and images. HTML is sanitized on save and when rendered or returned through `parsed_value` and JSON, including existing content and AI translations. Scripts, event handlers, unsafe URL protocols, and unsupported elements or attributes are removed. Applications should consume `parsed_value` rather than render the raw database `value` as HTML.
|
|
51
|
+
|
|
52
|
+
The read-only JSON API under `/api/pages` is public and requires no login. Its `group_id` parameter selects content; it is not an authorization token. Pages, translations, fields, and attachment URLs returned by this API must be suitable for public access. Applications with private content must restrict access to these endpoints in the host application or reverse proxy.
|
|
53
|
+
|
|
46
54
|
## Media URLs
|
|
47
55
|
|
|
48
56
|
By default a media URL is an Active Storage **redirect** URL: Rails answers 302 with a
|
|
@@ -89,8 +89,9 @@ export default class extends Controller {
|
|
|
89
89
|
|
|
90
90
|
this.statusTarget.innerHTML = `
|
|
91
91
|
<span class="d-inline-flex align-items-center gap-1 small ${color} lato-cms-component-form__status">
|
|
92
|
-
<i class="bi ${icon}"></i
|
|
92
|
+
<i class="bi ${icon}"></i>
|
|
93
93
|
</span>`
|
|
94
|
+
this.statusTarget.firstElementChild.append(document.createTextNode(message))
|
|
94
95
|
|
|
95
96
|
if (isSuccess) {
|
|
96
97
|
this._statusTimeout = setTimeout(() => {
|
|
@@ -77,7 +77,8 @@ export default class extends Controller {
|
|
|
77
77
|
if (!this.hasStatusTarget) return
|
|
78
78
|
|
|
79
79
|
const colorClass = type === 'success' ? 'text-success' : (type === 'danger' ? 'text-danger' : 'text-muted')
|
|
80
|
-
this.statusTarget.innerHTML = `<span class="small ${colorClass}"
|
|
80
|
+
this.statusTarget.innerHTML = `<span class="small ${colorClass}"></span>`
|
|
81
|
+
this.statusTarget.firstElementChild.textContent = message
|
|
81
82
|
|
|
82
83
|
if (type === 'success') {
|
|
83
84
|
clearTimeout(this._statusTimeout)
|
|
@@ -110,30 +110,46 @@ export default class extends Controller {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
wrapper.innerHTML = `
|
|
113
|
-
${this.previewMarkup(item)}
|
|
114
113
|
<div class="lato-cms-media-field__overlay">
|
|
115
|
-
<span class="lato-cms-media-field__name"
|
|
114
|
+
<span class="lato-cms-media-field__name"></span>
|
|
116
115
|
</div>
|
|
117
116
|
<button type="button" class="lato-cms-media-field__remove" data-action="lato-cms-media-field#remove">
|
|
118
117
|
<i class="bi bi-x-lg"></i>
|
|
119
118
|
</button>
|
|
120
|
-
<input type="hidden"
|
|
119
|
+
<input type="hidden">`
|
|
120
|
+
wrapper.prepend(this.previewElement(item))
|
|
121
|
+
wrapper.querySelector('.lato-cms-media-field__name').textContent = item.name
|
|
122
|
+
const input = wrapper.querySelector('input')
|
|
123
|
+
input.name = this.hiddenNameValue
|
|
124
|
+
input.value = item.id
|
|
121
125
|
|
|
122
126
|
this.gridTarget.insertBefore(wrapper, this.addTileTarget)
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
|
|
129
|
+
previewElement (item) {
|
|
126
130
|
// Mirrors the server-rendered tile: with a poster nothing but that image is
|
|
127
131
|
// fetched until the admin hits play (a freshly uploaded video has none yet,
|
|
128
132
|
// its poster job runs in the background).
|
|
129
133
|
if (this.kindValue === 'video') {
|
|
130
134
|
const poster = item.posterUrl || ''
|
|
131
|
-
|
|
135
|
+
const video = document.createElement('video')
|
|
136
|
+
video.controls = true
|
|
137
|
+
video.preload = poster ? 'none' : 'metadata'
|
|
138
|
+
video.poster = poster
|
|
139
|
+
video.className = 'lato-cms-media-field__player'
|
|
140
|
+
video.src = item.url
|
|
141
|
+
return video
|
|
132
142
|
}
|
|
133
143
|
if (item.thumbnailUrl || item.url) {
|
|
134
|
-
|
|
144
|
+
const image = document.createElement('img')
|
|
145
|
+
image.src = item.thumbnailUrl || item.url
|
|
146
|
+
image.className = 'lato-cms-media-field__thumb'
|
|
147
|
+
return image
|
|
135
148
|
}
|
|
136
|
-
|
|
149
|
+
const icon = document.createElement('div')
|
|
150
|
+
icon.className = 'lato-cms-media-field__icon'
|
|
151
|
+
icon.innerHTML = '<i class="bi bi-paperclip"></i>'
|
|
152
|
+
return icon
|
|
137
153
|
}
|
|
138
154
|
|
|
139
155
|
itemTiles () {
|
|
@@ -63,11 +63,13 @@ export default class extends Controller {
|
|
|
63
63
|
|
|
64
64
|
this.pending.set(item.id, item)
|
|
65
65
|
this.updateSelectionUi()
|
|
66
|
-
this.uploadNoticeTarget.innerHTML =
|
|
66
|
+
this.uploadNoticeTarget.innerHTML = '<div class="alert alert-success"></div>'
|
|
67
|
+
this.uploadNoticeTarget.firstElementChild.textContent = item.name
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
uploadFailed (event) {
|
|
70
|
-
this.uploadNoticeTarget.innerHTML =
|
|
71
|
+
this.uploadNoticeTarget.innerHTML = '<div class="alert alert-danger"></div>'
|
|
72
|
+
this.uploadNoticeTarget.firstElementChild.textContent = event.detail.message
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
buildItemFromElement (element) {
|
|
@@ -2,12 +2,21 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
2
2
|
|
|
3
3
|
export default class extends Controller {
|
|
4
4
|
static targets = ["editor", "source", "input", "toolbar", "sourceBtn"]
|
|
5
|
+
static values = { tags: String, attributes: String }
|
|
5
6
|
|
|
6
7
|
#sourceMode = false
|
|
7
8
|
|
|
8
9
|
connect() {
|
|
9
10
|
this.editorTarget.addEventListener("input", () => this.sync())
|
|
10
11
|
this.sourceTarget.addEventListener("input", () => this.syncFromSource())
|
|
12
|
+
this.editorTarget.addEventListener("paste", event => {
|
|
13
|
+
event.preventDefault()
|
|
14
|
+
const html = event.clipboardData.getData("text/html")
|
|
15
|
+
document.execCommand(html ? "insertHTML" : "insertText", false,
|
|
16
|
+
html ? this.sanitize(html) : event.clipboardData.getData("text/plain"))
|
|
17
|
+
this.sync()
|
|
18
|
+
})
|
|
19
|
+
this.editorTarget.addEventListener("drop", event => event.preventDefault())
|
|
11
20
|
|
|
12
21
|
// Prevent toolbar buttons from stealing focus (preserves text selection)
|
|
13
22
|
this.toolbarTarget.querySelectorAll("button").forEach(btn => {
|
|
@@ -24,11 +33,47 @@ export default class extends Controller {
|
|
|
24
33
|
// ── Sync helpers ────────────────────────────────────────────────────────────
|
|
25
34
|
|
|
26
35
|
sync() {
|
|
36
|
+
if (this.#sourceMode) return this.syncFromSource()
|
|
27
37
|
this.inputTarget.value = this.editorTarget.innerHTML
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
syncFromSource() {
|
|
31
|
-
this.inputTarget.value = this.sourceTarget.value
|
|
41
|
+
this.inputTarget.value = this.sanitize(this.sourceTarget.value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sanitize(html) {
|
|
45
|
+
// Template contents stay inert while source HTML is reduced to the server's allowlist.
|
|
46
|
+
const template = document.createElement("template")
|
|
47
|
+
template.innerHTML = html
|
|
48
|
+
const tags = new Set(this.tagsValue.split(" "))
|
|
49
|
+
const attributes = new Set(this.attributesValue.split(" "))
|
|
50
|
+
template.content.querySelectorAll("*").forEach(element => {
|
|
51
|
+
if (!tags.has(element.localName)) {
|
|
52
|
+
if (["script", "style", "iframe", "object", "embed", "svg", "math", "template"].includes(element.localName)) {
|
|
53
|
+
element.remove()
|
|
54
|
+
} else {
|
|
55
|
+
element.replaceWith(...element.childNodes)
|
|
56
|
+
}
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
Array.from(element.attributes).forEach(attribute => {
|
|
60
|
+
if (!attributes.has(attribute.name)) {
|
|
61
|
+
element.removeAttribute(attribute.name)
|
|
62
|
+
} else if (["href", "src"].includes(attribute.name)) {
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(attribute.value, document.baseURI)
|
|
65
|
+
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) element.removeAttribute(attribute.name)
|
|
66
|
+
} catch {
|
|
67
|
+
element.removeAttribute(attribute.name)
|
|
68
|
+
}
|
|
69
|
+
} else if (attribute.name === "style") {
|
|
70
|
+
const alignment = element.style.textAlign
|
|
71
|
+
element.removeAttribute("style")
|
|
72
|
+
if (["left", "center", "right", "justify"].includes(alignment)) element.style.textAlign = alignment
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
return template.innerHTML
|
|
32
77
|
}
|
|
33
78
|
|
|
34
79
|
// ── Toggle source view ──────────────────────────────────────────────────────
|
|
@@ -45,7 +90,7 @@ export default class extends Controller {
|
|
|
45
90
|
this.sourceBtnTarget.classList.replace("btn-outline-secondary", "btn-secondary")
|
|
46
91
|
} else {
|
|
47
92
|
// Apply edited source back to the editor
|
|
48
|
-
this.editorTarget.innerHTML = this.sourceTarget.value
|
|
93
|
+
this.editorTarget.innerHTML = this.sanitize(this.sourceTarget.value)
|
|
49
94
|
this.sync()
|
|
50
95
|
this.sourceTarget.classList.add("d-none")
|
|
51
96
|
this.editorTarget.classList.remove("d-none")
|
|
@@ -68,7 +68,10 @@ export default class extends Controller {
|
|
|
68
68
|
this.reset()
|
|
69
69
|
const message = data ? Object.values(data).flat().join(', ') : ''
|
|
70
70
|
this.element.dispatchEvent(new CustomEvent('lato-cms:upload-error', { bubbles: true, detail: { message } }))
|
|
71
|
-
if (this.hasNoticeTarget)
|
|
71
|
+
if (this.hasNoticeTarget) {
|
|
72
|
+
this.noticeTarget.innerHTML = '<div class="alert alert-danger"></div>'
|
|
73
|
+
this.noticeTarget.firstElementChild.textContent = message
|
|
74
|
+
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
reset () {
|
data/app/models/lato_cms/page.rb
CHANGED
|
@@ -97,13 +97,13 @@ module LatoCms
|
|
|
97
97
|
translated = self.class.parse_translated_field_values(content, targets.size)
|
|
98
98
|
raise "The LLM response couldn't be parsed into #{targets.size} translated value(s)" if translated.blank?
|
|
99
99
|
|
|
100
|
-
targets.each_with_index { |field, index| field.
|
|
100
|
+
targets.each_with_index { |field, index| field.update!(value: translated[index]) if translated[index].present? }
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def self.translate_fields_prompt(fields, target_locale)
|
|
104
104
|
"Translate each string in this JSON array into #{target_locale} (language code). Preserve any HTML " \
|
|
105
105
|
"tags exactly as-is, translate only the visible text. Respond with a JSON array of the same length " \
|
|
106
|
-
"and in the same order, translated strings only, no markdown, no explanation.\n\n#{fields.map(&:
|
|
106
|
+
"and in the same order, translated strings only, no markdown, no explanation.\n\n#{fields.map(&:parsed_value).to_json}"
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def self.parse_translated_field_values(content, expected_count)
|
|
@@ -6,6 +6,8 @@ module LatoCms
|
|
|
6
6
|
has_many :media, through: :page_field_media, class_name: 'LatoCms::Media'
|
|
7
7
|
|
|
8
8
|
ATTACHMENT_FIELD_TYPES = %w[file image video gallery].freeze
|
|
9
|
+
RICH_TEXT_TAGS = %w[a b blockquote br code div em h1 h2 h3 h4 h5 h6 hr i img li ol p pre s span strike strong u ul].freeze
|
|
10
|
+
RICH_TEXT_ATTRIBUTES = %w[href title style src alt width height].freeze
|
|
9
11
|
|
|
10
12
|
# Field types that hold free-form text, used to decide what a
|
|
11
13
|
# "clone & translate" (see PagesController#clone_component_fields) sends
|
|
@@ -29,6 +31,8 @@ module LatoCms
|
|
|
29
31
|
return nil if value.blank?
|
|
30
32
|
|
|
31
33
|
case field_type
|
|
34
|
+
when 'text'
|
|
35
|
+
self.class.sanitize_rich_text(value)
|
|
32
36
|
when 'number'
|
|
33
37
|
value.include?('.') ? value.to_f : value.to_i
|
|
34
38
|
when 'boolean'
|
|
@@ -46,6 +50,10 @@ module LatoCms
|
|
|
46
50
|
end
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
def self.sanitize_rich_text(value)
|
|
54
|
+
String.new(ActionController::Base.helpers.sanitize(value, tags: RICH_TEXT_TAGS, attributes: RICH_TEXT_ATTRIBUTES).to_s)
|
|
55
|
+
end
|
|
56
|
+
|
|
49
57
|
def field_config
|
|
50
58
|
return nil if repeater_order?
|
|
51
59
|
|
|
@@ -173,6 +181,8 @@ module LatoCms
|
|
|
173
181
|
return if value.blank?
|
|
174
182
|
|
|
175
183
|
case field_type
|
|
184
|
+
when 'text'
|
|
185
|
+
self.value = self.class.sanitize_rich_text(value)
|
|
176
186
|
when 'json'
|
|
177
187
|
begin
|
|
178
188
|
parsed = JSON.parse(value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<% label = field_config['name'] || field_id.humanize %>
|
|
2
2
|
<% required = field_config['required'] == true %>
|
|
3
|
-
<% current_value = page_field&.value || '' %>
|
|
3
|
+
<% current_value = LatoCms::PageField.sanitize_rich_text(page_field&.value || '') %>
|
|
4
4
|
<% input_name = lato_cms_field_input_name(field_id, input_name_prefix: local_assigns[:input_name_prefix]) %>
|
|
5
5
|
<% input_id = lato_cms_field_dom_id(field_id, dom_id_prefix: local_assigns[:dom_id_prefix]) %>
|
|
6
6
|
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
<%= label %><%= ' *' if required %>
|
|
9
9
|
</label>
|
|
10
10
|
|
|
11
|
-
<div data-controller="lato-cms-text-field"
|
|
11
|
+
<div data-controller="lato-cms-text-field"
|
|
12
|
+
data-lato-cms-text-field-tags-value="<%= LatoCms::PageField::RICH_TEXT_TAGS.join(' ') %>"
|
|
13
|
+
data-lato-cms-text-field-attributes-value="<%= LatoCms::PageField::RICH_TEXT_ATTRIBUTES.join(' ') %>">
|
|
12
14
|
|
|
13
15
|
<%# ── Toolbar ──────────────────────────────────────────────────────────── %>
|
|
14
16
|
<div class="lato-cms-wysiwyg-toolbar border border-bottom-0 rounded-top bg-light px-2 py-1 d-flex flex-wrap gap-1 align-items-center"
|
|
@@ -78,5 +80,5 @@
|
|
|
78
80
|
name="<%= input_name %>"
|
|
79
81
|
id="<%= input_id %>"
|
|
80
82
|
data-lato-cms-text-field-target="input"
|
|
81
|
-
value="<%=
|
|
83
|
+
value="<%= current_value %>">
|
|
82
84
|
</div>
|
data/lib/lato_cms/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lato_cms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gregorio Galante
|
|
@@ -15,42 +15,48 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: 8.1.3.1
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '8.2'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
26
|
- - ">="
|
|
24
27
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
28
|
+
version: 8.1.3.1
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '8.2'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: lato
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
29
35
|
requirements:
|
|
30
36
|
- - ">="
|
|
31
37
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
38
|
+
version: 3.25.0
|
|
33
39
|
type: :runtime
|
|
34
40
|
prerelease: false
|
|
35
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
42
|
requirements:
|
|
37
43
|
- - ">="
|
|
38
44
|
- !ruby/object:Gem::Version
|
|
39
|
-
version:
|
|
45
|
+
version: 3.25.0
|
|
40
46
|
- !ruby/object:Gem::Dependency
|
|
41
47
|
name: lato_spaces
|
|
42
48
|
requirement: !ruby/object:Gem::Requirement
|
|
43
49
|
requirements:
|
|
44
50
|
- - ">="
|
|
45
51
|
- !ruby/object:Gem::Version
|
|
46
|
-
version:
|
|
52
|
+
version: 3.2.0
|
|
47
53
|
type: :runtime
|
|
48
54
|
prerelease: false
|
|
49
55
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
56
|
requirements:
|
|
51
57
|
- - ">="
|
|
52
58
|
- !ruby/object:Gem::Version
|
|
53
|
-
version:
|
|
59
|
+
version: 3.2.0
|
|
54
60
|
- !ruby/object:Gem::Dependency
|
|
55
61
|
name: jbuilder
|
|
56
62
|
requirement: !ruby/object:Gem::Requirement
|