alchemy_cms 8.3.11 → 8.4.1

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.
Files changed (368) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +58 -10
  4. data/app/assets/builds/alchemy/admin/page-select.css +1 -1
  5. data/app/assets/builds/alchemy/admin/print.css +1 -1
  6. data/app/assets/builds/alchemy/admin/remote-select.css +1 -0
  7. data/app/assets/builds/alchemy/admin/tags-autocomplete.css +1 -0
  8. data/app/assets/builds/alchemy/admin.css +4 -4
  9. data/app/assets/builds/alchemy/alchemy_admin.min.js +1 -1
  10. data/app/assets/builds/alchemy/alchemy_admin.min.js.map +1 -1
  11. data/app/assets/builds/alchemy/dark-theme.css +1 -1
  12. data/app/assets/builds/alchemy/light-theme.css +1 -1
  13. data/app/assets/builds/alchemy/preview.min.js +1 -1
  14. data/app/assets/builds/alchemy/theme.css +1 -1
  15. data/app/assets/builds/alchemy/welcome.css +1 -1
  16. data/app/assets/builds/tinymce/skins/content/alchemy/content.min.css +1 -1
  17. data/app/assets/builds/tinymce/skins/content/alchemy-dark/content.min.css +1 -1
  18. data/app/assets/builds/tinymce/skins/ui/alchemy/skin.min.css +1 -1
  19. data/app/assets/builds/tinymce/skins/ui/alchemy-dark/skin.min.css +1 -1
  20. data/app/assets/images/alchemy/icons-sprite.svg +1 -1
  21. data/app/components/alchemy/admin/dashboard/widgets/greeting.html.erb +1 -1
  22. data/app/components/alchemy/admin/dashboard/widgets/news_reader.html.erb +38 -0
  23. data/app/components/alchemy/admin/dashboard/widgets/news_reader.rb +54 -0
  24. data/app/components/alchemy/admin/dashboard/widgets/online_users.html.erb +1 -1
  25. data/app/components/alchemy/admin/dashboard/widgets/recent_pages.html.erb +1 -1
  26. data/app/components/alchemy/admin/dashboard/widgets/system_info.html.erb +11 -3
  27. data/app/components/alchemy/admin/dashboard/widgets/system_info.rb +6 -1
  28. data/app/components/alchemy/admin/element_editor.html.erb +1 -1
  29. data/app/components/alchemy/admin/element_select.rb +30 -20
  30. data/app/components/alchemy/admin/element_status_indicators.html.erb +16 -0
  31. data/app/components/alchemy/admin/element_status_indicators.rb +31 -0
  32. data/app/components/alchemy/admin/link_dialog/external_tab.rb +2 -1
  33. data/app/components/alchemy/admin/main_navigation/entry.rb +61 -0
  34. data/app/components/alchemy/admin/main_navigation/menu.rb +21 -0
  35. data/app/components/alchemy/admin/main_navigation/sub_navigation.rb +31 -0
  36. data/app/components/alchemy/admin/page_node.html.erb +1 -1
  37. data/app/components/alchemy/admin/page_permitted_roles_select.html.erb +11 -0
  38. data/app/components/alchemy/admin/page_permitted_roles_select.rb +35 -0
  39. data/app/components/alchemy/admin/page_select.rb +18 -8
  40. data/app/components/alchemy/admin/preview_time_select.rb +1 -1
  41. data/app/components/alchemy/admin/toolbar_button.rb +43 -20
  42. data/app/components/alchemy/admin/uploader_button.rb +66 -0
  43. data/app/components/alchemy/ingredients/base_editor.rb +10 -1
  44. data/app/components/alchemy/ingredients/boolean_editor.rb +1 -0
  45. data/app/components/alchemy/ingredients/color_editor.rb +6 -3
  46. data/app/components/alchemy/ingredients/datetime_view.rb +1 -1
  47. data/app/components/alchemy/ingredients/picture_view.rb +2 -4
  48. data/app/components/alchemy/menubar.html.erb +157 -0
  49. data/app/components/alchemy/menubar.rb +15 -0
  50. data/app/controllers/alchemy/admin/attachments_controller.rb +12 -9
  51. data/app/controllers/alchemy/admin/base_controller.rb +34 -21
  52. data/app/controllers/alchemy/admin/dashboard_controller.rb +4 -0
  53. data/app/controllers/alchemy/admin/elements_controller.rb +4 -23
  54. data/app/controllers/alchemy/admin/languages_controller.rb +1 -1
  55. data/app/controllers/alchemy/admin/nodes_controller.rb +1 -1
  56. data/app/controllers/alchemy/admin/pages_controller.rb +1 -1
  57. data/app/controllers/alchemy/admin/pictures_controller.rb +6 -3
  58. data/app/controllers/alchemy/admin/resources_controller.rb +5 -5
  59. data/app/controllers/alchemy/admin/sites_controller.rb +1 -1
  60. data/app/controllers/alchemy/base_controller.rb +24 -16
  61. data/app/controllers/alchemy/pages_controller.rb +29 -8
  62. data/app/controllers/alchemy/sitemap_controller.rb +58 -0
  63. data/app/controllers/concerns/alchemy/admin/csp_protection.rb +43 -0
  64. data/app/controllers/concerns/alchemy/admin/current_language.rb +1 -1
  65. data/app/controllers/concerns/alchemy/admin/resource_filter.rb +3 -1
  66. data/{lib → app/controllers/concerns}/alchemy/controller_actions.rb +13 -0
  67. data/app/controllers/concerns/alchemy/legacy_page_redirects.rb +4 -4
  68. data/app/helpers/alchemy/admin/base_helper.rb +4 -1
  69. data/app/helpers/alchemy/admin/form_helper.rb +0 -2
  70. data/app/helpers/alchemy/admin/navigation_helper.rb +10 -181
  71. data/app/helpers/alchemy/elements_block_helper.rb +14 -0
  72. data/app/helpers/alchemy/resources_helper.rb +15 -1
  73. data/app/javascript/alchemy_admin/components/attachment_select.js +11 -20
  74. data/app/javascript/alchemy_admin/components/auto_submit.js +2 -4
  75. data/app/javascript/alchemy_admin/components/button.js +0 -6
  76. data/app/javascript/alchemy_admin/components/clear_schedule_button.js +21 -0
  77. data/app/javascript/alchemy_admin/components/color_select.js +50 -45
  78. data/app/javascript/alchemy_admin/components/conditional_field.js +50 -0
  79. data/app/javascript/alchemy_admin/components/dirty_guard.js +77 -0
  80. data/app/javascript/alchemy_admin/components/dom_id_select.js +17 -10
  81. data/app/javascript/alchemy_admin/components/element_editor/delete_element_button.js +7 -2
  82. data/app/javascript/alchemy_admin/components/element_editor.js +29 -56
  83. data/app/javascript/alchemy_admin/components/element_select.js +26 -66
  84. data/app/javascript/alchemy_admin/components/icon.js +9 -4
  85. data/app/javascript/alchemy_admin/{image_cropper.js → components/image_cropper.js} +39 -53
  86. data/app/javascript/alchemy_admin/components/image_overlay_link.js +14 -0
  87. data/app/javascript/alchemy_admin/components/index.js +12 -1
  88. data/app/javascript/alchemy_admin/components/main_navi_entry.js +75 -0
  89. data/app/javascript/alchemy_admin/components/menu_node.js +73 -0
  90. data/app/javascript/alchemy_admin/components/news_reader.js +109 -0
  91. data/app/javascript/alchemy_admin/components/node_folder.js +36 -0
  92. data/app/javascript/alchemy_admin/components/node_form.js +34 -0
  93. data/app/javascript/alchemy_admin/components/node_select.js +25 -30
  94. data/app/javascript/alchemy_admin/components/node_tree.js +56 -0
  95. data/app/javascript/alchemy_admin/components/page_node.js +2 -1
  96. data/app/javascript/alchemy_admin/components/page_select.js +16 -34
  97. data/app/javascript/alchemy_admin/components/picture_archive.js +92 -0
  98. data/app/javascript/alchemy_admin/components/picture_editor.js +2 -1
  99. data/app/javascript/alchemy_admin/components/preview_window.js +11 -3
  100. data/app/javascript/alchemy_admin/components/remote_select.js +461 -81
  101. data/app/javascript/alchemy_admin/components/select.js +26 -88
  102. data/app/javascript/alchemy_admin/components/sitemap.js +2 -1
  103. data/app/javascript/alchemy_admin/components/sortable_elements.js +2 -1
  104. data/app/javascript/alchemy_admin/components/tags_autocomplete.js +63 -48
  105. data/app/javascript/alchemy_admin/components/tinymce.js +2 -1
  106. data/app/javascript/alchemy_admin/components/uploader/file_upload.js +3 -2
  107. data/app/javascript/alchemy_admin/components/uploader.js +19 -3
  108. data/app/javascript/alchemy_admin/confirm_dialog.js +7 -1
  109. data/app/javascript/alchemy_admin/dialog.js +182 -110
  110. data/app/javascript/alchemy_admin/hotkeys.js +0 -5
  111. data/app/javascript/alchemy_admin/i18n.js +3 -9
  112. data/app/javascript/alchemy_admin/image_overlay.js +58 -42
  113. data/app/javascript/alchemy_admin/initializer.js +2 -2
  114. data/app/javascript/alchemy_admin/link_dialog.js +15 -2
  115. data/app/javascript/alchemy_admin/routes.js +18 -0
  116. data/app/javascript/alchemy_admin/turbo_stream_actions.js +88 -0
  117. data/app/javascript/alchemy_admin/utils/ajax.js +14 -3
  118. data/app/javascript/alchemy_admin/utils/events.js +5 -0
  119. data/app/javascript/alchemy_admin/utils/json_script.js +12 -0
  120. data/app/javascript/alchemy_admin/utils/tom_select.js +171 -0
  121. data/app/javascript/alchemy_admin.js +2 -19
  122. data/app/javascript/preview.js +131 -113
  123. data/app/jobs/alchemy/invalidate_elements_cache_job.rb +1 -1
  124. data/app/models/alchemy/admin/content_security_policy.rb +143 -0
  125. data/app/models/alchemy/admin/filters/base.rb +7 -0
  126. data/app/models/alchemy/admin/filters/datepicker.rb +1 -1
  127. data/app/models/alchemy/admin/filters/select.rb +20 -7
  128. data/app/models/alchemy/attachment.rb +14 -7
  129. data/app/models/alchemy/cache_control.rb +104 -0
  130. data/app/models/alchemy/current.rb +1 -1
  131. data/app/models/alchemy/eager_loading.rb +0 -2
  132. data/app/models/alchemy/element.rb +23 -8
  133. data/app/models/alchemy/element_definition.rb +1 -0
  134. data/app/models/alchemy/elements_repository.rb +11 -0
  135. data/app/models/alchemy/folded_page.rb +5 -1
  136. data/app/models/alchemy/ingredient.rb +26 -0
  137. data/app/models/alchemy/ingredients/datetime.rb +1 -1
  138. data/app/models/alchemy/ingredients/link.rb +2 -0
  139. data/app/models/alchemy/ingredients/picture.rb +3 -0
  140. data/app/models/alchemy/ingredients/text.rb +2 -0
  141. data/app/models/alchemy/language.rb +19 -7
  142. data/app/models/alchemy/legacy_page_url.rb +6 -1
  143. data/app/models/alchemy/node.rb +51 -0
  144. data/app/models/alchemy/page/page_natures.rb +41 -8
  145. data/app/models/alchemy/page/page_scopes.rb +23 -0
  146. data/app/models/alchemy/page/sitemap_cache_key.rb +56 -0
  147. data/app/models/alchemy/page.rb +88 -32
  148. data/app/models/alchemy/page_definition.rb +1 -1
  149. data/app/models/alchemy/page_mutex.rb +16 -0
  150. data/app/models/alchemy/page_version.rb +24 -1
  151. data/app/models/alchemy/permissions.rb +6 -5
  152. data/app/models/alchemy/picture.rb +15 -7
  153. data/app/models/alchemy/picture_description.rb +22 -0
  154. data/app/models/alchemy/picture_thumb.rb +18 -0
  155. data/app/models/alchemy/publishable/timestamp_resolver.rb +70 -0
  156. data/app/models/alchemy/resource.rb +1 -1
  157. data/app/models/alchemy/safe_url_validator.rb +34 -0
  158. data/app/models/alchemy/site.rb +8 -3
  159. data/app/models/alchemy/storage_adapter/active_storage/preprocessor.rb +7 -3
  160. data/app/models/alchemy/storage_adapter/active_storage.rb +4 -1
  161. data/app/models/alchemy/tag.rb +10 -4
  162. data/{lib → app/models/concerns}/alchemy/filetypes.rb +5 -0
  163. data/app/models/concerns/alchemy/picture_thumbnails.rb +61 -6
  164. data/app/models/concerns/alchemy/publishable.rb +14 -27
  165. data/app/models/concerns/alchemy/taggable.rb +92 -0
  166. data/app/models/concerns/alchemy/user_methods.rb +95 -0
  167. data/app/services/alchemy/duplicate_element.rb +5 -2
  168. data/app/services/alchemy/element_preloader.rb +1 -2
  169. data/app/stylesheets/alchemy/_custom-properties.scss +1 -0
  170. data/app/stylesheets/alchemy/_extends.scss +0 -8
  171. data/app/stylesheets/alchemy/_themes.scss +10 -9
  172. data/app/stylesheets/alchemy/admin/_empty-state.scss +29 -0
  173. data/app/stylesheets/alchemy/admin/_help.scss +10 -0
  174. data/app/stylesheets/alchemy/admin/_tom-select.scss +104 -15
  175. data/app/stylesheets/alchemy/admin/archive.scss +56 -11
  176. data/app/stylesheets/alchemy/admin/base.scss +4 -5
  177. data/app/stylesheets/alchemy/admin/buttons.scss +12 -1
  178. data/app/stylesheets/alchemy/admin/dashboard.scss +150 -20
  179. data/app/stylesheets/alchemy/admin/dialogs.scss +27 -36
  180. data/app/stylesheets/alchemy/admin/element-select.scss +1 -1
  181. data/app/stylesheets/alchemy/admin/elements.scss +8 -24
  182. data/app/stylesheets/alchemy/admin/flash.scss +2 -1
  183. data/app/stylesheets/alchemy/admin/forms.scss +60 -24
  184. data/app/stylesheets/alchemy/admin/frame.scss +40 -4
  185. data/app/stylesheets/alchemy/admin/image_library.scss +9 -12
  186. data/app/stylesheets/alchemy/admin/navigation.scss +17 -14
  187. data/app/stylesheets/alchemy/admin/node-select.scss +13 -37
  188. data/app/stylesheets/alchemy/admin/nodes.scss +1 -1
  189. data/app/stylesheets/alchemy/admin/page-select.scss +6 -97
  190. data/app/stylesheets/alchemy/admin/print.scss +0 -4
  191. data/app/stylesheets/alchemy/admin/remote-select.scss +170 -0
  192. data/app/stylesheets/alchemy/admin/search.scss +2 -3
  193. data/app/stylesheets/alchemy/admin/selects.scss +1 -294
  194. data/app/stylesheets/alchemy/admin/sitemap.scss +2 -1
  195. data/app/stylesheets/alchemy/admin/tables.scss +7 -1
  196. data/app/stylesheets/alchemy/admin/tags-autocomplete.scss +17 -0
  197. data/app/stylesheets/alchemy/admin/tags.scss +0 -13
  198. data/app/stylesheets/alchemy/admin/toolbar.scss +9 -6
  199. data/app/stylesheets/alchemy/admin/upload.scss +61 -1
  200. data/app/stylesheets/alchemy/admin.scss +4 -1
  201. data/app/types/alchemy/cache_control_type.rb +84 -0
  202. data/app/views/alchemy/_edit_mode.html.erb +1 -1
  203. data/app/views/alchemy/_menubar.html.erb +2 -154
  204. data/app/views/alchemy/_preview_mode_code.html.erb +0 -3
  205. data/app/views/alchemy/admin/_left_menu.html.erb +18 -5
  206. data/app/views/alchemy/admin/_top_menu.html.erb +1 -1
  207. data/app/views/alchemy/admin/attachments/_archive_overlay.html.erb +5 -4
  208. data/app/views/alchemy/admin/attachments/_file_to_assign.html.erb +3 -3
  209. data/app/views/alchemy/admin/attachments/_files_list.html.erb +71 -83
  210. data/app/views/alchemy/admin/attachments/assign.turbo_stream.erb +8 -0
  211. data/app/views/alchemy/admin/attachments/index.html.erb +32 -7
  212. data/app/views/alchemy/admin/clipboard/insert.turbo_stream.erb +1 -1
  213. data/app/views/alchemy/admin/crop.html.erb +20 -20
  214. data/app/views/alchemy/admin/dashboard/_footer.html.erb +0 -3
  215. data/app/views/alchemy/admin/dashboard/_top.html.erb +1 -0
  216. data/app/views/alchemy/admin/dashboard/help.html.erb +84 -57
  217. data/app/views/alchemy/admin/dashboard/license.html.erb +6 -0
  218. data/app/views/alchemy/admin/elements/_footer.html.erb +0 -1
  219. data/app/views/alchemy/admin/elements/_form.html.erb +1 -1
  220. data/app/views/alchemy/admin/elements/_header.html.erb +2 -19
  221. data/app/views/alchemy/admin/elements/_preview_text_quote.html.erb +3 -0
  222. data/app/views/alchemy/admin/elements/_schedule.html.erb +7 -7
  223. data/app/views/alchemy/admin/elements/_toolbar.html.erb +1 -1
  224. data/app/views/alchemy/admin/elements/create.turbo_stream.erb +1 -1
  225. data/app/views/alchemy/admin/elements/index.html.erb +1 -1
  226. data/app/views/alchemy/admin/elements/publish.turbo_stream.erb +2 -2
  227. data/app/views/alchemy/admin/elements/update.turbo_stream.erb +31 -0
  228. data/app/views/alchemy/admin/ingredients/update.turbo_stream.erb +6 -3
  229. data/app/views/alchemy/admin/languages/_table.html.erb +0 -2
  230. data/app/views/alchemy/admin/languages/index.html.erb +25 -29
  231. data/app/views/alchemy/admin/layoutpages/_layoutpage.html.erb +1 -3
  232. data/app/views/alchemy/admin/layoutpages/edit.html.erb +1 -1
  233. data/app/views/alchemy/admin/layoutpages/index.html.erb +26 -7
  234. data/app/views/alchemy/admin/nodes/_form.html.erb +3 -21
  235. data/app/views/alchemy/admin/nodes/_node.html.erb +93 -88
  236. data/app/views/alchemy/admin/nodes/index.html.erb +15 -23
  237. data/app/views/alchemy/admin/pages/_create_language_form.html.erb +2 -6
  238. data/app/views/alchemy/admin/pages/_form.html.erb +2 -1
  239. data/app/views/alchemy/admin/pages/_publication_fields.html.erb +1 -1
  240. data/app/views/alchemy/admin/pages/_table.html.erb +2 -4
  241. data/app/views/alchemy/admin/pages/_toolbar.html.erb +2 -1
  242. data/app/views/alchemy/admin/pages/edit.html.erb +10 -21
  243. data/app/views/alchemy/admin/pages/flush.turbo_stream.erb +1 -1
  244. data/app/views/alchemy/admin/pages/index.html.erb +26 -31
  245. data/app/views/alchemy/admin/pages/info.html.erb +2 -2
  246. data/app/views/alchemy/admin/pages/update.turbo_stream.erb +2 -2
  247. data/app/views/alchemy/admin/partials/_language_tree_select.html.erb +1 -1
  248. data/app/views/alchemy/admin/partials/_routes.html.erb +24 -48
  249. data/app/views/alchemy/admin/pictures/_archive.html.erb +3 -17
  250. data/app/views/alchemy/admin/pictures/_archive_overlay.html.erb +1 -1
  251. data/app/views/alchemy/admin/pictures/_filter_and_size_bar.html.erb +5 -3
  252. data/app/views/alchemy/admin/pictures/_form.html.erb +0 -1
  253. data/app/views/alchemy/admin/pictures/_infos.html.erb +5 -1
  254. data/app/views/alchemy/admin/pictures/_picture.html.erb +1 -1
  255. data/app/views/alchemy/admin/pictures/_picture_to_assign.html.erb +3 -4
  256. data/app/views/alchemy/admin/pictures/assign.turbo_stream.erb +7 -0
  257. data/app/views/alchemy/admin/pictures/index.html.erb +38 -30
  258. data/app/views/alchemy/admin/pictures/show.html.erb +41 -41
  259. data/app/views/alchemy/admin/resources/_empty_state.html.erb +27 -0
  260. data/app/views/alchemy/admin/resources/_pagination.html.erb +1 -1
  261. data/app/views/alchemy/admin/resources/_per_page_select.html.erb +1 -1
  262. data/app/views/alchemy/admin/resources/_resource_table.html.erb +0 -2
  263. data/app/views/alchemy/admin/resources/_resource_usage_info.html.erb +3 -2
  264. data/app/views/alchemy/admin/resources/index.html.erb +32 -13
  265. data/app/views/alchemy/admin/sites/index.html.erb +24 -22
  266. data/app/views/alchemy/admin/styleguide/index.html.erb +35 -12
  267. data/app/views/alchemy/admin/tags/index.html.erb +27 -25
  268. data/app/views/alchemy/admin/tinymce/_setup.html.erb +6 -8
  269. data/app/views/alchemy/admin/translations/_en.js +3 -0
  270. data/app/views/alchemy/admin/uploader/_button.html.erb +11 -17
  271. data/app/views/alchemy/admin/uploader/_setup.html.erb +3 -3
  272. data/app/views/alchemy/pages/sitemap.xml.erb +2 -0
  273. data/app/views/kaminari/alchemy/_first_page.html.erb +1 -2
  274. data/app/views/kaminari/alchemy/_gap.html.erb +0 -1
  275. data/app/views/kaminari/alchemy/_last_page.html.erb +1 -2
  276. data/app/views/kaminari/alchemy/_next_page.html.erb +1 -2
  277. data/app/views/kaminari/alchemy/_page.html.erb +1 -2
  278. data/app/views/kaminari/alchemy/_paginator.html.erb +0 -1
  279. data/app/views/kaminari/alchemy/_prev_page.html.erb +1 -2
  280. data/app/views/layouts/alchemy/admin/dialog.html.erb +3 -0
  281. data/app/views/layouts/alchemy/admin.html.erb +5 -6
  282. data/config/brakeman.ignore +23 -1
  283. data/config/importmap.rb +10 -8
  284. data/config/initializers/dragonfly.rb +21 -17
  285. data/config/locales/alchemy.en.yml +52 -11
  286. data/config/routes.rb +3 -3
  287. data/db/migrate/20260727120000_add_permitted_roles_to_alchemy_pages.rb +23 -0
  288. data/db/migrate/20260820120000_add_cached_tag_list_to_taggables.rb +57 -0
  289. data/lib/alchemy/configuration/number_option.rb +13 -0
  290. data/lib/alchemy/configuration/proc_option.rb +13 -0
  291. data/lib/alchemy/configuration.rb +2 -0
  292. data/lib/alchemy/configurations/admin_components.rb +30 -0
  293. data/lib/alchemy/configurations/format_matchers.rb +7 -1
  294. data/lib/alchemy/configurations/main.rb +103 -4
  295. data/lib/alchemy/configurations/menu.rb +91 -0
  296. data/lib/alchemy/configurations/menu_item.rb +123 -0
  297. data/lib/alchemy/configurations/sitemap.rb +1 -0
  298. data/lib/alchemy/configurations/uploader.rb +15 -1
  299. data/lib/alchemy/engine.rb +0 -6
  300. data/lib/alchemy/i18n.rb +26 -0
  301. data/lib/alchemy/modules.rb +52 -50
  302. data/lib/alchemy/tasks/generate_thumbnails.rb +99 -0
  303. data/lib/alchemy/tasks/tidy.rb +1 -1
  304. data/lib/alchemy/test_support/capybara_helpers.rb +48 -37
  305. data/lib/alchemy/test_support/current_language_shared_examples.rb +1 -1
  306. data/lib/alchemy/test_support/factories/dummy_user_factory.rb +4 -1
  307. data/lib/alchemy/test_support/having_picture_thumbnails_examples.rb +132 -0
  308. data/lib/alchemy/test_support/shared_ingredient_examples.rb +52 -0
  309. data/lib/alchemy/test_support/shared_publishable_examples.rb +29 -4
  310. data/lib/alchemy/upgrader/eight_four.rb +237 -0
  311. data/lib/alchemy/upgrader/register_module_rewriter.rb +177 -0
  312. data/lib/alchemy/upgrader.rb +2 -1
  313. data/lib/alchemy/version.rb +1 -1
  314. data/lib/alchemy.rb +0 -1
  315. data/lib/alchemy_cms.rb +0 -13
  316. data/lib/generators/alchemy/elements/templates/view.html.erb +1 -1
  317. data/lib/generators/alchemy/elements/templates/view.html.haml +1 -1
  318. data/lib/generators/alchemy/elements/templates/view.html.slim +1 -1
  319. data/lib/generators/alchemy/install/install_generator.rb +2 -13
  320. data/lib/generators/alchemy/install/templates/alchemy.rb.tt +24 -9
  321. data/lib/generators/alchemy/module/module_generator.rb +1 -0
  322. data/lib/generators/alchemy/module/templates/ability.rb.tt +1 -1
  323. data/lib/generators/alchemy/module/templates/module_config.rb.tt +16 -18
  324. data/lib/generators/alchemy/user_columns_migration/templates/migration.rb.tt +19 -0
  325. data/lib/generators/alchemy/user_columns_migration/user_columns_migration_generator.rb +30 -0
  326. data/lib/tasks/alchemy/thumbnails.rake +16 -29
  327. data/lib/tasks/alchemy/upgrade.rake +45 -3
  328. data/vendor/javascript/cropperjs.min.js +3 -3
  329. data/vendor/javascript/floating-ui.min.js +1 -1
  330. data/vendor/javascript/tinymce.min.js +5 -5
  331. data/vendor/javascript/tom-select.min.js +1 -1
  332. metadata +85 -65
  333. data/app/javascript/alchemy_admin/components/action.js +0 -50
  334. data/app/javascript/alchemy_admin/dirty.js +0 -55
  335. data/app/javascript/alchemy_admin/node_tree.js +0 -82
  336. data/app/javascript/alchemy_admin/picture_selector.js +0 -67
  337. data/app/javascript/alchemy_admin/templates/compiled.js +0 -1
  338. data/app/javascript/alchemy_admin/templates/node_folder.hbs +0 -3
  339. data/app/stylesheets/alchemy/admin/attachment-select.scss +0 -19
  340. data/app/views/alchemy/admin/_main_navi.html.erb +0 -6
  341. data/app/views/alchemy/admin/attachments/assign.js.erb +0 -15
  342. data/app/views/alchemy/admin/pages/_page_layout_filter.html.erb +0 -30
  343. data/app/views/alchemy/admin/partials/_main_navigation_entry.html.erb +0 -20
  344. data/app/views/alchemy/admin/partials/_sub_navigation.html.erb +0 -11
  345. data/app/views/alchemy/admin/pictures/assign.js.erb +0 -13
  346. data/app/views/alchemy/base/error_notice.js.erb +0 -1
  347. data/app/views/alchemy/base/permission_denied.js.erb +0 -3
  348. data/app/views/alchemy/base/redirect.js.erb +0 -11
  349. data/config/alchemy/config.yml +0 -217
  350. data/config/alchemy/modules.yml +0 -82
  351. data/config/initializers/rails_live_reload.rb +0 -13
  352. data/lib/alchemy/dev_support/live_reload_watcher.rb +0 -5
  353. data/lib/alchemy/taggable.rb +0 -49
  354. data/lib/generators/alchemy/install/templates/dragonfly.rb.tt +0 -34
  355. data/vendor/assets/stylesheets/alchemy_admin/select2.css +0 -1
  356. data/vendor/javascript/handlebars.min.js +0 -29
  357. data/vendor/javascript/jquery.min.js +0 -2
  358. data/vendor/javascript/rails-ujs.min.js +0 -1
  359. data/vendor/javascript/select2.min.js +0 -23
  360. /data/{lib → app/controllers/concerns}/alchemy/ability_helper.rb +0 -0
  361. /data/{lib → app/controllers/concerns}/alchemy/admin/locale.rb +0 -0
  362. /data/{lib → app/controllers/concerns}/alchemy/admin/preview_time.rb +0 -0
  363. /data/{lib → app/controllers/concerns}/alchemy/admin/timezone.rb +0 -0
  364. /data/{lib → app/controllers/concerns}/alchemy/configuration_methods.rb +0 -0
  365. /data/{lib → app/models}/alchemy/admin/preview_url.rb +0 -0
  366. /data/{lib → app/models/concerns}/alchemy/hints.rb +0 -0
  367. /data/{lib → app/models/concerns}/alchemy/name_conversions.rb +0 -0
  368. /data/{lib → app/services}/alchemy/elements_finder.rb +0 -0
