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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +203 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. 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
@@ -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: 'spec/factories'
8
+ g.factory_bot dir: "spec/factories"
23
9
  end
24
10
 
25
- def self.add_routes(&block)
26
- RivetCms::Routes.add_routes(&block)
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
- def self.append_routes(&block)
30
- RivetCms::Routes.append_routes(&block)
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
- def self.draw_routes(&block)
34
- RivetCms::Routes.draw_routes(&block)
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