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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7a35de5b4390aeb2fa2124719502e44c93735facfaa0170dc135224597f0360d
4
- data.tar.gz: 14130de771f74326f7860890a4e1613435650dc1dbd1c74dd9ba63780b6bcfbc
3
+ metadata.gz: 063d286f745ed3fb368105241f2b9cc7f753e153620c63bb9020fea5946a7fad
4
+ data.tar.gz: 8e29ca9e75f3a2e1644facb4e534c21f0fe821c7ddcd72d2bb9bc4a0eb4a8921
5
5
  SHA512:
6
- metadata.gz: 4d4bf488370c709556ae1ad3b7e5523ae6058574192fb2aee81d34f2e64c68de74f6ac96e662ec851f3bcad0119b233b3876bcca3d2bb218e447b6a9690ad910
7
- data.tar.gz: 792918eefe3aa9d8c5019c3db64bc5d2c204efe8637781275193ed27dcfd76624f72f5405cb3324385538355b7f3f9a6f392c9dbd1c248cdd83c1aaf5173e4aa
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>${message}
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}">${message}</span>`
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">${item.name}</span>
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" name="${this.hiddenNameValue}" value="${item.id}">`
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
- previewMarkup (item) {
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
- return `<video controls preload="${poster ? 'none' : 'metadata'}" poster="${poster}" class="lato-cms-media-field__player" src="${item.url}"></video>`
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
- return `<img src="${item.thumbnailUrl || item.url}" class="lato-cms-media-field__thumb">`
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
- return '<div class="lato-cms-media-field__icon"><i class="bi bi-paperclip"></i></div>'
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 = `<div class="alert alert-success">${item.name}</div>`
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 = `<div class="alert alert-danger">${event.detail.message}</div>`
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) this.noticeTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`
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 () {
@@ -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.update_column(:value, translated[index]) if translated[index].present? }
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(&:value).to_json}"
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="<%= CGI.escapeHTML(current_value) %>">
83
+ value="<%= current_value %>">
82
84
  </div>
@@ -1,3 +1,3 @@
1
1
  module LatoCms
2
- VERSION = "3.3.4"
2
+ VERSION = "3.4.0"
3
3
  end
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.3.4
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: 7.1.1
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: 7.1.1
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: '0'
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: '0'
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: '0'
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: '0'
59
+ version: 3.2.0
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: jbuilder
56
62
  requirement: !ruby/object:Gem::Requirement