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.
- checksums.yaml +4 -4
- data/app/assets/builds/alchemy/admin.css +1 -1
- 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/attachment_select.js +5 -2
- data/app/javascript/alchemy_admin/components/node_select.js +6 -3
- data/app/javascript/alchemy_admin/components/page_select.js +8 -5
- data/app/javascript/alchemy_admin/components/remote_select.js +35 -1
- 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/stylesheets/alchemy/admin/_tom-select.scss +1 -0
- data/app/stylesheets/alchemy/admin/forms.scss +2 -1
- data/app/views/alchemy/admin/elements/_form.html.erb +2 -5
- data/app/views/alchemy/admin/nodes/_page_nodes.html.erb +4 -2
- 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)}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 {
|
|
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, "&")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/"/g, """)
|
|
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
|
-
|
|
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.
|
|
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 -%>
|
|
@@ -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
|
|
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
|
-
|
|
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