@@ -1,7 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: alchemy_nodes
6
+ #
7
+ # id :integer not null, primary key
8
+ # depth :integer default(0), not null
9
+ # external :boolean default(FALSE), not null
10
+ # folded :boolean default(FALSE), not null
11
+ # lft :integer not null
12
+ # menu_type :string not null
13
+ # name :string
14
+ # nofollow :boolean default(FALSE), not null
15
+ # rgt :integer not null
16
+ # title :string
17
+ # url :string
18
+ # created_at :datetime not null
19
+ # updated_at :datetime not null
20
+ # creator_id :integer
21
+ # language_id :integer not null
22
+ # page_id :integer
23
+ # parent_id :integer
24
+ # updater_id :integer
25
+ #
26
+ # Indexes
27
+ #
28
+ # index_alchemy_nodes_on_creator_id (creator_id)
29
+ # index_alchemy_nodes_on_language_id (language_id)
30
+ # index_alchemy_nodes_on_lft (lft)
31
+ # index_alchemy_nodes_on_page_id (page_id)
32
+ # index_alchemy_nodes_on_parent_id (parent_id)
33
+ # index_alchemy_nodes_on_rgt (rgt)
34
+ # index_alchemy_nodes_on_updater_id (updater_id)
35
+ #
36
+ # Foreign Keys
37
+ #
38
+ # language_id (language_id => alchemy_languages.id)
39
+ # page_id (page_id => alchemy_pages.id) ON DELETE => restrict
40
+ #
3
41
  module Alchemy
