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.
- checksums.yaml +4 -4
- data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
- data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
- data/app/components/alchemy/admin/element_select.rb +0 -1
- data/app/controllers/alchemy/admin/elements_controller.rb +2 -1
- data/app/helpers/alchemy/resources_helper.rb +25 -0
- data/app/javascript/alchemy_admin/components/select.js +4 -0
- data/app/javascript/alchemy_admin/components/uploader/file_upload.js +6 -3
- data/app/models/alchemy/storage_adapter/dragonfly.rb +6 -0
- data/app/views/alchemy/admin/elements/_form.html.erb +2 -5
- data/app/views/alchemy/admin/resources/index.csv.erb +3 -3
- data/db/migrate/20260115164704_add_publication_timestamps_to_alchemy_elements.rb +33 -19
- data/lib/alchemy/version.rb +1 -1
- metadata +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
data/lib/alchemy/version.rb
CHANGED