camaleon_cms 2.9.3 → 2.9.4
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/apps/plugins/front_cache/admin_controller.rb +2 -3
- data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
- data/app/apps/plugins/front_cache/front_cache_helper.rb +86 -38
- data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
- data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +4 -0
- data/app/controllers/camaleon_cms/frontend_controller.rb +7 -5
- data/app/decorators/camaleon_cms/post_decorator.rb +6 -4
- data/app/decorators/camaleon_cms/site_decorator.rb +5 -3
- data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
- data/app/models/camaleon_cms/custom_fields_relationship.rb +7 -0
- data/app/models/camaleon_cms/post.rb +24 -0
- data/app/models/camaleon_cms/term_taxonomy.rb +6 -0
- data/app/models/camaleon_cms/user_role.rb +15 -0
- data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +19 -7
- data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
- data/config/locales/camaleon_cms/admin/en.yml +3 -0
- data/lib/camaleon_cms/engine.rb +0 -14
- data/lib/camaleon_cms/shortcode_registry.rb +102 -0
- data/lib/camaleon_cms/version.rb +1 -1
- data/lib/camaleon_cms.rb +12 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c6940e13676d23d32f7eddfc3cabe9cc6639914fc79f5d53497040dc2987fbfb
|
|
4
|
+
data.tar.gz: 8079bcd83ef28f77f628ad33fbc0bc89f17e2f6ae130868f3d49f112b4cfd1e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99302745a2adbd3a0bce4c9e85153bd6d216f5fd3d6ed67c038b461e465339de41e386b114d1075dd1471fca07c70c72c8492a5e9fcae28bf49fcc3f91c71389
|
|
7
|
+
data.tar.gz: e5ee5c70326b92c29e9ca02be583930d1e2a1bc9a6308cf0bc90a090a29a2c2e4b9417ce9958065561b8d5844146844544d341464c268f2ff40baec939dc4b6c
|
|
@@ -18,9 +18,7 @@ module Plugins
|
|
|
18
18
|
skip_posts: params[:cache][:skip_posts] || [],
|
|
19
19
|
cache_login: params[:cache][:cache_login],
|
|
20
20
|
home: params[:cache][:home],
|
|
21
|
-
preserve_cache_on_restart: params[:cache][:preserve_cache_on_restart]
|
|
22
|
-
invalidate_only: params[:cache][:invalidate_only],
|
|
23
|
-
cache_counter: current_site.get_meta('front_cache_elements')[:cache_counter] || 0
|
|
21
|
+
preserve_cache_on_restart: params[:cache][:preserve_cache_on_restart]
|
|
24
22
|
}
|
|
25
23
|
)
|
|
26
24
|
flash[:notice] = t('plugin.front_cache.message.settings_saved').to_s
|
|
@@ -30,6 +28,7 @@ module Plugins
|
|
|
30
28
|
def clean_cache
|
|
31
29
|
flash[:notice] = t('plugin.front_cache.message.cache_destroyed').to_s
|
|
32
30
|
front_cache_clean
|
|
31
|
+
front_cache_purge_stored_pages
|
|
33
32
|
redirect_to(request.referer || '/admin/plugins')
|
|
34
33
|
end
|
|
35
34
|
end
|
|
@@ -13,7 +13,6 @@ en:
|
|
|
13
13
|
pages_of: 'Pages of'
|
|
14
14
|
skip_cache_pages: 'Skip Cache Pages'
|
|
15
15
|
preserve_cache_on_restart: 'Preserve cache on restart'
|
|
16
|
-
invalidate_only: 'Invalidate the cache instead of deleting it. (Recommended only if you use custom caching in your views. Not recommended when using FileStore, which is the Rails default cache)'
|
|
17
16
|
message:
|
|
18
17
|
please_checkpost_need_cache: 'Please check all posts or post types you need to cache.'
|
|
19
18
|
enabled_cache_inner_content_logged_users: 'Enable cache for inner content for logged users'
|
|
@@ -34,7 +33,6 @@ es:
|
|
|
34
33
|
pages_of: 'Página de'
|
|
35
34
|
skip_cache_pages: 'Saltar página del Caché'
|
|
36
35
|
preserve_cache_on_restart: 'Preservar caché al reiniciar'
|
|
37
|
-
invalidate_only: 'Invalide el caché en lugar de eliminarlo. (Recomendado sólo si utiliza el almacenamiento en caché personalizado en sus vistas No se recomienda cuando se utiliza FileStore, que es la caché predeterminada de Rails)'
|
|
38
36
|
message:
|
|
39
37
|
please_checkpost_need_cache: 'Por favor, compruebe todos los mensajes o los tipos de correos que necesita para almacenar en caché.'
|
|
40
38
|
enabled_cache_inner_content_logged_users: 'Habilitar caché de contenido interno para usuarios registrados'
|
|
@@ -56,7 +54,6 @@ nl:
|
|
|
56
54
|
pages_of: "Inhoud van"
|
|
57
55
|
skip_cache_pages: 'Cache overslaan'
|
|
58
56
|
preserve_cache_on_restart: 'Bewaar cache bij herstarten'
|
|
59
|
-
invalidate_only: 'De cache ongeldig maken, in plaats van het te verwijderen. (Alleen aanbevolen als u aangepaste caching gebruikt in uw weergaven. Niet aanbevolen bij het gebruik van FileStore, wat is de standaard cache van de Rails)'
|
|
60
57
|
message:
|
|
61
58
|
please_checkpost_need_cache: "Kijk alle berichten of inhoud type's na die je wil cachen."
|
|
62
59
|
enabled_cache_inner_content_logged_users: 'Activeer cache voor gelogde gebruikers'
|
|
@@ -77,7 +74,6 @@ pt-BR:
|
|
|
77
74
|
pages_of: 'Páginas de'
|
|
78
75
|
skip_cache_pages: 'Saltar Cache de Páginas'
|
|
79
76
|
preserve_cache_on_restart: 'Preserve o cache no reinício'
|
|
80
|
-
invalidate_only: 'Invalide o cache em vez de excluí-lo. (Recomendado apenas se você usar cache personalizado em suas visualizações. Não recomendado quando usar FileStore, que é o cache padrão do Rails)'
|
|
81
77
|
message:
|
|
82
78
|
please_checkpost_need_cache: 'Por favor verifique todas as publicações ou o tipo do publicação que precisa colocar em cache.'
|
|
83
79
|
enabled_cache_inner_content_logged_users: 'Permitir cache para conteúdo interno de utilizadores ligados'
|
|
@@ -98,7 +94,6 @@ pt-BR:
|
|
|
98
94
|
pages_of: 'Páginas de'
|
|
99
95
|
skip_cache_pages: 'Pular Cache de Páginas'
|
|
100
96
|
preserve_cache_on_restart: 'Preserve o cache no reinício'
|
|
101
|
-
invalidate_only: 'Invalide o cache em vez de excluí-lo. (Recomendado apenas se você usar cache personalizado em suas visualizações. Não recomendado quando usar FileStore, que é o cache padrão do Rails)'
|
|
102
97
|
message:
|
|
103
98
|
please_checkpost_need_cache: 'Por favor cheque todos os posts ou o tipo do post que você precisa cachear.'
|
|
104
99
|
enabled_cache_inner_content_logged_users: 'Permitir cache para conteúdo interno de usuários logados'
|
|
@@ -119,7 +114,6 @@ it:
|
|
|
119
114
|
pages_of: 'Pagine di'
|
|
120
115
|
skip_cache_pages: 'Pagine da non cachare'
|
|
121
116
|
preserve_cache_on_restart: 'Conserva la cache al riavvio'
|
|
122
|
-
invalidate_only: 'Invalidate la cache invece di cancellarla. (Consigliato solo se si utilizza la cache personalizzata nelle tue viste. Non è consigliata quando si utilizza FileStore, che è la cache predefinita Rails)'
|
|
123
117
|
message:
|
|
124
118
|
please_checkpost_need_cache: 'Per favore, verifica tutti i tipi di messaggi o e-mail che è necessario memorizzare nella cache.'
|
|
125
119
|
enabled_cache_inner_content_logged_users: 'Attiva il contenuto della cache interna per gli utenti registrati'
|
|
@@ -140,7 +134,6 @@ zh-CN:
|
|
|
140
134
|
pages_of: '页'
|
|
141
135
|
skip_cache_pages: '跳过缓存页面'
|
|
142
136
|
preserve_cache_on_restart: '重新启动时保存缓存'
|
|
143
|
-
invalidate_only: '使缓存无效,而不是删除它。 (仅当您在视图中使用自定义缓存时才推荐使用FileStore时,建议不要使用Rails默认缓存)'
|
|
144
137
|
message:
|
|
145
138
|
please_checkpost_need_cache: '请检查您需要缓存位内容.'
|
|
146
139
|
enabled_cache_inner_content_logged_users: '为已登录的用户启用内部内容的缓存'
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
module Plugins
|
|
2
2
|
module FrontCache
|
|
3
3
|
module FrontCacheHelper
|
|
4
|
+
# Upper bound on any stored page's life. Stores whose purge is rescued away (RedisCacheStore
|
|
5
|
+
# and MemCacheStore reject the matcher) would otherwise keep retired or never-revisited
|
|
6
|
+
# entries forever — and Redis's default maxmemory-policy is noeviction, so TTL-less bodies
|
|
7
|
+
# would grow until the shared store refuses writes. An expired entry is an ordinary miss: the
|
|
8
|
+
# page is re-rendered and re-cached.
|
|
9
|
+
FRONT_CACHE_EXPIRATION = 1.week
|
|
4
10
|
# cache all pages configured in this plugin's settings for public users
|
|
5
11
|
def front_cache_front_before_load
|
|
6
|
-
|
|
7
|
-
|
|
12
|
+
# invalidate the site's cached pages on the first request after a (re)start, unless
|
|
13
|
+
# preserve_cache_on_restart is checked in the plugin settings
|
|
14
|
+
if current_site.get_option('refresh_cache')
|
|
15
|
+
front_cache_clean unless (current_site.get_meta('front_cache_elements') || {})[:preserve_cache_on_restart]
|
|
8
16
|
current_site.set_option('refresh_cache', false)
|
|
9
17
|
end
|
|
10
18
|
|
|
@@ -12,11 +20,18 @@ module Plugins
|
|
|
12
20
|
return if signin? || Rails.env.development? || Rails.env.test? || !request.get?
|
|
13
21
|
|
|
14
22
|
cache_key = front_cache_plugin_cache_key
|
|
15
|
-
|
|
16
|
-
|
|
23
|
+
# Fail closed on a missing settings meta: front_cache runs on every frontend request, so an
|
|
24
|
+
# absent front_cache_elements (never seeded, or hand-deleted) must degrade to "cache nothing"
|
|
25
|
+
# rather than raise NoMethodError and 500 the whole public site.
|
|
26
|
+
@caches = current_site.get_meta('front_cache_elements') || {}
|
|
27
|
+
# Single read: the old exist?-then-get pair issued two store reads, and an entry vanishing
|
|
28
|
+
# between them (a concurrent admin purge, TTL expiry) left .gsub running on nil — a
|
|
29
|
+
# visitor-facing 500.
|
|
30
|
+
cached_body = flash.keys.blank? ? front_cache_get(cache_key) : nil
|
|
31
|
+
if cached_body # recover cache item
|
|
17
32
|
Rails.logger.info "Camaleon CMS - readed cache: #{front_cache_plugin_get_path(cache_key)}"
|
|
18
33
|
response.headers['PLUGIN_FRONT_CACHE'] = 'TRUE'
|
|
19
|
-
args = { data:
|
|
34
|
+
args = { data: cached_body.gsub('{{form_authenticity_token}}', form_authenticity_token) }
|
|
20
35
|
hooks_run('front_cache_reading_cache', args)
|
|
21
36
|
# rubocop:disable Rails/OutputSafety -- This replays a trusted cached page body that was already rendered by Rails.
|
|
22
37
|
render html: args[:data].html_safe
|
|
@@ -26,24 +41,24 @@ module Plugins
|
|
|
26
41
|
|
|
27
42
|
@_plugin_do_cache = false
|
|
28
43
|
# cache paths and home page
|
|
29
|
-
|
|
44
|
+
paths = @caches[:paths] || []
|
|
45
|
+
if paths.include?(request.original_url) || paths.include?(request.path_info) ||
|
|
30
46
|
front_cache_plugin_match_path_patterns?(request.original_url, request.path_info) ||
|
|
31
47
|
(params[:action] == 'index' && params[:controller] == 'camaleon_cms/frontend' && @caches[:home].present?)
|
|
32
48
|
@_plugin_do_cache = true
|
|
33
49
|
elsif params[:action] == 'post' && params[:controller] == 'camaleon_cms/frontend' && params[:draft_id].blank?
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
end
|
|
50
|
+
# the_post is a single-record lookup (eager: false) -- no listing preloads for one post
|
|
51
|
+
# Never cache non-public posts. A password-protected post is unlocked per session (visibility_post
|
|
52
|
+
# audit M2), but the page cache is keyed on the URL alone, so caching an unlocked render would
|
|
53
|
+
# serve the protected body to visitors who never entered the password. Private posts are already
|
|
54
|
+
# excluded; password posts must be too.
|
|
55
|
+
if (post = current_site.the_post(params[:slug])) && post.can_visit? && !%w[private
|
|
56
|
+
password].include?(post.visibility)
|
|
57
|
+
if (@caches[:skip_posts] || []).include?(post.id.to_s)
|
|
58
|
+
@_plugin_do_cache = false
|
|
59
|
+
elsif (@caches[:post_types] || []).include?(post.post_type_id.to_s) ||
|
|
60
|
+
(@caches[:posts] || []).include?(post.id.to_s)
|
|
61
|
+
@_plugin_do_cache = true
|
|
47
62
|
end
|
|
48
63
|
end
|
|
49
64
|
end
|
|
@@ -79,8 +94,7 @@ module Plugins
|
|
|
79
94
|
post_types: [current_site.post_types.where(slug: 'page').first.id],
|
|
80
95
|
skip_posts: [],
|
|
81
96
|
home: true,
|
|
82
|
-
cache_login: true
|
|
83
|
-
cache_counter: 0
|
|
97
|
+
cache_login: true
|
|
84
98
|
}
|
|
85
99
|
)
|
|
86
100
|
end
|
|
@@ -101,46 +115,80 @@ module Plugins
|
|
|
101
115
|
arg[:links] << link_to(t('plugin.front_cache.clean_cache'), admin_plugins_front_cache_clean_path)
|
|
102
116
|
end
|
|
103
117
|
|
|
104
|
-
#
|
|
118
|
+
# invalidate the page cache on any content-changing request. PUT and DELETE count: a permanent
|
|
119
|
+
# post deletion rides DELETE (Rack::MethodOverride makes request.post? false for
|
|
120
|
+
# _method=delete forms), and without a bump the deleted page kept being served from cache.
|
|
105
121
|
def front_cache_post_requests
|
|
106
|
-
return unless request.post? || request.patch?
|
|
122
|
+
return unless request.post? || request.put? || request.patch? || request.delete?
|
|
123
|
+
# A draft autosave (posts/drafts) writes a private draft_child buffer, never published
|
|
124
|
+
# output, so it must not retire the public page cache — otherwise an open editor's
|
|
125
|
+
# per-minute autosave keeps the whole site's cache from ever warming.
|
|
126
|
+
return if params[:controller].to_s.end_with?('posts/drafts')
|
|
107
127
|
|
|
108
128
|
front_cache_clean
|
|
109
129
|
end
|
|
110
130
|
|
|
111
|
-
#
|
|
131
|
+
# invalidate all cached pages of the current site
|
|
112
132
|
def front_cache_clean
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
133
|
+
# Security: never Rails.cache.clear — the store is shared, and clearing it on every POST also
|
|
134
|
+
# destroyed unrelated entries such as the per-IP login brute-force counter (CaptchaHelper),
|
|
135
|
+
# silently defeating it. Bumping the version compared on every page-cache read (ActiveSupport
|
|
136
|
+
# `version:`) retires all of this site's pages on any store, with no store enumeration.
|
|
137
|
+
#
|
|
138
|
+
# The version lives in its own meta key, apart from the front_cache_elements settings hash:
|
|
139
|
+
# save_settings rewrites that hash wholesale from an earlier read, so a version stored inside
|
|
140
|
+
# it could be reverted by a settings save racing a concurrent bump — and a reverted version
|
|
141
|
+
# resurrects a retired generation as servable.
|
|
142
|
+
current_site.set_meta('front_cache_counter', front_cache_version + 1)
|
|
121
143
|
end
|
|
122
144
|
|
|
123
145
|
private
|
|
124
146
|
|
|
147
|
+
# Current page-cache version of the site. `.to_i` covers a site that has never invalidated
|
|
148
|
+
# (missing meta) as version 0 — it must never be nil: an entry written with `version: nil`
|
|
149
|
+
# would match ANY requested version and become immune to invalidation.
|
|
150
|
+
def front_cache_version
|
|
151
|
+
current_site.get_meta('front_cache_counter').to_i
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Physically remove every stored page entry of the current site. Only the explicit admin
|
|
155
|
+
# "Clean cache" action calls this — per-request invalidation is the version bump alone, so the
|
|
156
|
+
# request path never pays a store walk. The pattern is ^-anchored on purpose: with a store
|
|
157
|
+
# :namespace, ActiveSupport's key_matcher recognizes only a leading ^ (composing /^ns:<rest>/);
|
|
158
|
+
# any other source becomes /^ns:.*<source>/, where \A can never match, silently deleting
|
|
159
|
+
# nothing. Keys are single-line, so ^ cannot over-match. Best-effort: delete_matched is
|
|
160
|
+
# optional store API (MemCacheStore raises NotImplementedError, RedisCacheStore accepts only
|
|
161
|
+
# glob Strings, third-party stores raise their own classes) and the version bump has already
|
|
162
|
+
# retired the pages, so any store error is logged and swallowed.
|
|
163
|
+
def front_cache_purge_stored_pages
|
|
164
|
+
Rails.cache.delete_matched(%r{^cama_front_cache/#{current_site.id}/})
|
|
165
|
+
rescue StandardError, NotImplementedError => e
|
|
166
|
+
Rails.logger.warn "Camaleon CMS - front_cache purge skipped: #{e.class}: #{e.message}"
|
|
167
|
+
end
|
|
168
|
+
|
|
125
169
|
def front_cache_exist?(key)
|
|
126
|
-
!
|
|
170
|
+
!front_cache_get(key).nil?
|
|
127
171
|
end
|
|
128
172
|
|
|
129
173
|
def front_cache_get(key)
|
|
130
|
-
Rails.cache.read(front_cache_plugin_get_path(key))
|
|
174
|
+
Rails.cache.read(front_cache_plugin_get_path(key), version: front_cache_version)
|
|
131
175
|
end
|
|
132
176
|
|
|
177
|
+
# One entry per URL: the version is stored inside the entry, so re-caching a page after an
|
|
178
|
+
# invalidation overwrites the retired body in place on every store — storage is bounded at one
|
|
179
|
+
# entry per cached URL instead of one per URL per invalidation.
|
|
133
180
|
def front_cache_plugin_cache_create(key, content)
|
|
134
|
-
Rails.cache.write(front_cache_plugin_get_path(key), content
|
|
181
|
+
Rails.cache.write(front_cache_plugin_get_path(key), content,
|
|
182
|
+
version: front_cache_version, expires_in: FRONT_CACHE_EXPIRATION)
|
|
135
183
|
end
|
|
136
184
|
|
|
137
|
-
# return the
|
|
185
|
+
# return the cache key of a stored page
|
|
138
186
|
# key: (string, optional) the key of the cached page
|
|
139
187
|
def front_cache_plugin_get_path(key = nil)
|
|
140
188
|
if key.nil?
|
|
141
|
-
"
|
|
189
|
+
"cama_front_cache/#{current_site.id}"
|
|
142
190
|
else
|
|
143
|
-
"
|
|
191
|
+
"cama_front_cache/#{current_site.id}/#{key}"
|
|
144
192
|
end
|
|
145
193
|
end
|
|
146
194
|
|
|
@@ -61,11 +61,6 @@
|
|
|
61
61
|
<%= check_box_tag("cache[preserve_cache_on_restart]", 1, @caches[:preserve_cache_on_restart]) %>
|
|
62
62
|
</div>
|
|
63
63
|
|
|
64
|
-
<div class="form-group">
|
|
65
|
-
<label><%= t('plugin.front_cache.invalidate_only') %></label><br>
|
|
66
|
-
<%= check_box_tag("cache[invalidate_only]", 1, @caches[:invalidate_only]) %>
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
64
|
<!--<div class="form-group">-->
|
|
70
65
|
<!--<label><%#= t('plugin.front_cache.message.enabled_cache_inner_content_logged_users') %></label><br>-->
|
|
71
66
|
<!--<%#= check_box_tag("cache[cache_login]", 1, @caches[:cache_login]) %>-->
|
|
@@ -84,6 +84,9 @@ module CamaleonCms
|
|
|
84
84
|
# the SameSite=Lax auth cookie) wipes the post's categories, because an omitted `categories`
|
|
85
85
|
# param resolves to [] and update_categories then deletes them all (audit finding M6).
|
|
86
86
|
post.update_categories(cat_ids) if request.post?
|
|
87
|
+
# An existing post renders its OWN saved categories' field groups (update_categories now
|
|
88
|
+
# resets the association, so the read is fresh). Do NOT union the request's categories[]
|
|
89
|
+
# in here, or a read-only GET would render field groups for categories the post lacks.
|
|
87
90
|
args = {}
|
|
88
91
|
else
|
|
89
92
|
# The render-only branch builds a new post of the requested type; gate it on the ability to
|
|
@@ -92,6 +95,7 @@ module CamaleonCms
|
|
|
92
95
|
authorize! :create_post, post_type if post_type
|
|
93
96
|
post = CamaleonCms::Post.new
|
|
94
97
|
post.taxonomy_id = post_type&.id
|
|
98
|
+
# A new post has no saved categories, so pass the requested ids to preview their field groups.
|
|
95
99
|
args = { cat_ids: cat_ids }
|
|
96
100
|
end
|
|
97
101
|
render partial: 'camaleon_cms/admin/settings/custom_fields/render',
|
|
@@ -40,7 +40,7 @@ module CamaleonCms
|
|
|
40
40
|
mark_frontend_category_visited(@category)
|
|
41
41
|
@children = @category.children.no_empty.decorate
|
|
42
42
|
@posts = @category.the_posts.paginate(page: params[:page],
|
|
43
|
-
per_page: current_site.front_per_page)
|
|
43
|
+
per_page: current_site.front_per_page)
|
|
44
44
|
category_slug = @category.the_slug
|
|
45
45
|
|
|
46
46
|
# specific template category with specific slug within a post-type
|
|
@@ -78,7 +78,7 @@ module CamaleonCms
|
|
|
78
78
|
CurrentRequest.frontend_object = @object
|
|
79
79
|
mark_frontend_post_type_visited(@post_type)
|
|
80
80
|
@posts = @post_type.the_posts.paginate(page: params[:page],
|
|
81
|
-
per_page: current_site.front_per_page)
|
|
81
|
+
per_page: current_site.front_per_page)
|
|
82
82
|
@categories = @post_type.categories.no_empty.eager_load(:metas).decorate
|
|
83
83
|
@post_tags = @post_type.post_tags.eager_load(:metas)
|
|
84
84
|
post_type_slug = "post_types/#{@post_type.the_slug}"
|
|
@@ -105,7 +105,7 @@ module CamaleonCms
|
|
|
105
105
|
CurrentRequest.frontend_object = @object
|
|
106
106
|
mark_frontend_tag_visited(@post_tag)
|
|
107
107
|
@posts = @post_tag.the_posts.paginate(page: params[:page],
|
|
108
|
-
per_page: current_site.front_per_page)
|
|
108
|
+
per_page: current_site.front_per_page)
|
|
109
109
|
slug_post_tag = "post_types/#{@post_type.the_slug}/post_tag"
|
|
110
110
|
r_file = lookup_context.template_exists?(slug_post_tag) ? slug_post_tag : 'post_tag'
|
|
111
111
|
layout_ = lookup_context.template_exists?('layouts/post_tag') ? 'post_tag' : nil
|
|
@@ -198,13 +198,15 @@ module CamaleonCms
|
|
|
198
198
|
# post_or_slug_or_id: slug_post | id post | post object
|
|
199
199
|
# from_url: true/false => true (true, permit eval hooks "on_render_post")
|
|
200
200
|
def render_post(post_or_slug_or_id, from_url = false, status = nil, force_visit = false)
|
|
201
|
+
# eager: false -- rendering one post must not pay the listing preloads (with_eager)
|
|
202
|
+
visible_posts = verify_front_visibility(current_site.posts, eager: false)
|
|
201
203
|
@post = case post_or_slug_or_id
|
|
202
204
|
when String # slug
|
|
203
205
|
# find_by_slug is multi-language aware (matches localized slugs like
|
|
204
206
|
# "<!--:en-->sample-post<!--:-->..."), unlike find_by(slug:)
|
|
205
|
-
|
|
207
|
+
visible_posts.find_by_slug(post_or_slug_or_id) # rubocop:disable Rails/DynamicFindBy
|
|
206
208
|
when Integer # id
|
|
207
|
-
|
|
209
|
+
visible_posts.where(id: post_or_slug_or_id).first
|
|
208
210
|
else # model
|
|
209
211
|
post_or_slug_or_id
|
|
210
212
|
end
|
|
@@ -250,7 +250,8 @@ module CamaleonCms
|
|
|
250
250
|
# @param parent: parent decorated model, like: (PostType *default), Category, PostTag, Site
|
|
251
251
|
# @samples: my_post.the_next_post(), my_post.the_next_post(@category), my_post.the_next_post(current_site)
|
|
252
252
|
def the_next_post(parent = nil)
|
|
253
|
-
|
|
253
|
+
# eager: false -- next/prev resolve a single sibling used only for its title/url
|
|
254
|
+
h.verify_front_visibility((parent.presence || the_post_type).posts, eager: false).where(
|
|
254
255
|
post_order_predicate_gt(object.post_order, object.created_at)
|
|
255
256
|
).where.not(id: object.id).take.try(:decorate)
|
|
256
257
|
end
|
|
@@ -259,9 +260,10 @@ module CamaleonCms
|
|
|
259
260
|
# @param parent: parent decorated model, like: (PostType *default), Category, PostTag, Site
|
|
260
261
|
# @samples: my_post.the_prev_post(), my_post.the_prev_post(@category), my_post.the_prev_post(current_site)
|
|
261
262
|
def the_prev_post(parent = nil)
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
# eager: false -- next/prev resolve a single sibling used only for its title/url
|
|
264
|
+
h.verify_front_visibility((parent.presence || the_post_type).posts, eager: false)
|
|
265
|
+
.where(post_order_predicate_lt(object.post_order, object.created_at))
|
|
266
|
+
.where.not(id: object.id).reorder(post_order: :asc, created_at: :asc).last.try(:decorate)
|
|
265
267
|
end
|
|
266
268
|
|
|
267
269
|
# return the title with hierarchy prefixed
|
|
@@ -53,23 +53,25 @@ module CamaleonCms
|
|
|
53
53
|
# slug_or_id: (Array) array of post ids, return multiple posts
|
|
54
54
|
# return post model or nil
|
|
55
55
|
def the_post(slug_or_id)
|
|
56
|
+
# eager: false -- a single-record lookup must not pay the listing preloads (with_eager)
|
|
57
|
+
rel = h.verify_front_visibility(object.posts, eager: false)
|
|
56
58
|
if slug_or_id.is_a?(Integer)
|
|
57
59
|
post = begin
|
|
58
|
-
|
|
60
|
+
rel.find(slug_or_id)
|
|
59
61
|
rescue StandardError
|
|
60
62
|
nil
|
|
61
63
|
end
|
|
62
64
|
end
|
|
63
65
|
if slug_or_id.is_a?(Array)
|
|
64
66
|
post = begin
|
|
65
|
-
|
|
67
|
+
rel.find(slug_or_id)
|
|
66
68
|
rescue StandardError
|
|
67
69
|
nil
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
# use find_by_slug (multi-language aware) so posts with localized slugs
|
|
71
73
|
# (e.g. "<!--:en-->sample-post<!--:-->...") are matched instead of returning nil
|
|
72
|
-
post =
|
|
74
|
+
post = rel.find_by_slug(slug_or_id) if slug_or_id.is_a?(String) # rubocop:disable Rails/DynamicFindBy
|
|
73
75
|
post&.decorate
|
|
74
76
|
end
|
|
75
77
|
|
|
@@ -8,8 +8,13 @@ module CamaleonCms
|
|
|
8
8
|
|
|
9
9
|
# add where conditionals to filter private/hidden/expired/drafts/unpublished
|
|
10
10
|
# note: only for post records
|
|
11
|
-
|
|
11
|
+
# eager: collection listings (the_posts/the_contents) preload metas, categories, post_tags and
|
|
12
|
+
# the post-type metas up front to avoid N+1. Single-record lookups (the_post, render_post,
|
|
13
|
+
# the_next_post/the_prev_post) pass eager: false so a one-row find is not charged the listing
|
|
14
|
+
# preloads it never reads.
|
|
15
|
+
def verify_front_visibility(active_record, eager: true)
|
|
12
16
|
active_record = active_record.visible_frontend
|
|
17
|
+
active_record = active_record.with_eager if eager
|
|
13
18
|
r = { active_record: active_record }
|
|
14
19
|
hooks_run('filter_post', r)
|
|
15
20
|
r[:active_record]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module CamaleonCms
|
|
2
2
|
class CustomFieldsRelationship < CamaleonRecord
|
|
3
|
+
include CamaleonCms::ContentShortcodeGate
|
|
4
|
+
|
|
3
5
|
self.table_name = "#{PluginRoutes.static_system_info['db_prefix']}custom_fields_relationships"
|
|
4
6
|
|
|
5
7
|
# attr_accessible :objectid, :custom_field_id, :term_order, :value, :object_class,
|
|
@@ -24,6 +26,11 @@ module CamaleonCms
|
|
|
24
26
|
GATED_FIELD_KEYS = (MARKUP_FIELD_KEYS + JSON_MARKUP_FIELD_KEYS + URI_FIELD_KEYS).freeze
|
|
25
27
|
|
|
26
28
|
validate :reject_untrusted_dangerous_value
|
|
29
|
+
# Any custom-field value is expanded by do_shortcode at render (CustomFieldsConcern#the_field
|
|
30
|
+
# and friends), regardless of field type, so gate a shortcode in ANY value behind
|
|
31
|
+
# content_shortcodes -- broader than the HTML gate above, which only covers markup/URI field
|
|
32
|
+
# types.
|
|
33
|
+
gate_content_shortcodes :value
|
|
27
34
|
|
|
28
35
|
after_save :set_parent_slug
|
|
29
36
|
after_save :update_model_owner # TODO: convert this model into polymorphic
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module CamaleonCms
|
|
2
2
|
class Post < CamaleonCms::PostDefault
|
|
3
3
|
include CamaleonCms::CategoriesTagsForPosts
|
|
4
|
+
include CamaleonCms::ContentShortcodeGate
|
|
4
5
|
|
|
5
6
|
# Structural, non-executable markup that long-form post content legitimately uses but the
|
|
6
7
|
# sanitizer default drops. Superset of the default so upstream security additions are inherited.
|
|
@@ -39,6 +40,9 @@ module CamaleonCms
|
|
|
39
40
|
default_scope -> { where(post_class: 'Post').order(post_order: :asc, created_at: :desc) }
|
|
40
41
|
|
|
41
42
|
validate :reject_untrusted_dangerous_content, on: %i[create update]
|
|
43
|
+
# Shortcodes in post content are expanded by do_shortcode at render (PostDecorator#the_content),
|
|
44
|
+
# an end-run around post_content_unfiltered_html; gate authorship behind content_shortcodes.
|
|
45
|
+
gate_content_shortcodes :content
|
|
42
46
|
|
|
43
47
|
# DEPRECATED
|
|
44
48
|
has_many :post_relationships, class_name: 'CamaleonCms::PostRelationship', foreign_key: :objectid,
|
|
@@ -74,11 +78,18 @@ module CamaleonCms
|
|
|
74
78
|
scope :drafts, -> { where(status: %w[draft draft_child]) }
|
|
75
79
|
scope :pending, -> { where(status: 'pending') }
|
|
76
80
|
scope :latest, -> { reorder(created_at: :desc) }
|
|
81
|
+
# `preload`, not `includes`: frontend listings run `the_posts` through this scope. If it were
|
|
82
|
+
# `includes`, a caller that later chains a join/`eager_load`/hash-`where` on one of these tables
|
|
83
|
+
# (e.g. PostDecorator#the_related_posts joins :categories) would flip the relation to
|
|
84
|
+
# `eager_loading?` -- one multi-way LEFT JOIN that loads a PARTIAL categories set per post and
|
|
85
|
+
# duplicates rows in `pluck`/`count`. `preload` issues separate queries, never merged into a join.
|
|
86
|
+
scope :with_eager, -> { preload(:metas, :categories, :post_tags, post_type: :metas) }
|
|
77
87
|
|
|
78
88
|
validates_with CamaleonCms::PostUniqValidator
|
|
79
89
|
attr_accessor :show_title_with_parent
|
|
80
90
|
|
|
81
91
|
before_create :fix_post_order, if: ->(p) { p.post_order.blank? || p.post_order == 0 }
|
|
92
|
+
before_save :set_published_at_on_publish
|
|
82
93
|
|
|
83
94
|
# return all parents for current page hierarchy ordered bottom to top
|
|
84
95
|
def parents
|
|
@@ -338,5 +349,18 @@ module CamaleonCms
|
|
|
338
349
|
last_post = post_type.posts.where.not(id: nil).last
|
|
339
350
|
self.post_order = last_post.present? ? (last_post.post_order || 0) + 1 : 1
|
|
340
351
|
end
|
|
352
|
+
|
|
353
|
+
# Stamp the publish time at the moment a post becomes published without an explicit date --
|
|
354
|
+
# the admin Publish action, or any save that transitions the status to 'published'. Keyed to
|
|
355
|
+
# the status change (or the record being born published), not to every save, so a post created
|
|
356
|
+
# as a draft keeps a nil published_at until it is actually published, a caller-supplied date
|
|
357
|
+
# (e.g. a scheduled post) is preserved, and later edits of an already-published post keep the
|
|
358
|
+
# original date.
|
|
359
|
+
def set_published_at_on_publish
|
|
360
|
+
return unless status == 'published' && published_at.blank?
|
|
361
|
+
return unless new_record? || will_save_change_to_status?
|
|
362
|
+
|
|
363
|
+
self.published_at = Time.current.utc
|
|
364
|
+
end
|
|
341
365
|
end
|
|
342
366
|
end
|
|
@@ -2,6 +2,7 @@ module CamaleonCms
|
|
|
2
2
|
class TermTaxonomy < CamaleonRecord
|
|
3
3
|
include CamaleonCms::Metas
|
|
4
4
|
include CamaleonCms::CustomFieldsRead
|
|
5
|
+
include CamaleonCms::ContentShortcodeGate
|
|
5
6
|
|
|
6
7
|
extend CamaleonCms::NormalizeAttrs
|
|
7
8
|
|
|
@@ -72,6 +73,11 @@ module CamaleonCms
|
|
|
72
73
|
# validates
|
|
73
74
|
validates :name, :taxonomy, presence: true
|
|
74
75
|
validates_with CamaleonCms::UniqValidator
|
|
76
|
+
# A taxonomy/widget description is expanded by do_shortcode at render
|
|
77
|
+
# (TermTaxonomyDecorator#the_content, shortcode_templates/widget.html.erb); STI-covers every
|
|
78
|
+
# subclass (post types, categories, tags, widgets, sidebars). Gate authorship behind
|
|
79
|
+
# content_shortcodes -- benign descriptions pass without ever building an Ability.
|
|
80
|
+
gate_content_shortcodes :description
|
|
75
81
|
|
|
76
82
|
# relations
|
|
77
83
|
has_many :term_relationships, class_name: 'CamaleonCms::TermRelationship',
|
|
@@ -199,6 +199,21 @@ module CamaleonCms
|
|
|
199
199
|
'active content there. Site-wide, unlike the per-post-type "Allow unfiltered HTML in post ' \
|
|
200
200
|
'content" permission.'
|
|
201
201
|
).to_s
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: 'content_shortcodes',
|
|
205
|
+
label: I18n.t('camaleon_cms.admin.users.roles_values.content_shortcodes',
|
|
206
|
+
default: 'Allow shortcodes in content').to_s,
|
|
207
|
+
color: 'danger',
|
|
208
|
+
description: I18n.t(
|
|
209
|
+
'camaleon_cms.admin.users.tool_tip.content_shortcodes',
|
|
210
|
+
default: 'Permit users with this role to publish shortcodes in content that the CMS expands at ' \
|
|
211
|
+
'render: post content, custom-field values, taxonomy content and widget descriptions. A ' \
|
|
212
|
+
'shortcode makes theme/plugin code emit arbitrary HTML/JS on the page, which the ' \
|
|
213
|
+
'content scan cannot judge. Without it, a save carrying a registered shortcode is refused ' \
|
|
214
|
+
'rather than cleaned up. Site-wide, and an end-run around the per-post-type "Allow ' \
|
|
215
|
+
'unfiltered HTML in post content" permission.'
|
|
216
|
+
).to_s
|
|
202
217
|
}
|
|
203
218
|
]
|
|
204
219
|
}.freeze
|
|
@@ -27,8 +27,12 @@ module CamaleonCms
|
|
|
27
27
|
# cats: (Array) array of category ids assigned for this post, sample: [1, 2, 3]
|
|
28
28
|
def update_categories(cats = [])
|
|
29
29
|
rescue_extra_data
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
# uniq: duplicate ids (e.g. a crafted `categories[]=5&categories[]=5`) would otherwise create
|
|
31
|
+
# duplicate term_relationships rows, which duplicate the post in category/tag listings and
|
|
32
|
+
# inflate will_paginate's total_entries (there is no unique index on the join table)
|
|
33
|
+
cats = cats.to_i.uniq
|
|
34
|
+
# reuse the snapshot rescue_extra_data just took instead of plucking the same set again
|
|
35
|
+
old_categories = manage_categories? ? @cats_before : categories.pluck("#{CamaleonCms::TermTaxonomy.table_name}.id")
|
|
32
36
|
delete_categories = old_categories - cats
|
|
33
37
|
news_categories = cats - old_categories
|
|
34
38
|
term_relationships.where('term_taxonomy_id in (?)', delete_categories).destroy_all if delete_categories.present?
|
|
@@ -71,9 +75,6 @@ module CamaleonCms
|
|
|
71
75
|
# categories_id: (Array) array of category ids to unassign from this post, sample: [1,2,3]
|
|
72
76
|
def unassign_category(categories_id)
|
|
73
77
|
categories_id = [categories_id] if categories_id.is_a?(Integer)
|
|
74
|
-
# a loaded (stale) categories association would hide the removed ids from the
|
|
75
|
-
# counter refresh below — check_default_category loads it on every save
|
|
76
|
-
categories.reset
|
|
77
78
|
rescue_extra_data
|
|
78
79
|
term_relationships.where(term_taxonomy_id: categories_id).destroy_all
|
|
79
80
|
update_counts('categories')
|
|
@@ -87,14 +88,25 @@ module CamaleonCms
|
|
|
87
88
|
|
|
88
89
|
private
|
|
89
90
|
|
|
90
|
-
@cats_before = []
|
|
91
|
-
@tags_before = []
|
|
92
91
|
# save as a cache previous categories and tags assigned for this post
|
|
93
92
|
def rescue_extra_data
|
|
93
|
+
# Every writer mutates term_relationships directly, which does NOT invalidate an already-loaded
|
|
94
|
+
# categories/post_tags/term_relationships proxy -- and the_post preloads categories/post_tags via
|
|
95
|
+
# with_eager, so the proxy is routinely loaded before a write. Reset here, the single point every
|
|
96
|
+
# writer (and update_extra_data) calls first, so the snapshots below and every later
|
|
97
|
+
# pluck/empty?/count read the database rather than a stale in-memory target.
|
|
98
|
+
reset_term_proxies
|
|
94
99
|
@cats_before = categories.pluck(:id) if manage_categories?
|
|
95
100
|
@tags_before = post_tags.pluck(:id) if manage_tags?
|
|
96
101
|
end
|
|
97
102
|
|
|
103
|
+
# drop any loaded category/tag/term_relationship cache so a write starts from DB truth
|
|
104
|
+
def reset_term_proxies
|
|
105
|
+
categories.reset
|
|
106
|
+
post_tags.reset
|
|
107
|
+
term_relationships.reset
|
|
108
|
+
end
|
|
109
|
+
|
|
98
110
|
# Update the quantity of posts assigned for tags and categories assigned to this post
|
|
99
111
|
# if kind is empty, then this will update both: cats and tags
|
|
100
112
|
# kind: (string) tags | categories
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamaleonCms
|
|
4
|
+
# Save-time authorization gate for authored content that `do_shortcode` later expands (post
|
|
5
|
+
# content, custom-field values, taxonomy/widget descriptions). A registered shortcode makes
|
|
6
|
+
# theme/plugin code emit arbitrary HTML/JS at render, which the content scan cannot judge, so
|
|
7
|
+
# authorship is GATED behind the default-off `content_shortcodes` manager permission rather than
|
|
8
|
+
# filtered. The content is never escaped, stripped or rewritten -- a save carrying a shortcode is
|
|
9
|
+
# refused for an untrusted author, and stored verbatim for a trusted one.
|
|
10
|
+
#
|
|
11
|
+
# Conforms to `security-capability-gating` (fail-closed) and the content-shortcode-gating
|
|
12
|
+
# capability. A model gates an attribute with `gate_content_shortcodes :attr`; a newly added
|
|
13
|
+
# surface that `do_shortcode` expands MUST adopt this gate (asserted by the coverage spec).
|
|
14
|
+
module ContentShortcodeGate
|
|
15
|
+
extend ActiveSupport::Concern
|
|
16
|
+
|
|
17
|
+
class_methods do
|
|
18
|
+
# Gate the named attribute(s) on create/update. Each attribute is scanned in its RAW stored
|
|
19
|
+
# form -- which contains every locale of a translatable value at once, so a shortcode in any
|
|
20
|
+
# locale is caught -- and, if a registered shortcode is present and the actor is a
|
|
21
|
+
# non-administrator lacking `content_shortcodes`, the save is refused with a base error.
|
|
22
|
+
def gate_content_shortcodes(*attributes, on: %i[create update])
|
|
23
|
+
gated = attributes.map(&:to_sym)
|
|
24
|
+
validate(on: on) { cama_reject_unauthorized_shortcodes(gated) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def cama_reject_unauthorized_shortcodes(attributes)
|
|
31
|
+
return unless attributes.any? { |attr| cama_shortcode_attr_gated?(attr) }
|
|
32
|
+
return if cama_trusted_for_shortcodes?
|
|
33
|
+
|
|
34
|
+
errors.add(:base, cama_shortcode_rejection_message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# An attribute needs the gate only when it (a) changed on this save (or the record is new) -- so
|
|
38
|
+
# a title-only edit never re-gates pre-existing shortcode content, mirroring
|
|
39
|
+
# Post#reject_untrusted_dangerous_content -- and (b) actually contains a registered shortcode.
|
|
40
|
+
def cama_shortcode_attr_gated?(attr)
|
|
41
|
+
return false unless new_record? || cama_gate_attribute_changed?(attr)
|
|
42
|
+
|
|
43
|
+
value = self[attr]
|
|
44
|
+
value.present? && CamaleonCms::ShortcodeRegistry.content_has_shortcode?(value.to_s)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def cama_gate_attribute_changed?(attr)
|
|
48
|
+
respond_to?("#{attr}_changed?") ? public_send("#{attr}_changed?") : attribute_changed?(attr.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fail closed (the gate applies) without a request context or on any evaluation error, per
|
|
52
|
+
# security-capability-gating: a background job, rake task or console is untrusted. Administrators
|
|
53
|
+
# always pass; a non-administrator passes only while holding the `content_shortcodes` manager
|
|
54
|
+
# permission.
|
|
55
|
+
def cama_trusted_for_shortcodes?
|
|
56
|
+
user = CurrentRequest.user
|
|
57
|
+
site = CurrentRequest.site
|
|
58
|
+
return false if user.blank? || site.blank?
|
|
59
|
+
return true if user.admin?
|
|
60
|
+
|
|
61
|
+
CamaleonCms::Ability.new(user, site).can?(:manage, :content_shortcodes)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Only en.yml carries this key while the process locale follows the current admin/site language
|
|
67
|
+
# -- fall back to English rather than emit "translation missing".
|
|
68
|
+
def cama_shortcode_rejection_message
|
|
69
|
+
key = 'camaleon_cms.admin.shortcodes.message.rejected'
|
|
70
|
+
I18n.t(key, default: I18n.t(key, locale: :en))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -287,6 +287,9 @@ en:
|
|
|
287
287
|
new_site: 'New Site'
|
|
288
288
|
edit_site: 'Edit Site'
|
|
289
289
|
update: 'Update'
|
|
290
|
+
shortcodes:
|
|
291
|
+
message:
|
|
292
|
+
rejected: "This content contains a shortcode that your role is not allowed to publish. Remove the shortcode, or ask an administrator for the shortcode-content permission."
|
|
290
293
|
post:
|
|
291
294
|
add_tag: 'Add a Tag'
|
|
292
295
|
clone: 'Clone'
|
data/lib/camaleon_cms/engine.rb
CHANGED
|
@@ -14,15 +14,6 @@ require 'dartsass-sprockets'
|
|
|
14
14
|
require 'cama_contact_form'
|
|
15
15
|
require 'cama_meta_tag'
|
|
16
16
|
|
|
17
|
-
# `factory_bot_rails` gem can be added to test, development and/or other environments in the main app, containing the
|
|
18
|
-
# `camaleon_cms` gem.
|
|
19
|
-
# So, being unknown, whether it is defined or not, we're trying requiring the gem, using `rescue` in case of failures.
|
|
20
|
-
begin
|
|
21
|
-
require 'factory_bot_rails'
|
|
22
|
-
rescue LoadError
|
|
23
|
-
# do nothing
|
|
24
|
-
end
|
|
25
|
-
|
|
26
17
|
$camaleon_engine_dir = File.expand_path('../..', __dir__)
|
|
27
18
|
require File.join($camaleon_engine_dir, 'lib', 'plugin_routes').to_s
|
|
28
19
|
require File.join($camaleon_engine_dir, 'lib', 'camaleon_cms', 'setup_token').to_s
|
|
@@ -128,10 +119,5 @@ module CamaleonCms
|
|
|
128
119
|
config.after_initialize do
|
|
129
120
|
PluginRoutes.draw_routes_eagerly
|
|
130
121
|
end
|
|
131
|
-
|
|
132
|
-
if defined?(FactoryBotRails)
|
|
133
|
-
config.factory_bot.definition_file_paths +=
|
|
134
|
-
[File.expand_path('../../spec/factories', __dir__)]
|
|
135
|
-
end
|
|
136
122
|
end
|
|
137
123
|
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamaleonCms
|
|
4
|
+
# Process-wide, boot-time registry of shortcode NAMES. It exists so that save-time code can detect
|
|
5
|
+
# shortcodes without a frontend request: the per-request `CurrentRequest.shortcodes` list is
|
|
6
|
+
# populated by the active frontend theme's `front_before_load` hook and is therefore empty in the
|
|
7
|
+
# admin save path, so the save-time gate consults this canonical set instead.
|
|
8
|
+
#
|
|
9
|
+
# Providers declare their shortcode names through the `register` DSL at boot. Declarations run with
|
|
10
|
+
# NO request/site context, so a declaration MUST NOT touch `current_site`/`request` -- only the
|
|
11
|
+
# names move here; the render-time handler stays in `shortcode_add`. Core declares its own bundled
|
|
12
|
+
# names (see CORE_SHORTCODES); a theme/plugin gem declares its names from its own boot (an
|
|
13
|
+
# initializer or engine), e.g. `CamaleonCms::ShortcodeRegistry.register('redirect')`.
|
|
14
|
+
#
|
|
15
|
+
# Detection mirrors the engine's `ShortCodeHelper#cama_reg_shortcode` regex shape, sourced from
|
|
16
|
+
# this registry rather than `CurrentRequest`, so a name is a match only when followed by a space +
|
|
17
|
+
# attributes or an immediate "]" -- bracketed prose that is not a registered name is never treated
|
|
18
|
+
# as a shortcode.
|
|
19
|
+
#
|
|
20
|
+
# Fail-closed (design D4): an "unavailable" registry -- boot never completed registration -- treats
|
|
21
|
+
# any non-blank content as gated, so a non-administrator is refused rather than let through on an
|
|
22
|
+
# empty set. A legitimately empty registry (available, but no provider declared any name) is a
|
|
23
|
+
# distinct, non-error state that gates nothing.
|
|
24
|
+
class ShortcodeRegistry
|
|
25
|
+
# Core-bundled shortcode names, declared through the DSL at boot. Mirrors the shortcodes that
|
|
26
|
+
# `ShortCodeHelper#shortcodes_init` adds per request; the two are kept in sync by hand (unifying
|
|
27
|
+
# the boot DSL with the render-time `shortcode_add` registration is a deliberate non-goal -- see
|
|
28
|
+
# the gate-content-shortcodes design).
|
|
29
|
+
CORE_SHORTCODES = %w[widget load_libraries asset data].freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Boot-time DSL: declare one or more shortcode names. Request-independent, idempotent, and
|
|
33
|
+
# order-independent. Blank names are ignored.
|
|
34
|
+
def register(*names)
|
|
35
|
+
names.flatten.each do |name|
|
|
36
|
+
name = name.to_s.strip
|
|
37
|
+
registered_names << name unless name.empty?
|
|
38
|
+
end
|
|
39
|
+
@regexp = nil
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Mark the registry as successfully built at boot. Called once registration has completed; if
|
|
44
|
+
# boot raises before this point the registry stays unavailable and detection fails closed.
|
|
45
|
+
def mark_available!
|
|
46
|
+
@available = true
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def available?
|
|
51
|
+
@available == true
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Frozen snapshot of the registered names.
|
|
55
|
+
def names
|
|
56
|
+
registered_names.dup.freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Whether `content` contains a registered shortcode. Fails closed when the registry is
|
|
60
|
+
# unavailable (any non-blank content is treated as gated); a legitimately empty registry
|
|
61
|
+
# matches nothing.
|
|
62
|
+
def content_has_shortcode?(content)
|
|
63
|
+
content = content.to_s
|
|
64
|
+
return false if content.empty?
|
|
65
|
+
return true unless available?
|
|
66
|
+
|
|
67
|
+
matcher = regexp
|
|
68
|
+
return false if matcher.nil?
|
|
69
|
+
|
|
70
|
+
matcher.match?(content)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Test-only: reset to the unavailable/empty baseline.
|
|
74
|
+
def reset!
|
|
75
|
+
@registered_names = nil
|
|
76
|
+
@regexp = nil
|
|
77
|
+
@available = false
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def registered_names
|
|
84
|
+
@registered_names ||= Set.new
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mirrors ShortCodeHelper#cama_reg_shortcode: "(\\[(a|b)((\s)((?!\\]).)*|)\\])". In that source
|
|
88
|
+
# the "\s" is a literal space (a double-quoted-string escape, not the regex whitespace class),
|
|
89
|
+
# so a code matches only when followed by a literal space + attributes, or an immediate "]".
|
|
90
|
+
# Names are regex-escaped and ordered longest-first so a name that is a prefix of another
|
|
91
|
+
# (e.g. media / media_gallery) still matches its own token.
|
|
92
|
+
def regexp
|
|
93
|
+
return nil if registered_names.empty?
|
|
94
|
+
|
|
95
|
+
@regexp ||= begin
|
|
96
|
+
codes = registered_names.sort_by { |name| -name.length }.map { |name| Regexp.escape(name) }.join('|')
|
|
97
|
+
Regexp.new("(\\[(#{codes})((\s)((?!\\]).)*|)\\])")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/camaleon_cms/version.rb
CHANGED
data/lib/camaleon_cms.rb
CHANGED
|
@@ -11,6 +11,18 @@ require 'camaleon_cms/uploader_image_processing'
|
|
|
11
11
|
require 'camaleon_cms/uploader_support'
|
|
12
12
|
require 'camaleon_cms/captcha_image_generation'
|
|
13
13
|
require 'camaleon_cms/task_reporter'
|
|
14
|
+
require 'camaleon_cms/shortcode_registry'
|
|
14
15
|
|
|
15
16
|
module CamaleonCms
|
|
16
17
|
end
|
|
18
|
+
|
|
19
|
+
# Declare core's bundled shortcode names through the boot DSL, then mark the registry available. This
|
|
20
|
+
# runs at require time (before any request), so the save-time shortcode gate can detect shortcodes
|
|
21
|
+
# with no frontend request. If this ever raises the registry stays unavailable and the gate fails
|
|
22
|
+
# closed (non-administrators are refused) rather than fail open on an empty set.
|
|
23
|
+
begin
|
|
24
|
+
CamaleonCms::ShortcodeRegistry.register(*CamaleonCms::ShortcodeRegistry::CORE_SHORTCODES)
|
|
25
|
+
CamaleonCms::ShortcodeRegistry.mark_available!
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
warn("CamaleonCms::ShortcodeRegistry boot registration failed (gate fails closed): #{e.message}")
|
|
28
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: camaleon_cms
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.9.
|
|
4
|
+
version: 2.9.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Owen Peredo Diaz
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: 0.1.
|
|
60
|
+
version: 0.1.13
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - "~>"
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: 0.1.
|
|
67
|
+
version: 0.1.13
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: cama_meta_tag
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -956,6 +956,7 @@ files:
|
|
|
956
956
|
- app/models/camaleon_record.rb
|
|
957
957
|
- app/models/concerns/camaleon_cms/categories_tags_for_posts.rb
|
|
958
958
|
- app/models/concerns/camaleon_cms/common_relationships.rb
|
|
959
|
+
- app/models/concerns/camaleon_cms/content_shortcode_gate.rb
|
|
959
960
|
- app/models/concerns/camaleon_cms/custom_fields_read.rb
|
|
960
961
|
- app/models/concerns/camaleon_cms/metas.rb
|
|
961
962
|
- app/models/concerns/camaleon_cms/normalize_attrs.rb
|
|
@@ -1246,6 +1247,7 @@ files:
|
|
|
1246
1247
|
- lib/camaleon_cms/engine.rb
|
|
1247
1248
|
- lib/camaleon_cms/media_security_headers.rb
|
|
1248
1249
|
- lib/camaleon_cms/setup_token.rb
|
|
1250
|
+
- lib/camaleon_cms/shortcode_registry.rb
|
|
1249
1251
|
- lib/camaleon_cms/svg_content_checker.rb
|
|
1250
1252
|
- lib/camaleon_cms/task_reporter.rb
|
|
1251
1253
|
- lib/camaleon_cms/unsafe_markup.rb
|