alchemy_cms 8.3.8 → 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)}
@@ -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
@@ -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 -%>
@@ -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.8"
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.8
4
+ version: 8.3.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Thomas von Deyen