4
42
  class Node < BaseRecord
43
+ include Alchemy::RelatableResource
44
+
5
45
  VALID_URL_REGEX = /\A(\/|\D[a-z+\d.-]+:)/
6
46
  SKIPPED_ATTRIBUTES_ON_COPY = %w[id created_at updated_at creator_id updater_id lft rgt depth parent_id]
7
47
 
@@ -37,16 +77,27 @@ module Alchemy
37
77
  # Like #available_to_guests but including nodes attached to restricted pages,
38
78
  # which logged in members are allowed to see.
39
79
  scope :available_to_members, -> {
80
+ Alchemy::Deprecation.warn("Node.available_to_members is deprecated and will be removed in Alchemy 9.0. Use Node.available_to(user) instead.")
40
81
  from_current_public_site.where(page: nil)
41
82
  .or(from_current_public_site.where(page: Alchemy::Page.published))
42
83
  }
43
84
 
85
+ # Nodes the given user may see: external links or nodes attached to a
86
+ # published page the user is allowed to read. Mirrors +Page#readable_by?+
87
+ # so a node never reveals a page the user cannot open. Passing +nil+ yields
88
+ # the same result as #available_to_guests.
89
+ scope :available_to, ->(user) {
90
+ from_current_public_site.where(page: nil)
91
+ .or(from_current_public_site.where(page: Alchemy::Page.published.readable_by(user)))
92
+ }
93
+
44
94
  before_validation :translate_root_menu_name, if: -> { root? }
