rivet_cms 0.1.2.pre → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.md +173 -10
- data/app/assets/builds/rivet_cms.css +2 -2
- data/app/assets/builds/rivet_cms.js +395 -9505
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
- data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
- data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
- data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
- data/app/controllers/rivet_cms/application_controller.rb +220 -2
- data/app/controllers/rivet_cms/auth_controller.rb +28 -0
- data/app/controllers/rivet_cms/components_controller.rb +93 -2
- data/app/controllers/rivet_cms/content_controller.rb +105 -0
- data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
- data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
- data/app/controllers/rivet_cms/documents_controller.rb +204 -0
- data/app/controllers/rivet_cms/fields_controller.rb +77 -74
- data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
- data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
- data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
- data/app/controllers/rivet_cms/setup_controller.rb +60 -0
- data/app/controllers/rivet_cms/trash_controller.rb +68 -0
- data/app/controllers/rivet_cms/users_controller.rb +117 -0
- data/app/helpers/rivet_cms/application_helper.rb +13 -3
- data/app/helpers/rivet_cms/components_helper.rb +4 -0
- data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
- data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
- data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
- data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
- data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
- data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
- data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
- data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
- data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
- data/app/models/rivet_cms/api_token.rb +64 -0
- data/app/models/rivet_cms/category.rb +20 -0
- data/app/models/rivet_cms/component.rb +10 -0
- data/app/models/rivet_cms/component_instance.rb +27 -0
- data/app/models/rivet_cms/content_type.rb +36 -23
- data/app/models/rivet_cms/content_value.rb +19 -0
- data/app/models/rivet_cms/current.rb +6 -0
- data/app/models/rivet_cms/document.rb +71 -0
- data/app/models/rivet_cms/document_revision.rb +128 -0
- data/app/models/rivet_cms/entry.rb +73 -0
- data/app/models/rivet_cms/entry_collection.rb +41 -0
- data/app/models/rivet_cms/field.rb +203 -61
- data/app/models/rivet_cms/media_asset.rb +109 -0
- data/app/models/rivet_cms/organization.rb +46 -0
- data/app/models/rivet_cms/relation.rb +23 -0
- data/app/models/rivet_cms/user.rb +61 -0
- data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
- data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
- data/app/services/rivet_cms/content_query.rb +158 -0
- data/app/services/rivet_cms/content_validator.rb +150 -0
- data/app/services/rivet_cms/draft_writer.rb +74 -0
- data/app/services/rivet_cms/open_api_generator.rb +173 -221
- data/app/services/rivet_cms/revision_pruner.rb +64 -0
- data/app/services/rivet_cms/webhooks.rb +61 -0
- data/app/views/layouts/rivet_cms/application.html.erb +35 -42
- data/config/routes.rb +68 -45
- data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
- data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
- data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
- data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
- data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
- data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
- data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
- data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
- data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
- data/db/seeds/README.md +70 -0
- data/db/seeds/templates/blog.rb +39 -0
- data/db/seeds/templates/events.rb +12 -0
- data/db/seeds/templates/faq.rb +9 -0
- data/db/seeds/templates/pages.rb +35 -0
- data/db/seeds/templates/site_settings.rb +22 -0
- data/db/seeds/templates/team.rb +11 -0
- data/db/seeds/templates/testimonials.rb +11 -0
- data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
- data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
- data/lib/rivet_cms/access_check.rb +12 -0
- data/lib/rivet_cms/audit.rb +43 -0
- data/lib/rivet_cms/engine.rb +28 -22
- data/lib/rivet_cms/hooks.rb +106 -0
- data/lib/rivet_cms/navigation.rb +111 -0
- data/lib/rivet_cms/safe_pattern.rb +40 -0
- data/lib/rivet_cms/seeds.rb +264 -0
- data/lib/rivet_cms/version.rb +1 -1
- data/lib/rivet_cms.rb +289 -32
- data/lib/tasks/rivet_cms_tasks.rake +18 -4
- metadata +138 -98
- data/MIT-LICENSE +0 -20
- data/app/assets/builds/rivet_cms.js.map +0 -7
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
- data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
- data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
- data/app/helpers/rivet_cms/flash_helper.rb +0 -37
- data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
- data/app/javascript/controllers/content_type_form_controller.js +0 -53
- data/app/javascript/controllers/field_layout_controller.js +0 -709
- data/app/javascript/rivet_cms.js +0 -29
- data/app/models/rivet_cms/content.rb +0 -4
- data/app/models/rivet_cms/field_values/base.rb +0 -11
- data/app/models/rivet_cms/field_values/boolean.rb +0 -4
- data/app/models/rivet_cms/field_values/integer.rb +0 -4
- data/app/models/rivet_cms/field_values/string.rb +0 -4
- data/app/models/rivet_cms/field_values/text.rb +0 -4
- data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
- data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
- data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
- data/app/views/rivet_cms/content_types/index.html.erb +0 -151
- data/app/views/rivet_cms/content_types/new.html.erb +0 -19
- data/app/views/rivet_cms/content_types/show.html.erb +0 -147
- data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
- data/app/views/rivet_cms/fields/_form.html.erb +0 -111
- data/app/views/rivet_cms/fields/edit.html.erb +0 -25
- data/app/views/rivet_cms/fields/index.html.erb +0 -126
- data/app/views/rivet_cms/fields/new.html.erb +0 -25
- data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
- data/config/i18n-tasks.yml +0 -178
- data/config/locales/en.yml +0 -14
- data/db/migrate/20250317194359_create_core_tables.rb +0 -90
- data/lib/rivet_cms/routes.rb +0 -49
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Without configuration RivetCms handles sign-in itself: the first visit
|
|
2
|
+
# creates the first admin account, and the Users page invites more. CE has no
|
|
3
|
+
# roles; every signed-in user can do everything (role and record-scoped
|
|
4
|
+
# permissions are a RivetCMS Pro feature). Nothing below is required.
|
|
5
|
+
#
|
|
6
|
+
# To use your application's authentication instead (Devise, the Rails 8
|
|
7
|
+
# authentication generator, custom sessions), wire the lambdas below;
|
|
8
|
+
# configuring `authenticate` disables the built-in login and Users page.
|
|
9
|
+
#
|
|
10
|
+
# RivetCms.configure do |config|
|
|
11
|
+
# # Engine controllers inherit from this class, making your auth filters and
|
|
12
|
+
# # helpers available. Point it at a slim controller if your
|
|
13
|
+
# # ApplicationController carries many before_actions.
|
|
14
|
+
# config.parent_controller = "ApplicationController"
|
|
15
|
+
#
|
|
16
|
+
# # Return truthy to allow the request, falsy to deny it. On denial RivetCms
|
|
17
|
+
# # redirects browsers to login_path (or 403s) and returns 401 to the SPA — so
|
|
18
|
+
# # a boolean check is enough and fails CLOSED. (You may also render/redirect
|
|
19
|
+
# # yourself for custom handling; the engine respects a response you've made.)
|
|
20
|
+
# config.authenticate = ->(controller) { controller.send(:user_signed_in?) } # Devise example
|
|
21
|
+
# # Devise's authenticate_user! also works (returns the user, redirects on failure):
|
|
22
|
+
# # config.authenticate = ->(controller) { controller.send(:authenticate_user!) }
|
|
23
|
+
#
|
|
24
|
+
# # The signed-in host user (or nil). Used for revision authorship and the
|
|
25
|
+
# # header identity display.
|
|
26
|
+
# config.current_user = ->(controller) { controller.send(:current_user) }
|
|
27
|
+
#
|
|
28
|
+
# # How to display a user. Defaults try full_name/name/display_name/email.
|
|
29
|
+
# # config.user_name = ->(user) { user.full_name }
|
|
30
|
+
# # config.user_email = ->(user) { user.email }
|
|
31
|
+
#
|
|
32
|
+
# # Where the admin frontend sends the browser when a session expires (401).
|
|
33
|
+
# config.login_path = "/users/sign_in"
|
|
34
|
+
#
|
|
35
|
+
# # Renders a Log out button in the admin header, submitted as a real form
|
|
36
|
+
# # (with _method) so DELETE routes work. nil hides the button.
|
|
37
|
+
# config.logout_path = "/users/sign_out"
|
|
38
|
+
# config.logout_method = "delete"
|
|
39
|
+
#
|
|
40
|
+
# # Media/upload settings:
|
|
41
|
+
# # config.max_upload_size = 100 * 1024 * 1024
|
|
42
|
+
# # config.media_host = "https://cms.example.com"
|
|
43
|
+
# #
|
|
44
|
+
# # Thumbnails need system binaries (all optional, see README): libvips for
|
|
45
|
+
# # images, poppler-utils for PDF previews, ffmpeg for video previews.
|
|
46
|
+
# #
|
|
47
|
+
# # MIME types the media library accepts (checked against the sniffed type).
|
|
48
|
+
# # Defaults to common image/video/audio/document types; SVG is excluded
|
|
49
|
+
# # because scripted SVGs can carry XSS. Set to nil to allow anything.
|
|
50
|
+
# # config.allowed_media_types += %w[image/svg+xml]
|
|
51
|
+
#
|
|
52
|
+
# # Superseded published revisions to keep per document. The default :all
|
|
53
|
+
# # never deletes anything; set an integer to prune on publish (0 keeps only
|
|
54
|
+
# # the live snapshot). The live snapshot and working draft are never pruned.
|
|
55
|
+
# # config.revision_retention = :all
|
|
56
|
+
#
|
|
57
|
+
# # Authorization: receives a RivetCms::AccessCheck (user, action, resource,
|
|
58
|
+
# # organization, record), returns a boolean; default allow. Actions: :read,
|
|
59
|
+
# # :write, :publish, :delete. Resources: :content, :schema, :media, :api.
|
|
60
|
+
# # Allowlist known pairs and deny the rest; the vocabulary grows in minor
|
|
61
|
+
# # releases.
|
|
62
|
+
# # config.can = ->(check) { check.user&.admin? || check.action == :read }
|
|
63
|
+
#
|
|
64
|
+
# # Delivery API: false (default) requires an API token; true allows anonymous
|
|
65
|
+
# # reads of published content. Preview (draft) reads always need a token.
|
|
66
|
+
# # config.public_api = false
|
|
67
|
+
#
|
|
68
|
+
# # Lifecycle hooks: run host code when content changes. Hooks fire after
|
|
69
|
+
# # the database commit and receive the published snapshot revision.
|
|
70
|
+
# # RivetCms.on(:publish) { |revision| Rails.cache.delete("nav") }
|
|
71
|
+
#
|
|
72
|
+
# # Webhooks: POST a JSON payload to each endpoint on matching events
|
|
73
|
+
# # (events omitted = all events). Delivered async via ActiveJob.
|
|
74
|
+
# # config.webhooks = [
|
|
75
|
+
# # { url: "https://example.com/deploy-hook", events: %w[entry.published] }
|
|
76
|
+
# # ]
|
|
77
|
+
# end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Immutable description of one authorization check, passed to RivetCms.can.
|
|
3
|
+
# Checks run in two phases: first with record nil, before anything loads
|
|
4
|
+
# ("is this action available to the user at all", also drives the sidebar),
|
|
5
|
+
# then again with record set to the loaded entry, content type, field,
|
|
6
|
+
# component, media asset, or API token. Fields may be ADDED in minor
|
|
7
|
+
# releases; policies should read only the fields they need and treat
|
|
8
|
+
# unknown action/resource symbols as deny, since the vocabulary grows.
|
|
9
|
+
AccessCheck = Struct.new(:user, :action, :resource, :organization, :record, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
class AccessDenied < StandardError; end
|
|
12
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# One observability stream over every admin mutation, fired as the :audit
|
|
3
|
+
# hook. Named events (:publish, :prune) are behavior hooks carrying rich
|
|
4
|
+
# domain objects for a specific moment; :audit is uniform who/what/when
|
|
5
|
+
# data for consumers that want all of it (audit logs, activity feeds,
|
|
6
|
+
# SIEM pipelines).
|
|
7
|
+
#
|
|
8
|
+
# RivetCms.on(:audit) do |event|
|
|
9
|
+
# AuditRow.create!(action: event.action, subject: event.subject_id, ...)
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# The payload is plain values plus the actor object; subscribers must
|
|
13
|
+
# tolerate unknown actions, since the vocabulary grows in minor releases.
|
|
14
|
+
# CE audits admin UI mutations; console and seed changes are not recorded.
|
|
15
|
+
AuditEvent = Struct.new(:action, :subject_type, :subject_id, :subject_label,
|
|
16
|
+
:organization_id, :actor, :metadata, :at, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
module Audit
|
|
19
|
+
class << self
|
|
20
|
+
def record(action, subject:, actor:, organization:, metadata: {})
|
|
21
|
+
event = AuditEvent.new(
|
|
22
|
+
action: action.to_s,
|
|
23
|
+
subject_type: subject.class.name.demodulize.underscore,
|
|
24
|
+
subject_id: subject.try(:prefix_id) || subject.id,
|
|
25
|
+
subject_label: label_for(subject),
|
|
26
|
+
organization_id: organization&.id,
|
|
27
|
+
actor: actor,
|
|
28
|
+
metadata: metadata,
|
|
29
|
+
at: Time.current
|
|
30
|
+
)
|
|
31
|
+
Hooks.run(:audit, event)
|
|
32
|
+
event
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def label_for(subject)
|
|
38
|
+
(subject.try(:name) || subject.try(:label) || subject.try(:slug) ||
|
|
39
|
+
subject.try(:title) || subject.try(:filename))&.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/rivet_cms/engine.rb
CHANGED
|
@@ -1,37 +1,43 @@
|
|
|
1
|
-
# lib/rivet_cms/engine.rb
|
|
2
|
-
require "image_processing"
|
|
3
|
-
require "prefixed_ids"
|
|
4
|
-
require "kaminari"
|
|
5
|
-
require "turbo-rails"
|
|
6
|
-
require "stimulus-rails"
|
|
7
|
-
require "rivet_cms/routes"
|
|
8
|
-
|
|
9
1
|
module RivetCms
|
|
10
2
|
class Engine < ::Rails::Engine
|
|
11
3
|
isolate_namespace RivetCms
|
|
12
4
|
|
|
13
|
-
# Configure internationalization before the engine loads
|
|
14
|
-
config.before_configuration do
|
|
15
|
-
config.i18n.load_path += Dir[File.join(File.dirname(__FILE__), '..', 'config', 'locales', '**', '*.yml')]
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Set up testing and factory configurations
|
|
19
5
|
config.generators do |g|
|
|
20
6
|
g.test_framework :rspec
|
|
21
7
|
g.fixture_replacement :factory_bot
|
|
22
|
-
g.factory_bot dir:
|
|
8
|
+
g.factory_bot dir: "spec/factories"
|
|
23
9
|
end
|
|
24
10
|
|
|
25
|
-
|
|
26
|
-
|
|
11
|
+
# CE webhooks ride the same lifecycle seam host apps use
|
|
12
|
+
initializer "rivet_cms.webhooks" do
|
|
13
|
+
RivetCms::Hooks.on(:publish, key: :rivet_cms_webhooks) { |revision| RivetCms::Webhooks.deliver(:publish, revision) }
|
|
27
14
|
end
|
|
28
15
|
|
|
29
|
-
|
|
30
|
-
|
|
16
|
+
# Host initializers have run by now; a bad webhook entry should stop
|
|
17
|
+
# boot, not silently drop deliveries behind the hook seam's rescue.
|
|
18
|
+
config.after_initialize do
|
|
19
|
+
RivetCms::Webhooks.validate_config!
|
|
31
20
|
end
|
|
32
21
|
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
# Run engine migrations from the host app without requiring
|
|
23
|
+
# `rails rivet_cms:install:migrations`.
|
|
24
|
+
initializer "rivet_cms.append_migrations" do |app|
|
|
25
|
+
config.paths["db/migrate"].expanded.each do |expanded_path|
|
|
26
|
+
unless app.config.paths["db/migrate"].expanded.include?(expanded_path)
|
|
27
|
+
app.config.paths["db/migrate"] << expanded_path
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Register the precompiled admin bundles for Sprockets, which (unlike
|
|
33
|
+
# Propshaft) only precompiles an explicit list. Without this a Sprockets
|
|
34
|
+
# host raises AssetNotFound in production. The Array guard means it fires
|
|
35
|
+
# only under Sprockets; Propshaft precompiles the builds directory anyway.
|
|
36
|
+
initializer "rivet_cms.assets" do |app|
|
|
37
|
+
next unless app.config.respond_to?(:assets)
|
|
38
|
+
|
|
39
|
+
precompile = app.config.assets.precompile
|
|
40
|
+
app.config.assets.precompile |= %w[rivet_cms.js rivet_cms.css] if precompile.is_a?(Array)
|
|
35
41
|
end
|
|
36
42
|
end
|
|
37
|
-
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Lifecycle event registry: the extension seam host apps (and the Pro
|
|
3
|
+
# engine) attach through. Subscribers must not raise into the publish
|
|
4
|
+
# path; failures are logged and swallowed.
|
|
5
|
+
#
|
|
6
|
+
# RivetCms::Hooks.on(:publish) { |revision| ... }
|
|
7
|
+
#
|
|
8
|
+
# Registration is idempotent per key: re-registering with the same key
|
|
9
|
+
# replaces the previous handler instead of adding a duplicate. Anonymous
|
|
10
|
+
# handlers key on their own identity, which is stable when registration
|
|
11
|
+
# happens in an initializer (runs once per boot). Code that registers from
|
|
12
|
+
# a reloadable context (e.g. to_prepare) must pass an explicit key, or
|
|
13
|
+
# every reload adds another copy:
|
|
14
|
+
#
|
|
15
|
+
# RivetCms::Hooks.on(:publish, key: :sitemap) { |revision| ... }
|
|
16
|
+
#
|
|
17
|
+
# Subscribers run in registration order; there is no ordering API, so no
|
|
18
|
+
# subscriber may depend on running before or after another.
|
|
19
|
+
#
|
|
20
|
+
# Extensions can define their own events before subscribing or firing:
|
|
21
|
+
#
|
|
22
|
+
# RivetCms::Hooks.register_event(:unpublish)
|
|
23
|
+
#
|
|
24
|
+
# Built-in events:
|
|
25
|
+
# :publish - a document revision was published (fires after commit,
|
|
26
|
+
# receives the published snapshot revision)
|
|
27
|
+
# :prune - a superseded revision is about to be destroyed by retention
|
|
28
|
+
# (fires inside the publish transaction, before the delete, so
|
|
29
|
+
# a subscriber can archive it elsewhere first)
|
|
30
|
+
# :audit - an admin mutation happened (receives a RivetCms::AuditEvent;
|
|
31
|
+
# see RivetCms::Audit for the payload contract)
|
|
32
|
+
module Hooks
|
|
33
|
+
BUILT_IN_EVENTS = %i[publish prune audit].freeze
|
|
34
|
+
MUTEX = Mutex.new
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def register_event(event)
|
|
38
|
+
mutex.synchronize { known_events << event.to_sym }
|
|
39
|
+
event.to_sym
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def events
|
|
43
|
+
mutex.synchronize { known_events.to_a }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def on(event, callable = nil, key: nil, &block)
|
|
47
|
+
handler = callable || block
|
|
48
|
+
raise ArgumentError, "handler required" if handler.nil?
|
|
49
|
+
|
|
50
|
+
mutex.synchronize do
|
|
51
|
+
raise ArgumentError, "unknown event: #{event} (register_event it first)" unless known_events.include?(event)
|
|
52
|
+
|
|
53
|
+
registry[event][key || handler] = handler
|
|
54
|
+
end
|
|
55
|
+
handler
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run(event, *args)
|
|
59
|
+
handlers = mutex.synchronize { registry.key?(event) ? registry[event].values : [] }
|
|
60
|
+
handlers.each do |handler|
|
|
61
|
+
handler.call(*args)
|
|
62
|
+
rescue => error
|
|
63
|
+
Rails.logger&.error("[RivetCms] #{event} hook failed: #{error.class}: #{error.message}")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Test-only: capture subscriptions so an example can register handlers
|
|
68
|
+
# and hand the registry back exactly as it found it.
|
|
69
|
+
def snapshot
|
|
70
|
+
mutex.synchronize { registry.transform_values(&:dup) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def restore(snapshot)
|
|
74
|
+
return if snapshot.nil? # never mistake a failed snapshot for "no subscribers"
|
|
75
|
+
|
|
76
|
+
mutex.synchronize do
|
|
77
|
+
@registry = Hash.new { |hash, event| hash[event] = {} }
|
|
78
|
+
snapshot&.each { |event, handlers| @registry[event] = handlers.dup }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Test-only: wipes ALL subscribers including the engine's boot-time
|
|
83
|
+
# registrations (initializers do not rerun). Pair with snapshot/restore.
|
|
84
|
+
def reset!
|
|
85
|
+
mutex.synchronize do
|
|
86
|
+
@registry = nil
|
|
87
|
+
@known_events = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def mutex
|
|
94
|
+
MUTEX
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def registry
|
|
98
|
+
@registry ||= Hash.new { |hash, event| hash[event] = {} }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def known_events
|
|
102
|
+
@known_events ||= Set.new(BUILT_IN_EVENTS)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Admin sidebar registry: the seam the Pro engine adds nav items through.
|
|
3
|
+
# Core registers its own items here too, so every item, core or extension,
|
|
4
|
+
# goes through the same can? filter and a denied user never sees a link
|
|
5
|
+
# that would 403.
|
|
6
|
+
#
|
|
7
|
+
# RivetCms::Navigation.register :audit_log,
|
|
8
|
+
# label: "Audit Log",
|
|
9
|
+
# section: "Manage",
|
|
10
|
+
# icon: :content,
|
|
11
|
+
# requires: [:read, :content],
|
|
12
|
+
# path: -> { audit_log_path },
|
|
13
|
+
# position: 80
|
|
14
|
+
#
|
|
15
|
+
# path is either a string or a lambda instance_exec'd in the controller, so
|
|
16
|
+
# engine route helpers work directly. requires is [action, resource] for
|
|
17
|
+
# can?, or nil for always visible. icon names one of the built-in sidebar
|
|
18
|
+
# icons; unknown names render a neutral dot. Items sort by position across
|
|
19
|
+
# sections; a section appears where its lowest-position item falls.
|
|
20
|
+
# Re-registering a key replaces the item, so registration is reload-safe.
|
|
21
|
+
module Navigation
|
|
22
|
+
Item = Struct.new(:key, :label, :section, :icon, :requires, :path, :position, :exact, :badge, :visible, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
MUTEX = Mutex.new
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def register(key, label:, section:, path:, icon: nil, requires: nil, position: 100, exact: false, badge: nil, visible: nil)
|
|
28
|
+
raise ArgumentError, "label required" if label.to_s.strip.empty?
|
|
29
|
+
raise ArgumentError, "path must be a String or callable" unless path.is_a?(String) || path.respond_to?(:call)
|
|
30
|
+
# Class equality, not is_a?: ActiveSupport::Duration answers is_a?(Integer)
|
|
31
|
+
raise ArgumentError, "position must be an Integer" unless position.class == Integer
|
|
32
|
+
raise ArgumentError, "badge must be callable" unless badge.nil? || badge.respond_to?(:call)
|
|
33
|
+
raise ArgumentError, "visible must be callable" unless visible.nil? || visible.respond_to?(:call)
|
|
34
|
+
unless requires.nil? || (requires.is_a?(Array) && requires.size == 2 && requires.all? { |part| part.respond_to?(:to_sym) })
|
|
35
|
+
raise ArgumentError, "requires must be nil or [action, resource]"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
item = Item.new(
|
|
39
|
+
key: key.to_sym, label: label.to_s, section: section.to_s, icon: icon&.to_sym,
|
|
40
|
+
requires: requires&.map(&:to_sym), path: path, position: position, exact: exact == true,
|
|
41
|
+
badge: badge, visible: visible
|
|
42
|
+
)
|
|
43
|
+
mutex.synchronize { registry[item.key] = item }
|
|
44
|
+
item
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def unregister(key)
|
|
48
|
+
mutex.synchronize { registry.delete(key.to_sym) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def items
|
|
52
|
+
mutex.synchronize { registry.values.sort_by.with_index { |item, index| [ item.position, index ] } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Test-only: capture registrations so an example can add items and hand
|
|
56
|
+
# the registry back exactly as it found it.
|
|
57
|
+
def snapshot
|
|
58
|
+
mutex.synchronize { registry.dup }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def restore(snapshot)
|
|
62
|
+
return if snapshot.nil? # never mistake a failed snapshot for "no items"
|
|
63
|
+
|
|
64
|
+
mutex.synchronize { @registry = snapshot.dup }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def mutex
|
|
70
|
+
MUTEX
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def registry
|
|
74
|
+
@registry ||= {}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Core items carry the same gates their index actions enforce, so hiding
|
|
79
|
+
# and denying can never disagree.
|
|
80
|
+
register :dashboard, label: "Dashboard", section: "", icon: :dashboard,
|
|
81
|
+
path: -> { root_path }, position: 10, exact: true
|
|
82
|
+
register :content, label: "Content", section: "Manage", icon: :content,
|
|
83
|
+
requires: [ :read, :content ], path: -> { content_path }, position: 20
|
|
84
|
+
register :content_types, label: "Content Types", section: "Manage", icon: :content_types,
|
|
85
|
+
requires: [ :read, :schema ], path: -> { content_types_path }, position: 30
|
|
86
|
+
register :components, label: "Components", section: "Manage", icon: :components,
|
|
87
|
+
requires: [ :read, :schema ], path: -> { components_path }, position: 40
|
|
88
|
+
register :media, label: "Media", section: "Manage", icon: :media,
|
|
89
|
+
requires: [ :read, :media ], path: -> { media_assets_path }, position: 50
|
|
90
|
+
# The badge counts what the trash page would show this user: entries
|
|
91
|
+
# always (the item itself is content-gated), removed types only with
|
|
92
|
+
# schema read, mirroring the page's sections. Within a domain the count
|
|
93
|
+
# is an aggregate, not filtered per record, like stat counts.
|
|
94
|
+
register :trash, label: "Trash", section: "Manage", icon: :trash,
|
|
95
|
+
requires: [ :read, :content ], path: -> { trash_path }, position: 55,
|
|
96
|
+
badge: -> {
|
|
97
|
+
count = Document.with_discarded.discarded.in_visible_types.where(organization: Current.organization).count
|
|
98
|
+
count += ContentType.with_discarded.discarded.where(organization: Current.organization).count if can?(:read, :schema)
|
|
99
|
+
count
|
|
100
|
+
}
|
|
101
|
+
# Only exists in built-in auth mode; hosts with their own auth manage
|
|
102
|
+
# users in their own admin
|
|
103
|
+
register :users, label: "Users", section: "Manage", icon: :users,
|
|
104
|
+
requires: [ :read, :users ], path: -> { users_path }, position: 58,
|
|
105
|
+
visible: -> { RivetCms.builtin_auth? }
|
|
106
|
+
register :api, label: "API", section: "Deliver", icon: :api,
|
|
107
|
+
requires: [ :read, :api ], path: -> { api_docs_path }, position: 60
|
|
108
|
+
register :api_tokens, label: "API Tokens", section: "Deliver", icon: :api_tokens,
|
|
109
|
+
requires: [ :read, :api ], path: -> { api_tokens_path }, position: 70
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Compiles and matches admin-supplied regex patterns without letting a
|
|
3
|
+
# pathological pattern hang the process. Ruby 3.2+ supports a per-regexp
|
|
4
|
+
# timeout; on older Rubies patterns still work, bounded only by MAX_LENGTH.
|
|
5
|
+
module SafePattern
|
|
6
|
+
MAX_LENGTH = 200
|
|
7
|
+
TIMEOUT_SECONDS = 1
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def valid?(source)
|
|
11
|
+
return false if source.to_s.length > MAX_LENGTH
|
|
12
|
+
|
|
13
|
+
compile(source)
|
|
14
|
+
true
|
|
15
|
+
rescue RegexpError, ArgumentError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def match?(source, value)
|
|
20
|
+
compile(source).match?(value.to_s)
|
|
21
|
+
rescue RegexpError, ArgumentError
|
|
22
|
+
false
|
|
23
|
+
rescue => error
|
|
24
|
+
raise unless defined?(Regexp::TimeoutError) && error.is_a?(Regexp::TimeoutError)
|
|
25
|
+
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def compile(source)
|
|
32
|
+
if Regexp.respond_to?(:timeout)
|
|
33
|
+
Regexp.new(source.to_s, timeout: TIMEOUT_SECONDS)
|
|
34
|
+
else
|
|
35
|
+
Regexp.new(source.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|