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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0997f6bbf586f6f6c5618c97814c0a291fa98bcb54adbd7736a0449f60edc33
4
- data.tar.gz: e73b0849f7b21ffc7bd7510d71c328706b022cfefaf738194836b9e332e4f062
3
+ metadata.gz: c6940e13676d23d32f7eddfc3cabe9cc6639914fc79f5d53497040dc2987fbfb
4
+ data.tar.gz: 8079bcd83ef28f77f628ad33fbc0bc89f17e2f6ae130868f3d49f112b4cfd1e3
5
5
  SHA512:
6
- metadata.gz: 74c4843293dd8fbfa585cc784834866be63a7e330b7c9d21ecbbeb9a9e6753168d5c2502705db0fdecee9f89b2ff3b3d102e699c41384fb398f6332be98464cf
7
- data.tar.gz: f152511e4e5022003ea2003a275cd677912cce73ac55bb9dbc8528094b6ddcf529b445627ba3750c4bbcebf5412b7107049c43daa0f3e3f5390539d2ba6c4eff
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
- if current_site.get_option('refresh_cache') # clear cache every restart server unless option checked in settings
7
- front_cache_clean unless current_site.get_meta('front_cache_elements')[:preserve_cache_on_restart]
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
- @caches = current_site.get_meta('front_cache_elements')
16
- if flash.keys.blank? && front_cache_exist?(cache_key) # recover cache item
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: front_cache_get(cache_key).gsub('{{form_authenticity_token}}', form_authenticity_token) }
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
- if @caches[:paths].include?(request.original_url) || @caches[:paths].include?(request.path_info) ||
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
- if (post = current_site.the_posts.find_by_slug(params[:slug])) # rubocop:disable Rails/DynamicFindBy
35
- post = post.decorate
36
- # Never cache non-public posts. A password-protected post is unlocked per session (visibility_post
37
- # audit M2), but the page cache is keyed on the URL alone, so caching an unlocked render would
38
- # serve the protected body to visitors who never entered the password. Private posts are already
39
- # excluded; password posts must be too.
40
- if post.can_visit? && !%w[private password].include?(post.visibility)
41
- if (@caches[:skip_posts] || []).include?(post.id.to_s)
42
- @_plugin_do_cache = false
43
- elsif (@caches[:post_types] || []).include?(post.post_type_id.to_s) ||
44
- (@caches[:posts] || []).include?(post.id.to_s)
45
- @_plugin_do_cache = true
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
- # save as cache all post requests
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
- # clear all frontend cache items
131
+ # invalidate all cached pages of the current site
112
132
  def front_cache_clean
113
- @caches = current_site.get_meta('front_cache_elements')
114
- if @caches[:invalidate_only]
115
- @caches[:cache_counter] += 1
116
- else
117
- Rails.cache.clear
118
- @caches[:cache_counter] = 0
119
- end
120
- current_site.set_meta('front_cache_elements', @caches)
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
- !Rails.cache.read(front_cache_plugin_get_path(key)).nil?
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 physical path of cache directory
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
- "pages/#{@caches[:cache_counter]}/#{current_site.id}"
189
+ "cama_front_cache/#{current_site.id}"
142
190
  else
143
- "pages/#{@caches[:cache_counter]}/#{current_site.id}/#{key}"
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).eager_load(:metas)
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).eager_load(:metas)
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).eager_load(:metas)
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
- current_site.the_posts.find_by_slug(post_or_slug_or_id) # rubocop:disable Rails/DynamicFindBy
207
+ visible_posts.find_by_slug(post_or_slug_or_id) # rubocop:disable Rails/DynamicFindBy
206
208
  when Integer # id
207
- current_site.the_posts.where(id: post_or_slug_or_id).first
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
- (parent.presence || the_post_type).the_posts.where(
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
- (parent.presence || the_post_type).the_posts
263
- .where(post_order_predicate_lt(object.post_order, object.created_at))
264
- .where.not(id: object.id).reorder(post_order: :asc, created_at: :asc).last.try(:decorate)
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
- the_posts.find(slug_or_id)
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
- the_posts.find(slug_or_id)
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 = the_posts.find_by_slug(slug_or_id) if slug_or_id.is_a?(String) # rubocop:disable Rails/DynamicFindBy
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
- def verify_front_visibility(active_record)
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
- cats = cats.to_i
31
- old_categories = categories.pluck("#{CamaleonCms::TermTaxonomy.table_name}.id")
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'
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CamaleonCms
2
- VERSION = '2.9.3'.freeze
2
+ VERSION = '2.9.4'.freeze
3
3
  end
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.3
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.12
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.12
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