45
95
  before_validation :set_menu_type_from_root, unless: -> { root? }
46
96
 
47
97
  validates :menu_type, presence: true
48
98
  validates :name, presence: true, if: -> { page.nil? }
49
99
  validates :url, format: {with: VALID_URL_REGEX}, unless: -> { url.nil? }
100
+ validates_with Alchemy::SafeUrlValidator, attributes: [:url]
50
101
 
51
102
  # Returns the name
52
103
  #
@@ -31,11 +31,7 @@ module Alchemy
31
31
  def expiration_time
32
32
  return 0 unless cache_page?
33
33
 
34
- if definition.cache.to_s.match?(/\d+/)
35
- definition.cache.to_i
36
- else
37
- Alchemy.config.page_cache.max_age
38
- end
34
+ cache_control.max_age || Alchemy.config.page_cache.max_age
39
35
  end
40
36
 
41
37
  def rootpage?
@@ -94,7 +90,7 @@ module Alchemy
94
90
  #
95
91
  def status_message(status_type)
96
92
  if status_type == :scheduled && scheduled?
97
- Alchemy.t(public_on&.future? ? :public_on : :public_until, scope: "page_states.scheduled", public_on: public_on && ::I18n.l(public_on, format: :"alchemy.default"), public_until: public_until && ::I18n.l(public_until, format: :"alchemy.default"))
93
+ Alchemy.t(public_on&.future? ? :public_on : :public_until, scope: "page_states.scheduled", public_on: Alchemy.l(public_on), public_until: Alchemy.l(public_until))
98
94
  else
