alchemy_cms 8.3.7 → 8.3.9

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.
@@ -17,7 +17,6 @@ module Alchemy
17
17
  placeholder: Alchemy.t(:select_element) do
18
18
  text_field_tag(field_name, nil, {
19
19
  autofocus:,
20
- required: true,
21
20
  value: elements.many? ? nil : elements.first&.name,
22
21
  class: "alchemy_selectbox full_width"
23
22
  })
@@ -49,8 +49,9 @@ module Alchemy
49
49
  if @element.save
50
50
  render :create, status: 201
51
51
  else
52
+ @parent_element = @element.parent_element
52
53
  @element.page_version = @page_version
53
- @elements = @page.available_element_definitions
54
+ @elements = @page.available_elements_within_current_scope(@parent_element)
54
55
  render :new, status: 422
55
56
  end
56
57
  end
@@ -99,6 +99,31 @@ module Alchemy
99
99
  end
100
100
  end
101
101
 
102
+ # Returns the value prepared for a cell of a CSV export.
103
+ #
104
+ # Spreadsheet applications evaluate a cell starting with one of these characters
105
+ # as a formula, turning exported content into code that runs on the machine
106
+ # opening the file. The tab keeps the cell text and, unlike the more common
107
+ # single quote, survives Excel saving and re-opening the file, which strips
108
+ # quotes and would reactivate the formula. It only works inside a quoted field,
109
+ # so the export generates with `force_quotes`.
110
+ #
111
+ # A bare negative number is exempt, because spreadsheets read it as a number
112
+ # instead of a formula, so escaping it would only break arithmetic on the
113
+ # exported column. An expression like `-2+3` is a formula and stays escaped.
114
+ #
115
+ # @see https://owasp.org/www-community/attacks/CSV_Injection
116
+ #
117
+ # @param [Object] value
118
+ # @return [String]
119
+ #
120
+ def csv_value(value)
121
+ value = value.to_s
122
+ return value if value.match?(/\A-\d+(\.\d+)?\z/)
123
+
124
+ value.match?(/\A[-=+@\t\r\n\uFF0D\uFF1D\uFF0B\uFF20]/) ? "\t#{value}" : value
125
+ end
126
+
102
127
  # Returns a options hash for simple_form input fields.
103
128
  def resource_attribute_field_options(attribute)
104
129
  options = {hint: resource_handler.help_text_for(attribute)}
@@ -1,4 +1,7 @@
1
- import { RemoteSelect } from "alchemy_admin/components/remote_select"
1
+ import {
2
+ RemoteSelect,
3
+ escapeHtml
4
+ } from "alchemy_admin/components/remote_select"
2
5
 