99
95
  Alchemy.t(status[status_type].to_s, scope: "page_states.#{status_type}")
100
96
  end
@@ -174,9 +170,46 @@ module Alchemy
174
170
  # @returns Boolean
175
171
  #
176
172
  def cache_page?
177
- return false if !public? || restricted?
173
+ !cache_control.no_store?
174
+ end
175
+
176
+ # The effective Cache-Control directives for this page: the page layout's
177
+ # `cache` setting combined (most restrictively) with the `page_cache` of any
178
+ # published element on the page, then narrowed for restricted / non-public /
179
+ # search-results pages.
180
+ #
181
+ # @returns [Alchemy::CacheControl]
182
+ def cache_control
183
+ return @cache_control if defined?(@cache_control)
184
+
185
+ control = [definition.cache, *element_cache_controls].reduce(:&)
186
+
187
+ control = Alchemy::CacheControl.parse(false) if !public? || definition.searchresults
188
+
189
+ if restricted?
190
+ control = if definition.cache.default?
191
+ Alchemy::CacheControl.parse(false)
192
+ else
193
+ control.restrict_visibility
194
+ end
195
+ end
196
+
197
+ @cache_control = control
198
+ end
199
+
200
+ private
201
+
202
+ # CacheControl of every visible element on the page whose definition
203
+ # declares a non-default `page_cache`. Reads through the page version's
204
+ # element repository so it reuses already-loaded elements instead of
205
+ # firing its own query, and only inspects elements at all when at least
206
+ # one definition is non-default.
207
+ def element_cache_controls
208
+ constrained = Alchemy::Element.definitions.reject { |d| d.page_cache.default? }
209
+ return [] if constrained.empty?
178
210
 
179
- definition.cache != false && definition.searchresults != true
211
+ present_names = public_version&.element_repository&.visible&.named(*constrained.map(&:name))&.map(&:name) || []
212
+ constrained.select { |d| present_names.include?(d.name) }.map(&:page_cache)
180
213
  end
181
214
  end
182
215
  end
@@ -45,6 +45,29 @@ module Alchemy
45
45
  #
46
46
  scope :restricted, -> { where(restricted: true) }
47
47
 
48
+ # All pages readable by the given user. Mirrors the +readable_by?+
49
+ # predicate for a whole relation, so +accessible_by+ and the per
50
+ # instance check stay in sync.
51
+ #
52
+ # Unrestricted pages are readable by everyone. Restricted pages are only
53
+ # readable when one of their +permitted_roles+ matches a role of the
54
+ # user. Roles are stored as a JSON array, so a single quoted role token
55
+ # is matched with a portable LIKE. +sanitize_sql_like+ escapes any LIKE
56
+ # wildcards in the role name so a role cannot smuggle in a +%+ or +_+;
57
+ # the escape character is +!+ instead of the usual backslash to avoid
58
+ # MySQL treating it as a string escape.
59
+ #
60
+ scope :readable_by,
61
+ ->(user) {
62
+ roles = Array(user&.alchemy_roles).map(&:to_s).select(&:present?)
63
+ return not_restricted if roles.empty?
64
+
65
+ column = "#{Alchemy::Page.table_name}.permitted_roles"
66
+ conditions = roles.map { "#{column} LIKE ? ESCAPE '!'" }.join(" OR ")
67
+ patterns = roles.map { |role| "%\"#{Alchemy::Page.sanitize_sql_like(role, "!")}\"%" }
68
+ not_restricted.or(restricted.where(conditions, *patterns))
69
+ }
70
+
48
71
  # All public pages
49
72
  #
50
73
  scope :published,
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ # Builds the cache key for the XML sitemap.
5
+ #
6
+ # The key combines three independent sources of change:
7
+ #
8
+ # 1. The contents of the +Alchemy::Page.sitemap+ scope, as page count and
9
+ # latest +updated_at+.
10
+ # 2. The requested base url (protocol, host and port), because
11
+ # +show_alchemy_page_url+ builds absolute urls from +request.base_url+ and
12
+ # a site can be served under several of them (see +Alchemy::Site.find_for_host+).
13
+ # 3. A coarse time bucket of +max_age+ seconds.
14
+ #
15
+ # The time bucket is what makes scheduled publishing work. Pages enter and
16
+ # leave the sitemap when +public_on+ / +public_until+ pass, without any record
17
+ # being touched, so no content based key can notice that transition. Rotating
18
+ # the key every +max_age+ seconds bounds how long such a page can be missing.
19
+ # It also keeps the ETag from freezing: without it, a revalidating client
20
+ # would receive +304 Not Modified+ forever.
21
+ class Page::SitemapCacheKey
22
+ attr_reader :site, :base_url, :max_age
23
+
24
+ # @param site [Alchemy::Site] The site the sitemap is rendered for.
25
+ # @param base_url [String] The requested base url (e.g. +request.base_url+).
26
+ # @param max_age [Integer] Length of the time bucket in seconds.
27
+ def initialize(site:, base_url:, max_age: Alchemy.config.sitemap.max_age)
28
+ @site = site
29
+ @base_url = base_url
30
+ @max_age = max_age
31
+ end
32
+
33
+ # @return [Array<Object>]
34
+ def call
35
+ [site&.id, base_url, content_version, time_bucket]
36
+ end
37
+
38
+ private
39
+
40
+ # Page count and latest +updated_at+ of the sitemap scope.
41
+ #
42
+ # We can not use +ActiveRecord::Relation#cache_key_with_version+ here. The
43
+ # +published+ scope inlines +Time.current+ into its SQL, so the query digest
44
+ # that +cache_key+ is built from would differ on every single call and the
45
+ # key would never be stable.
46
+ def content_version
47
+ pages = Page.sitemap
48
+ timestamp = pages.maximum(Page.arel_table[:updated_at])
49
+ "#{pages.count}-#{timestamp&.to_fs(:usec)}"
50
+ end
51
+
52
+ def time_bucket
53
+ Time.current.to_i / max_age
54
+ end
55
+ end
56
+ end
@@ -4,35 +4,46 @@
4
4
  #
5
5
  # Table name: alchemy_pages
6
6
  #
7
- # id :integer not null, primary key
8
- # name :string
9
- # urlname :string
10
- # title :string (deprecated - use draft_version.title)
11
- # language_code :string
12
- # language_root :boolean
13
- # page_layout :string
14
- # meta_keywords :text (deprecated - use draft_version.meta_keywords)
15
- # meta_description :text (deprecated - use draft_version.meta_description)
16
- # lft :integer
17
- # rgt :integer
18
- # parent_id :integer
19
- # depth :integer
20
- # locked_by :integer
21
- # restricted :boolean default(FALSE)
22
- # robot_index :boolean default(TRUE)
23
- # robot_follow :boolean default(TRUE)
24
- # sitemap :boolean default(TRUE)
25
- # layoutpage :boolean default(FALSE)
26
- # created_at :datetime not null
27
- # updated_at :datetime not null
28
- # creator_id :integer
29
- # updater_id :integer
30
- # language_id :integer
31
- # cached_tag_list :text
32
- # published_at :datetime
33
- # public_on :datetime
34
- # public_until :datetime
35
- # locked_at :datetime
7
+ # id :integer not null, primary key
8
+ # cached_tag_list :text
9
+ # depth :integer
10
+ # language_code :string
11
+ # language_root :boolean default(FALSE), not null
12
+ # layoutpage :boolean default(FALSE), not null
13
+ # lft :integer
14
+ # locked_at :datetime
15
+ # locked_by :integer
16
+ # name :string
17
+ # page_layout :string
18
+ # permitted_roles :text
19
+ # published_at :datetime
20
+ # restricted :boolean default(FALSE), not null
21
+ # rgt :integer
22
+ # robot_follow :boolean default(TRUE), not null
23
+ # robot_index :boolean default(TRUE), not null
24
+ # searchable :boolean default(TRUE), not null
25
+ # sitemap :boolean default(TRUE), not null
26
+ # urlname :string
27
+ # created_at :datetime not null
28
+ # updated_at :datetime not null
29
+ # creator_id :integer
30
+ # language_id :integer not null
31
+ # parent_id :integer
32
+ # updater_id :integer
33
+ #
34
+ # Indexes
35
+ #
36
+ # index_alchemy_pages_on_creator_id (creator_id)
37
+ # index_alchemy_pages_on_language_id (language_id)
38
+ # index_alchemy_pages_on_locked_at_and_locked_by (locked_at,locked_by)
39
+ # index_alchemy_pages_on_rgt (rgt)
40
+ # index_alchemy_pages_on_updater_id (updater_id)
41
+ # index_pages_on_parent_id_and_lft (parent_id,lft)
42
+ # index_pages_on_urlname (urlname)
43
+ #
44
+ # Foreign Keys
45
+ #
46
+ # language_id (language_id => alchemy_languages.id)
36
47
  #