3
6
  export class AttachmentSelect extends RemoteSelect {
4
7
  _renderResult(item) {
@@ -15,7 +18,7 @@ export class AttachmentSelect extends RemoteSelect {
15
18
  _renderListEntry(attachment, term) {
16
19
  return `
17
20
  <div class="attachment-select--attachment">
18
- <alchemy-icon name="${attachment.icon_css_class}"></alchemy-icon>
21
+ <alchemy-icon name="${escapeHtml(attachment.icon_css_class)}"></alchemy-icon>
19
22
  <span class="attachment-select--attachment-name">${this._hightlightTerm(attachment.name, term)}</span>
20
23
  </div>
21
24
  `
@@ -1,4 +1,7 @@
1
- import { RemoteSelect } from "alchemy_admin/components/remote_select"
1
+ import {
2
+ RemoteSelect,
3
+ escapeHtml
4
+ } from "alchemy_admin/components/remote_select"
2
5
 
3
6
  class NodeSelect extends RemoteSelect {
4
7
  _searchQuery(term, page) {
@@ -23,7 +26,7 @@ class NodeSelect extends RemoteSelect {
23
26
  * @private
24
27
  */
25
28
  _renderListEntry(node, term) {
26
- const ancestors = node.ancestors.map((a) => a.name)
29
+ const ancestors = node.ancestors.map((a) => escapeHtml(a.name))
27
30
  const seperator = `<alchemy-icon name="arrow-right-s"></alchemy-icon>`
28
31
 
29
32
  return `
@@ -38,7 +41,7 @@ class NodeSelect extends RemoteSelect {
38
41
  </span>
39
42
  </div>
40
43
  <div class="node-select--node-url">
41
- ${node.url || ""}
44
+ ${escapeHtml(node.url || "")}
42
45
  </div>
43
46
  </div>
44
47
  `
@@ -1,4 +1,7 @@
1
- import { RemoteSelect } from "alchemy_admin/components/remote_select"
1
+ import {
2
+ RemoteSelect,
3
+ escapeHtml
4
+ } from "alchemy_admin/components/remote_select"
2
5
 
3
6
  export class PageSelect extends RemoteSelect {
4
7
  get pageId() {
@@ -30,7 +33,7 @@ export class PageSelect extends RemoteSelect {
30
33
  * @private
31
34
  */
32
35
  _renderResult(page) {
33
- return page.text || page.name
36
+ return escapeHtml(page.text || page.name)
34
37
  }
35
38
 
36
39
  /**
@@ -46,11 +49,11 @@ export class PageSelect extends RemoteSelect {
46
49
  <div class="page-select--top">
47
50
  <alchemy-icon name="file-3"></alchemy-icon>
48
51
  <span class="page-select--page-name">${this._hightlightTerm(page.name, term)}</span>
49
- <span class="page-select--site-name">${page.site.name}</span>
52
+ <span class="page-select--site-name">${escapeHtml(page.site.name)}</span>
50
53
  </div>
51
54
  <div class="page-select--bottom">
52
- <span class="page-select--page-urlname">${page.url_path}</span>
53
- <span class="page-select--language-code">${page.language_code}</span>
55
+ <span class="page-select--page-urlname">${escapeHtml(page.url_path)}</span>
56
+ <span class="page-select--language-code">${escapeHtml(page.language_code)}</span>
54
57
  </div>
55
58
  </div>
56
59
  `
@@ -1,7 +1,41 @@
1
1
  import { setupSelectLocale } from "alchemy_admin/i18n"
2
2
 
3
+ /**
4
+ * Escapes a string for use within HTML. Select2 hands the return value of
5
+ * `formatResult`/`formatSelection` straight to jQuery, so everything woven into
6
+ * those templates has to be escaped here.
7
+ * @param {string} value
8
+ * @returns {string}
9
+ */
10
+ export function escapeHtml(value) {
11
+ return `${value}`
12
+ .replace(/&/g, "&amp;")
13
+ .replace(/</g, "&lt;")
14
+ .replace(/>/g, "&gt;")
15
+ .replace(/"/g, "&quot;")
16
+ }
17
+
18
+ /**
19
+ * Escapes a string for literal use inside a `RegExp`, so a term with
20
+ * metacharacters (e.g. `(`) matches itself instead of being compiled as a
21
+ * pattern, which could match the wrong text or throw a SyntaxError.
22
+ * @param {string} value
23
+ * @returns {string}
24
+ */
25
+ export function escapeRegExp(value) {
26
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
27
+ }
28
+
3
29
  export function hightlightTerm(name, term) {
4
- return name.replace(new RegExp(term, "gi"), (match) => `<em>${match}</em>`)
30
+ // The result is rendered raw as the entry's name, so the name must be escaped
31
+ // before the term markup is woven in — otherwise a crafted name (e.g. a menu
32
+ // node title) is a stored XSS sink.
33
+ const escapedName = escapeHtml(name)
34
+ if (!term) return escapedName
35
+ return escapedName.replace(
36
+ new RegExp(escapeRegExp(term), "gi"),
37
+ (match) => `<em>${match}</em>`
38
+ )
5
39
  }
6
40
 
7
41
  export class RemoteSelect extends HTMLElement {
@@ -197,6 +197,10 @@ class Select extends HTMLSelectElement {
197
197
  }
198
198
 
199
199
  #destroyTomSelect() {
200
+ // Tom Select's destroy() removes the dropdown, but it never closes it, so
201
+ // the onDropdownClose handler does not run and the full window dropdown
202
+ // mask would be left behind, swallowing every click.
203
+ this.#tomSelect?.close(false)
200
204
  this.#tomSelect?.destroy()
201
205
  this.#tomSelect = null
202
206
  }
@@ -93,11 +93,14 @@ export class FileUpload extends HTMLElement {
93
93
  ? config.allowed_filetypes.alchemy_pictures
94
94
  : config.allowed_filetypes.alchemy_attachments
95
95
 
96
+ // The allowlist holds filename extensions, so we cannot derive the format
97
+ // from the MIME type: `application/msword` is not `doc` and every Office
98
+ // Open XML type only yields `vnd`.
99
+ const extension = this.file?.name.match(/\.([^.]+)$/)?.[1].toLowerCase()
100
+
96
101
  const isFileFormatSupported =
97
102
  allowedFiletypes.includes("*") ||
98
- allowedFiletypes.includes(
99
- this.file?.type.replace(/^\w+\/(\w+)(\+\w+)?/i, "$1")
100
- )
103
+ allowedFiletypes.some((type) => type.toLowerCase() === extension)
101
104
 
102
105
  if (!isFileFormatSupported) {
103
106
  errorMessage = translate("File type not allowed")
@@ -148,6 +148,12 @@ module Alchemy
148
148
  # @param [Alchemy::Attachment]
149
149
  # @return [String]
150
150
  def file_extension(attachment)
151
+ extension = File.extname(file_name(attachment).to_s).delete_prefix(".").downcase
152
+ return extension if extension.present?
153
+
154
+ # Marcel returns the canonical extension for a mime type, which is not
155
+ # always the one the file was named after: `audio/mpeg` yields `mpga`,
156
+ # not `mp3`. Only reach for it when the name has no extension at all.
151
157
  content_type = file_mime_type(attachment)
152
158
  Marcel::Magic.new(content_type).extensions.first if content_type
153
159
  end
@@ -229,6 +229,7 @@
229
229
  .option {
230
230
  padding: var(--spacing-2);
231
231
  cursor: pointer;
232
+ min-height: 2.75em;
232
233
 
233
234
  .highlight {
234
235
  text-decoration: underline;
@@ -128,7 +128,8 @@ form {
128
128
  }
129
129
 
130
130
  .field_with_errors {
131
- input[type="text"],
131
+ &:not(:has(.ts-control)) input[type="text"],
132
+ .ts-control,
132
133
  input[type="email"],
133
134
  input[type="password"],
134
135
  textarea,
@@ -6,16 +6,13 @@
6
6
  <%= turbo_frame_tag @element do %>
7
7
  <%= alchemy_form_for [:admin, @element], remote: false do |form| %>
8
8
  <%= form.hidden_field :page_version_id %>
9
- <div class="input">
10
- <%= form.label(:name, class: "control-label required") do %>
11
- <%= Alchemy.t(:element_of_type) %><abbr>*</abbr>
12
- <% end %>
9
+ <%= form.input :name, label: Alchemy.t(:element_of_type) do %>
13
10
  <%= render Alchemy::Admin::ElementSelect.new(
14
11
  @elements,
15
12
  field_name: form.field_name(:name),
16
13
  autofocus: true
17
14
  ) %>
18
- </div>
15
+ <% end %>
19
16
  <%= form.hidden_field :parent_element_id, value: @parent_element.try(:id) %>
20
17
  <%= form.submit Alchemy.t(:add) %>
21
18
  <%- end -%>
@@ -7,11 +7,13 @@
7
7
  <th class="tools"></th>
8
8
  </tr>
9
9
  <% nodes = @page.nodes.select(&:persisted?) %>
10
- <% seperator = '<alchemy-icon name="arrow-right-s" style="vertical-align: text-bottom"></alchemy-icon>' %>
10
+ <% seperator = '<alchemy-icon name="arrow-right-s" style="vertical-align: text-bottom"></alchemy-icon>'.html_safe %>
11
11
  <% if nodes.length > 0 %>
12
12
  <% nodes.each do |node| %>
13
13
  <tr class="even">
14
- <td><%== node.ancestors.map(&:name).join(seperator) %><%== seperator %><strong><%== node.name %></strong></td>
14
+ <td>
15
+ <%= safe_join(node.ancestors.map(&:name), seperator) %><%= seperator %><strong><%= node.name %></strong>
16
+ </td>
15
17
  <td class="tools">
16
18
  <sl-tooltip content="<%= Alchemy.t("delete_node") %>">
17
19
  <%= link_to render_icon(:minus),
@@ -1,13 +1,13 @@
1
- <% csv = CSV.generate(col_sep: ";", row_sep: "\r\n") do |row| %>
1
+ <% csv = CSV.generate(col_sep: ";", row_sep: "\r\n", force_quotes: true) do |row| %>
2
2
  <% row << ["Id"] + resource_handler.attributes.collect do |a|
3
- resource_handler.model.human_attribute_name(a[:name])
3
+ csv_value(resource_handler.model.human_attribute_name(a[:name]))
4
4
  end %>
5
5
  <% resources_instance_variable.each do |resource| %>
6
6
  <% row << [resource.id] + resource_handler.attributes.map do |a|
7
7
  if a[:type] == :boolean
8
8
  resource.send(a[:name]) ? 'x' : ''
9
9
  else
10
- render_attribute(resource, a, {truncate: false}).to_s.strip
10
+ csv_value(render_attribute(resource, a, {truncate: false}).to_s.strip)
11
11
  end
12
12
  end %>
13
13
  <% end %>
@@ -1,30 +1,44 @@
1
1
  class AddPublicationTimestampsToAlchemyElements < ActiveRecord::Migration[7.2]
2
+ # Only SQLite needs this: it has no real ALTER TABLE, so the remove_column
3
+ # calls in down rebuild the table via DROP TABLE, whose implicit DELETE
4
+ # cascades into alchemy_ingredients. The adapter's PRAGMA foreign_keys = OFF
5
+ # guard against that is a silent no-op inside a transaction.
6
+ disable_ddl_transaction! if connection.adapter_name.match?(/sqlite/i)
7
+
2
8
  def up
3
- add_column :alchemy_elements, :public_on, :datetime
4
- add_column :alchemy_elements, :public_until, :datetime
9
+ # Adding nullable columns is a plain ALTER TABLE ADD COLUMN, so up can keep
10
+ # the atomicity the declaration above gives up for both directions.
11
+ connection.transaction do
12
+ add_column :alchemy_elements, :public_on, :datetime
13
+ add_column :alchemy_elements, :public_until, :datetime
5
14
 
6
- say_with_time "Populating publication dates" do
7
- update <<-SQL.squish
8
- UPDATE alchemy_elements
9
- SET public_on = created_at
10
- WHERE public = #{connection.quoted_true}
11
- SQL
15
+ say_with_time "Populating publication dates" do
16
+ update <<-SQL.squish
17
+ UPDATE alchemy_elements
18
+ SET public_on = created_at
19
+ WHERE public = #{connection.quoted_true}
20
+ SQL
21
+ end
12
22
  end
13
23
  end
14
24
 
15
25
  def down
16
- say_with_time "Reverting publication dates" do
17
- update <<-SQL.squish
18
- UPDATE alchemy_elements
19
- SET public = CASE
20
- WHEN public_on IS NOT NULL AND public_on <= CURRENT_TIMESTAMP
21
- THEN #{connection.quoted_true}
22
- ELSE #{connection.quoted_false}
23
- END
24
- SQL
26
+ # Running without a transaction on SQLite, a re-run can start after the
27
+ # columns are already gone, leaving the statement below nothing to read.
28
+ if connection.column_exists?(:alchemy_elements, :public_on)
29
+ say_with_time "Reverting publication dates" do
30
+ update <<-SQL.squish
31
+ UPDATE alchemy_elements
32
+ SET public = CASE
33
+ WHEN public_on IS NOT NULL AND public_on <= CURRENT_TIMESTAMP
34
+ THEN #{connection.quoted_true}
35
+ ELSE #{connection.quoted_false}
36
+ END
37
+ SQL
38
+ end
25
39
  end
26
40
 
27
- remove_column :alchemy_elements, :public_until
28
- remove_column :alchemy_elements, :public_on
41
+ remove_column :alchemy_elements, :public_until, if_exists: true
42
+ remove_column :alchemy_elements, :public_on, if_exists: true
29
43
  end
30
44
  end
@@ -3,7 +3,7 @@
3
3
  module Alchemy
4
4
  extend self
5
5
 
6
- VERSION = "8.3.7"
6
+ VERSION = "8.3.9"
7
7
 
8
8
  def version
9
9
  VERSION
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: alchemy_cms
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.3.7
4
+ version: 8.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas von Deyen