37
48
 
38
49
  require_dependency "alchemy/page/fixed_attributes"
@@ -45,6 +56,7 @@ require_dependency "alchemy/page/page_elements"
45
56
  module Alchemy
46
57
  class Page < BaseRecord
47
58
  include Alchemy::Taggable
59
+ include Alchemy::RelatableResource
48
60
 
49
61
  # These columns are deprecated in favor of page versions
50
62
  self.ignored_columns += [
@@ -92,6 +104,7 @@ module Alchemy
92
104
  :layoutpage,
93
105
  :menu_id,
94
106
  {
107
+ permitted_roles: [],
95
108
  draft_version_attributes: [:id] + PageVersion::METADATA_ATTRIBUTES.map(&:to_sym)
96
109
  }
97
110
  ]
@@ -135,6 +148,14 @@ module Alchemy
135
148
 
136
149
  has_many :page_ingredients, class_name: "Alchemy::Ingredients::Page", foreign_key: :related_object_id, dependent: :nullify
137
150
 
151
+ # Stored as a JSON array in a plain text column, so the value is byte
152
+ # identical on every supported database and the +readable_by+ scope can
153
+ # match a single quoted role with a portable LIKE. No +type: Array+, as
154
+ # that would serialize an empty array to NULL, which +readable_by?+ cannot
155
+ # intersect; the writer already guarantees an array is stored.
156
+ attribute :permitted_roles, default: -> { ["member"] }
157
+ serialize :permitted_roles, coder: JSON
158
+
138
159
  before_destroy :check_descendants_for_menu_nodes
139
160
 
140
161
  before_validation :set_language,
@@ -143,14 +164,18 @@ module Alchemy
143
164
  validates_presence_of :page_layout
144
165
  validates_format_of :page_layout, with: /\A[a-z0-9_-]+\z/, unless: -> { page_layout.blank? }
145
166
  validates_presence_of :parent, unless: -> { layoutpage? || language_root? }
167
+ validates_presence_of :permitted_roles, if: :restricted?
168
+ validate :permitted_roles_are_configured
146
169
 
147
170
  after_initialize :ensure_draft_version, if: :new_record?
148
171
 
149
172
  before_save :set_language_code,
150
173
  if: -> { language.present? }
151
174
 
152
- before_save :set_restrictions_to_child_pages,
153
- if: :restricted_changed?
175
+ # Runs after save, so descendants inheriting from this page see the new
176
+ # values instead of the ones still stored in the database.
177
+ after_save :set_restrictions_to_child_pages,
178
+ if: -> { saved_change_to_restricted? || saved_change_to_permitted_roles? }
154
179
 
155
180
  before_save :inherit_restricted_status,
156
181
  if: -> { parent && parent.restricted? }
@@ -377,12 +402,22 @@ module Alchemy
377
402
 
378
403
  def set_restrictions_to_child_pages
379
404
  descendants.each do |child|
380
- child.update(restricted: restricted?)
405
+ child.update(restricted: restricted?, permitted_roles: permitted_roles)
381
406
  end
382
407
  end
383
408
 
384
409
  def inherit_restricted_status
385
410
  self.restricted = parent.restricted?
411
+ # Roles are only seeded from the parent. An existing page keeps the roles
412
+ # assigned to it, until the parent pushes its own roles down again.
413
+ self.permitted_roles = parent.permitted_roles if new_record?
414
+ end
415
+
416
+ def permitted_roles_are_configured
417
+ unknown = permitted_roles - Alchemy.config.user_roles.map(&:to_s)
418
+ return if unknown.empty?
419
+
420
+ errors.add(:permitted_roles, :inclusion, roles: unknown.to_sentence)
386
421
  end
387
422
 
388
423
  # Returns the first published child
@@ -468,6 +503,27 @@ module Alchemy
468
503
  (editor_roles & user.alchemy_roles).any?
469
504
  end
470
505
 
506
+ # Accepts an array of role names (as sent by the admin form) and normalizes
507
+ # it to an array of unique, non blank strings. Whether the roles are
508
+ # actually configured is enforced by a validation, not silently here.
509
+ #
510
+ def permitted_roles=(roles)
511
+ super(Array(roles).map(&:to_s).select(&:present?).uniq)
512
+ end
513
+
514
+ # Checks if the given user is allowed to read this page.
515
+ #
516
+ # Unrestricted pages are readable by everyone. A restricted page is only
517
+ # readable by users holding one of the page's +permitted_roles+. A restricted
518
+ # page without any role locks everybody out, which is why at least one role
519
+ # is validated to be present.
520
+ #
521
+ def readable_by?(user)
522
+ return true unless restricted?
523
+
524
+ (permitted_roles & Array(user&.alchemy_roles).map(&:to_s)).any?
525
+ end
526
+
471
527
  # Returns the value of +public_on+ attribute from public version
472
528
  #
473
529
  # If it's a fixed attribute then the fixed value is returned instead
@@ -12,7 +12,7 @@ module Alchemy
12
12
  attribute :autogenerate, default: []
13
13
  attribute :layoutpage, :boolean, default: false
14
14
  attribute :unique, :boolean, default: false
15
- attribute :cache, default: true
15
+ attribute :cache, Alchemy::CacheControlType.new, default: true
16
16
  attribute :insert_elements_at, :string, default: "bottom"
17
17
  attribute :fixed_attributes, default: {}
18
18
  attribute :searchable, :boolean, default: true
@@ -1,5 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: alchemy_page_mutexes
6
+ #
7
+ # id :integer not null, primary key
8
+ # created_at :datetime
9
+ # page_id :integer not null
10
+ #
11
+ # Indexes
12
+ #
13
+ # index_alchemy_page_mutexes_on_page_id (page_id) UNIQUE
14
+ #
15
+ # Foreign Keys
16
+ #
17
+ # page_id (page_id => alchemy_pages.id)
18
+ #
3
19
  module Alchemy
4
20
  class PageMutex < BaseRecord
5
21
  class LockFailed < StandardError; end
@@ -1,5 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: alchemy_page_versions
6
+ #
7
+ # id :integer not null, primary key
8
+ # meta_description :text
9
+ # meta_keywords :text
10
+ # public_on :datetime
11
+ # public_until :datetime
12
+ # title :string
13
+ # created_at :datetime not null
14
+ # updated_at :datetime not null
15
+ # page_id :integer not null
16
+ #
17
+ # Indexes
18
+ #
19
+ # index_alchemy_page_versions_on_page_id (page_id)
20
+ # index_alchemy_page_versions_on_public_on_and_public_until (public_on,public_until)
21
+ #
22
+ # Foreign Keys
23
+ #
24
+ # page_id (page_id => alchemy_pages.id) ON DELETE => cascade
25
+ #
3
26
  module Alchemy
4
27
  class PageVersion < BaseRecord
5
28
  include Alchemy::Publishable
@@ -30,7 +53,7 @@ module Alchemy
30
53
  before_destroy :delete_elements
31
54
 
32
55
  def element_repository
33
- ElementsRepository.new(elements)
56
+ @element_repository ||= ElementsRepository.new(elements)
34
57
  end
35
58
 
36
59
  private
@@ -69,13 +69,14 @@ module Alchemy
69
69
  e.public?
70
70
  end
71
71
 
72
- can :read, Alchemy::Page, Alchemy::Page.published.from_current_site do |p|
73
- p.public? && p.site == Alchemy::Current.site
72
+ can :read, Alchemy::Page,
73
+ Alchemy::Page.published.from_current_site.readable_by(@user) do |p|
74
+ p.public? && p.site == Alchemy::Current.site && p.readable_by?(@user)
74
75
  end
75
76
 
76
- can :read, Alchemy::Node, Alchemy::Node.available_to_members do |node|
77
+ can :read, Alchemy::Node, Alchemy::Node.available_to(@user) do |node|
77
78
  node.language.public? && node.language.site_id == Alchemy::Current.site&.id &&
78
- (node.page.nil? || node.page.public?)
79
+ (node.page.nil? || (node.page.public? && node.page.readable_by?(@user)))
79
80
  end
80
81
  end
81
82
  end
@@ -104,7 +105,7 @@ module Alchemy
104
105
 
105
106
  # Controller actions
106
107
  can :leave, :alchemy_admin
107
- can [:info, :help, :show], :alchemy_admin_dashboard
108
+ can [:info, :license, :help, :show], :alchemy_admin_dashboard
108
109
  can :manage, :alchemy_admin_clipboard
109
110
  can :update, :alchemy_admin_layoutpages
110
111
  can :tree, :alchemy_admin_pages
@@ -5,19 +5,27 @@
5
5
  # Table name: alchemy_pictures
6
6
  #
7
7
  # id :integer not null, primary key
8
- # name :string
8
+ # cached_tag_list :text
9
+ # image_file_format :string
10
+ # image_file_height :integer
9
11
  # image_file_name :string
12
+ # image_file_size :integer
13
+ # image_file_uid :string
10
14
  # image_file_width :integer
11
- # image_file_height :integer
15
+ # name :string
16
+ # upload_hash :string
12
17
  # created_at :datetime not null
13
18
  # updated_at :datetime not null
14
19
  # creator_id :integer
15
20
  # updater_id :integer
16
- # upload_hash :string
17
- # cached_tag_list :text
18
- # image_file_uid :string
19
- # image_file_size :integer
20
- # image_file_format :string
21
+ #
22
+ # Indexes
23
+ #
24
+ # index_alchemy_pictures_on_created_at (created_at)
25
+ # index_alchemy_pictures_on_creator_id (creator_id)
26
+ # index_alchemy_pictures_on_image_file_name (image_file_name)
27
+ # index_alchemy_pictures_on_name (name)
28
+ # index_alchemy_pictures_on_updater_id (updater_id)
21
29
  #
22
30
 
23
31
  module Alchemy
@@ -1,3 +1,25 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: alchemy_picture_descriptions
4
+ #
5
+ # id :integer not null, primary key
6
+ # text :text
7
+ # created_at :datetime not null
8
+ # updated_at :datetime not null
9
+ # language_id :integer not null
10
+ # picture_id :integer not null
11
+ #
12
+ # Indexes
13
+ #
14
+ # alchemy_picture_descriptions_on_picture_id_and_language_id (picture_id,language_id) UNIQUE
15
+ # index_alchemy_picture_descriptions_on_language_id (language_id)
16
+ # index_alchemy_picture_descriptions_on_picture_id (picture_id)
17
+ #
18
+ # Foreign Keys
19
+ #
20
+ # language_id (language_id => alchemy_languages.id)
21
+ # picture_id (picture_id => alchemy_pictures.id)
22
+ #
1
23
  module Alchemy
2
24
  class PictureDescription < ActiveRecord::Base
3
25
  belongs_to :picture, class_name: "Alchemy::Picture"
@@ -1,5 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: alchemy_picture_thumbs
6
+ #
7
+ # id :integer not null, primary key
8
+ # signature :string not null
9
+ # uid :text not null
10
+ # picture_id :integer not null
11
+ #
12
+ # Indexes
13
+ #
14
+ # index_alchemy_picture_thumbs_on_picture_id (picture_id)
15
+ # index_alchemy_picture_thumbs_on_signature (signature) UNIQUE
16
+ #
17
+ # Foreign Keys
18
+ #
19
+ # picture_id (picture_id => alchemy_pictures.id)
20
+ #
3
21
  module Alchemy
4
22
  # The persisted version of a rendered picture variant
5
23
  #
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Alchemy
4
+ module Publishable
5
+ # Default resolver for Publishable records.
6
+ #
7
+ # Uses the +public_on+ and +public_until+ timestamp columns to determine
8
+ # whether a record is public, scheduled, or publishable.
9
+ #
10
+ # This class defines the interface that custom resolvers configured via
11
+ # +Alchemy.config.publishable_resolver+ must implement.
12
+ #
13
+ class TimestampResolver
14
+ class << self
15
+ # Returns records without a +public_on+ date set.
16
+ def draft(publishables)
17
+ publishables.where(public_on: nil)
18
+ end
19
+
20
+ # Returns records with a +public_on+ date set.
21
+ def scheduled(publishables)
22
+ publishables.where.not(public_on: nil)
23
+ end
24
+
25
+ # Returns records that are public at the given time.
26
+ def published(publishables, at:)
27
+ table_name = publishables.table_name
28
+ scheduled(publishables)
29
+ .where("#{table_name}.public_on <= :at", at:)
30
+ .where("#{table_name}.public_until IS NULL OR #{table_name}.public_until > :at", at:)
31
+ end
32
+ end
33
+
34
+ def initialize(publishable)
35
+ @publishable = publishable
36
+ end
37
+
38
+ # Determines if the record is public at the given time.
39
+ def public?(at: Current.preview_time)
40
+ already_public_for?(at:) && still_public_for?(at:)
41
+ end
42
+
43
+ # Determines if the record has a future publication or expiration event.
44
+ def scheduled?(at: Current.preview_time)
45
+ (publishable.public_on.present? && publishable.public_on > at) ||
46
+ (publishable.public_until.present? && publishable.public_until > at)
47
+ end
48
+
49
+ # Determines if the record is publishable.
50
+ #
51
+ # A record is publishable if a +public_on+ timestamp is set and not
52
+ # expired yet.
53
+ def publishable?
54
+ !publishable.public_on.nil? && still_public_for?(at: Time.current)
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :publishable
60
+
61
+ def already_public_for?(at: Current.preview_time)
62
+ !publishable.public_on.nil? && publishable.public_on <= at
63
+ end
64
+
65
+ def still_public_for?(at: Current.preview_time)
66
+ publishable.public_until.nil? || publishable.public_until > at
67
+ end
68
+ end
69
+ end
70
+ end
@@ -229,7 +229,7 @@ module Alchemy
229
229
  # Joins all searchable attribute names into a Ransack compatible search query
230
230
  #
231
231
  def search_field_name
232
- searchable_attribute_names.join("_or_") + "_cont"
232
+ searchable_attribute_names.join("_or_") + "_i_cont"
233
233
  end
234
234
 
235
235
  def in_engine?