rivet_cms 0.1.1.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 +175 -6
  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 +65 -42
  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 -39
@@ -0,0 +1,122 @@
1
+ module RivetCms
2
+ class SessionsController < AuthController
3
+ LOGIN_ATTEMPT_LIMIT = 10
4
+ LOGIN_ATTEMPT_WINDOW = 5.minutes
5
+
6
+ # Memoized on the class, computed on first use so BCrypt is referenced at
7
+ # request time (after bcrypt is required), never at load time
8
+ def self.dummy_digest
9
+ @dummy_digest ||= BCrypt::Password.create("rivet-cms-timing-equalizer").to_s
10
+ end
11
+
12
+ def new
13
+ return redirect_to root_path if builtin_session_user
14
+ return redirect_to setup_path if users.none?
15
+
16
+ render inertia: "Auth/Login", props: { submit_path: login_path }
17
+ end
18
+
19
+ def create
20
+ # Reserve an attempt atomically BEFORE authenticating, so a parallel
21
+ # burst cannot slip every request through BCrypt before any counter
22
+ # reaches the limit. The increment is the reservation.
23
+ unless reserve_attempt
24
+ return redirect_to login_path,
25
+ inertia: { errors: { base: [ "Too many attempts. Wait a few minutes and try again" ] } }
26
+ end
27
+
28
+ user = users.active.find_by(email: submitted_email)
29
+ if user&.can_sign_in? && user.authenticate(params[:password].to_s)
30
+ release_reservation_on_success
31
+ sign_in(user)
32
+ redirect_to root_path
33
+ else
34
+ equalize_timing unless user&.can_sign_in?
35
+ # Failure keeps its reservation; that is the point of the counters
36
+ redirect_to login_path, inertia: { errors: { base: [ "That email and password combination does not work" ] } }
37
+ end
38
+ end
39
+
40
+ def destroy
41
+ reset_session
42
+ redirect_to login_path, status: :see_other
43
+ end
44
+
45
+ private
46
+
47
+ # A BCrypt comparison runs on every login attempt, so an attacker cannot
48
+ # tell known emails (expensive compare) from unknown ones (fast return)
49
+ # by timing; only its cost matters, never its content.
50
+ def equalize_timing
51
+ BCrypt::Password.new(self.class.dummy_digest).is_password?(params[:password].to_s)
52
+ end
53
+
54
+ # Two independent counters guard login: one per source IP, one per target
55
+ # account (scoped by organization, so the same email in another tenant is
56
+ # a separate counter). See reserve_attempt for the ordering that keeps the
57
+ # two from corrupting each other, and release_reservation_on_success for
58
+ # what a successful login does and does not clear.
59
+ #
60
+ # This needs a shared, incrementing cache (Redis, Memcached) to bind
61
+ # multiple workers; on a per-process MemoryStore it is per-worker, and on
62
+ # the NullStore it does nothing. It is a floor, not a replacement for
63
+ # rack-attack or a WAF at the edge.
64
+ def submitted_email
65
+ params[:email].to_s.strip.downcase
66
+ end
67
+
68
+ def ip_throttle_key
69
+ "rivet_cms:login_attempts:ip:#{request.remote_ip}"
70
+ end
71
+
72
+ def account_throttle_key(email = submitted_email)
73
+ "rivet_cms:login_attempts:account:#{Current.organization&.id}:#{email}"
74
+ end
75
+
76
+ # Reserve one IP attempt, then one account attempt, checking each before
77
+ # reserving the next. Returns false (throttled) without ever touching the
78
+ # account counter once the IP is over limit, so a blocked IP cannot lock
79
+ # arbitrary accounts. If the account is over limit, the IP reservation
80
+ # this request just made is released, so an already-locked account does
81
+ # not drain the shared IP budget.
82
+ def reserve_attempt
83
+ return false if over_limit?(bump(ip_throttle_key))
84
+
85
+ if over_limit?(bump(account_throttle_key))
86
+ unbump(ip_throttle_key)
87
+ return false
88
+ end
89
+ true
90
+ end
91
+
92
+ def over_limit?(count)
93
+ # nil (NullStore) means throttling is disabled, never over limit
94
+ count && count > LOGIN_ATTEMPT_LIMIT
95
+ end
96
+
97
+ # A success proves ownership of this account, so clear its counter; but it
98
+ # only releases this request's own IP reservation, never the whole IP
99
+ # history, so one account's success cannot erase IP-wide spray protection.
100
+ def release_reservation_on_success
101
+ unbump(ip_throttle_key)
102
+ Rails.cache.delete(account_throttle_key)
103
+ end
104
+
105
+ def bump(key)
106
+ # increment is atomic on stores that support it; it seeds the key on a
107
+ # real store, and returns nil on the NullStore (throttling disabled)
108
+ Rails.cache.increment(key, 1, expires_in: LOGIN_ATTEMPT_WINDOW)
109
+ end
110
+
111
+ # Release a reservation only if it is still live. A bare decrement on an
112
+ # expired key recreates it as a non-expiring -1, a permanent counter that
113
+ # later increments accumulate on and can eventually lock the IP forever.
114
+ # The exist? check skips that; expires_in bounds the entry anyway if the
115
+ # key expires in the race between the check and the decrement.
116
+ def unbump(key)
117
+ return unless Rails.cache.exist?(key)
118
+
119
+ Rails.cache.decrement(key, 1, expires_in: LOGIN_ATTEMPT_WINDOW)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,60 @@
1
+ module RivetCms
2
+ # First-run screen: with no users at all, the first visit creates the owner
3
+ # account. Gone the moment one user exists; never a registration form.
4
+ #
5
+ # Outside development/test a fresh deployment cannot be claimed by whoever
6
+ # finds it first: setup demands a code that is written to the server log
7
+ # (or configured via RivetCms.setup_code), so claiming requires server
8
+ # access. The Jenkins initial-password pattern.
9
+ class SetupController < AuthController
10
+ before_action :ensure_first_run
11
+
12
+ def new
13
+ log_setup_code
14
+ render inertia: "Auth/Setup", props: { submit_path: setup_path, requires_code: RivetCms.setup_code_required? }
15
+ end
16
+
17
+ def create
18
+ unless setup_code_valid?
19
+ return redirect_to setup_path,
20
+ inertia: { errors: { base: [ "That setup code is not right. It is printed in the server log." ] } }
21
+ end
22
+ if params[:password].blank?
23
+ # has_secure_password ignores an empty string entirely, which would
24
+ # otherwise create a passwordless owner and sign them in
25
+ return redirect_to setup_path, inertia: { errors: { password: [ "can't be blank" ] } }
26
+ end
27
+
28
+ user = users.new(user_params)
29
+
30
+ if user.save
31
+ sign_in(user)
32
+ redirect_to root_path, notice: "Welcome to RivetCMS"
33
+ else
34
+ redirect_to setup_path, inertia: { errors: user.errors }
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def ensure_first_run
41
+ redirect_to login_path if users.any?
42
+ end
43
+
44
+ def setup_code_valid?
45
+ return true unless RivetCms.setup_code_required?
46
+
47
+ ActiveSupport::SecurityUtils.secure_compare(params[:setup_code].to_s, RivetCms.setup_code)
48
+ end
49
+
50
+ def log_setup_code
51
+ return unless RivetCms.setup_code_required?
52
+
53
+ Rails.logger&.info("[RivetCms] First-run setup code: #{RivetCms.setup_code}")
54
+ end
55
+
56
+ def user_params
57
+ params.permit(:name, :email, :password)
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,68 @@
1
+ module RivetCms
2
+ # One place to find everything trashed, so recovering does not depend on
3
+ # remembering which content type something belonged to. Entries of removed
4
+ # types are represented by their type below: restoring the type brings its
5
+ # entries back with it.
6
+ class TrashController < ApplicationController
7
+ include InertiaProps
8
+
9
+ before_action -> { authorize! :read, :content }
10
+
11
+ def show
12
+ scope = trashed_entries
13
+ if params[:type].present?
14
+ scope = scope.joins(:content_type).where(rivet_cms_content_types: { slug: params[:type] })
15
+ end
16
+ scope = scope.search(params[:q]) if params[:q].present?
17
+ page = scope.includes(:content_type, :draft_revision).order(deleted_at: :desc).page(params[:page]).per(25)
18
+ entries = permitted_documents(page)
19
+ titles = document_titles(entries)
20
+
21
+ render inertia: "Trash/Show", props: {
22
+ documents: entries.map { |document|
23
+ document_list_props(document, titles).merge(
24
+ content_type_name: document.content_type.name,
25
+ trashed_at: document.deleted_at.iso8601,
26
+ paths: {
27
+ restore: restore_content_type_document_path(document.content_type, document),
28
+ purge: purge_content_type_document_path(document.content_type, document)
29
+ }
30
+ )
31
+ },
32
+ q: params[:q].presence,
33
+ type: params[:type].presence,
34
+ # Filter options come from what is actually trashed, like media kinds
35
+ types: filter_types,
36
+ pagination: { page: page.current_page, total_pages: page.total_pages },
37
+ content_types: removed_content_types
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def trashed_entries
44
+ Document.with_discarded.discarded.in_visible_types.where(organization: Current.organization)
45
+ end
46
+
47
+ def filter_types
48
+ permitted(ContentType.where(id: trashed_entries.distinct.select(:content_type_id)).order(:name), :read, :content)
49
+ .map { |content_type| { slug: content_type.slug, name: content_type.name } }
50
+ end
51
+
52
+ # Removed types are schema data; without schema read the section is empty
53
+ def removed_content_types
54
+ return [] unless can?(:read, :schema)
55
+
56
+ removed = permitted(ContentType.with_discarded.discarded.where(organization: Current.organization).order(:deleted_at), :read, :schema)
57
+ entry_counts = Document.with_discarded.where(content_type_id: removed.map(&:id)).group(:content_type_id).count
58
+
59
+ removed.map { |content_type|
60
+ content_type_props(content_type).merge(
61
+ removed_at: content_type.deleted_at.iso8601,
62
+ documents_count: entry_counts.fetch(content_type.id, 0),
63
+ paths: { restore: restore_content_type_path(content_type), purge: purge_content_type_path(content_type) }
64
+ )
65
+ }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,117 @@
1
+ module RivetCms
2
+ # User management for built-in authentication mode. Hidden and denied under
3
+ # host auth. CE has no roles, so this is add / edit / deactivate only; every
4
+ # user can do everything. Users are deactivated, never deleted, since audit
5
+ # events and content attribution reference them.
6
+ #
7
+ # The authorize! :users gates below pass for everyone under CE's allow-all
8
+ # policy; they are the seam through which Pro restricts user management to
9
+ # its own roles.
10
+ class UsersController < ApplicationController
11
+ before_action :ensure_builtin_mode
12
+ before_action -> { authorize! :read, :users }, only: [ :index ]
13
+ before_action -> { authorize! :write, :users }, except: [ :index ]
14
+ before_action :set_user, except: [ :index, :create ]
15
+ before_action -> { authorize! :write, :users, record: @user }, except: [ :index, :create ]
16
+
17
+ def index
18
+ render inertia: "Users/Index", props: {
19
+ users: users.order(:created_at).map { |user| user_props(user) },
20
+ invite_link: flash[:invite_link]
21
+ }
22
+ end
23
+
24
+ def create
25
+ user = users.new(user_params)
26
+
27
+ if user.save
28
+ audit "user.created", user
29
+ flash[:invite_link] = invitation_url(user.generate_token_for(:password_setup))
30
+ redirect_to users_path, notice: "#{user.name} was added. Share the sign-in link below; it is shown only once."
31
+ else
32
+ redirect_to users_path, inertia: { errors: user.errors }
33
+ end
34
+ end
35
+
36
+ def update
37
+ if @user.update(user_params)
38
+ audit "user.updated", @user
39
+ redirect_to users_path, notice: "#{@user.name} was updated"
40
+ else
41
+ redirect_to users_path, inertia: { errors: @user.errors }
42
+ end
43
+ end
44
+
45
+ def deactivate
46
+ if @user == Current.user
47
+ return redirect_to users_path, alert: "You cannot deactivate your own account"
48
+ end
49
+
50
+ locked_out = User.transaction do
51
+ # Lock every user who can actually sign in (active with a password) so
52
+ # two concurrent deactivations serialize instead of both seeing "one
53
+ # other can still sign in" and leaving nobody who can. Pending
54
+ # invitees have no password and do not count. (No-op on SQLite,
55
+ # honored on Postgres/MySQL.)
56
+ signin_ids = users.active.where.not(password_digest: nil).order(:id).lock.pluck(:id)
57
+ next true if signin_ids.include?(@user.id) && (signin_ids - [ @user.id ]).empty?
58
+
59
+ @user.update!(active: false)
60
+ false
61
+ end
62
+
63
+ if locked_out
64
+ return redirect_to users_path, alert: "At least one active user must remain, or no one could sign in"
65
+ end
66
+
67
+ audit "user.deactivated", @user
68
+ redirect_to users_path, notice: "#{@user.name} was deactivated and can no longer sign in"
69
+ end
70
+
71
+ def reactivate
72
+ @user.update!(active: true)
73
+ audit "user.reactivated", @user
74
+ redirect_to users_path, notice: "#{@user.name} was reactivated"
75
+ end
76
+
77
+ def reset_link
78
+ flash[:invite_link] = invitation_url(@user.generate_token_for(:password_setup))
79
+ redirect_to users_path, notice: "New sign-in link for #{@user.name}; it is shown only once."
80
+ end
81
+
82
+ private
83
+
84
+ def ensure_builtin_mode
85
+ head :not_found unless RivetCms.builtin_auth?
86
+ end
87
+
88
+ def users
89
+ User.where(organization: Current.organization)
90
+ end
91
+
92
+ def set_user
93
+ @user = users.find(params[:id])
94
+ end
95
+
96
+ def user_params
97
+ params.permit(:name, :email)
98
+ end
99
+
100
+ def user_props(user)
101
+ {
102
+ id: user.prefix_id,
103
+ name: user.name,
104
+ email: user.email,
105
+ status: user.status,
106
+ yourself: user == Current.user,
107
+ created_at: user.created_at.iso8601,
108
+ paths: {
109
+ update: user_path(user),
110
+ deactivate: deactivate_user_path(user),
111
+ reactivate: reactivate_user_path(user),
112
+ reset_link: reset_link_user_path(user)
113
+ }
114
+ }
115
+ end
116
+ end
117
+ end
@@ -1,7 +1,17 @@
1
1
  module RivetCms
2
2
  module ApplicationHelper
3
- include RivetCms::BrandColorHelper
4
- include RivetCms::SignOutHelper
5
- include RivetCms::FlashHelper
3
+ # Fails open on the page, closed on the asset: a registered extension
4
+ # bundle that cannot be resolved is logged and skipped instead of 500ing
5
+ # every admin page. Matches the can? posture of degrading rather than
6
+ # taking the whole admin down over an initializer typo.
7
+ def admin_extension_tag(kind, name)
8
+ case kind
9
+ when :script then javascript_include_tag(name, type: "module", defer: true)
10
+ when :stylesheet then stylesheet_link_tag(name, media: "all")
11
+ end
12
+ rescue StandardError => error
13
+ Rails.logger&.error("[RivetCms] admin #{kind} #{name.inspect} could not be rendered (skipping): #{error.class}: #{error.message}")
14
+ nil
15
+ end
6
16
  end
7
17
  end
@@ -0,0 +1,4 @@
1
+ module RivetCms
2
+ module ComponentsHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module RivetCms
2
+ module ContentTypesHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module RivetCms
2
+ module DashboardHelper
3
+ end
4
+ end
@@ -0,0 +1,35 @@
1
+ require "net/http"
2
+
3
+ module RivetCms
4
+ # Basic CE webhook delivery: one POST, JSON body, short timeouts, no
5
+ # signing. Any failure (connection error or non-2xx response) raises, so
6
+ # retry behavior uniformly follows the host's queue adapter defaults.
7
+ class WebhookDeliveryJob < ApplicationJob
8
+ # Webhook URLs are often capability URLs (tokens in path or query):
9
+ # keep them out of job argument logs, and log only the host on failure.
10
+ self.log_arguments = false
11
+
12
+ OPEN_TIMEOUT = 5
13
+ READ_TIMEOUT = 10
14
+ WRITE_TIMEOUT = 10
15
+
16
+ class DeliveryError < StandardError; end
17
+
18
+ def perform(url, payload)
19
+ uri = URI.parse(url)
20
+ http = Net::HTTP.new(uri.hostname, uri.port)
21
+ http.use_ssl = uri.scheme == "https"
22
+ http.open_timeout = OPEN_TIMEOUT
23
+ http.read_timeout = READ_TIMEOUT
24
+ http.write_timeout = WRITE_TIMEOUT
25
+
26
+ request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "User-Agent" => "RivetCMS/#{RivetCms::VERSION}")
27
+ request.body = payload.to_json
28
+ response = http.request(request)
29
+
30
+ unless response.is_a?(Net::HTTPSuccess)
31
+ raise DeliveryError, "webhook to #{uri.host} responded #{response.code}"
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,31 @@
1
+ module RivetCms
2
+ # Shared by content types and components. The fields association is
3
+ # soft-delete scoped (kept only), so dependent: :destroy would leave
4
+ # discarded fields holding the FK — destroy those explicitly on delete.
5
+ module HasFields
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_many :fields, dependent: :destroy
10
+ before_destroy :destroy_discarded_fields, prepend: true
11
+ end
12
+
13
+ def all_fields
14
+ fields.with_discarded
15
+ end
16
+
17
+ def active_fields
18
+ fields
19
+ end
20
+
21
+ def discarded_fields
22
+ fields.with_discarded.discarded
23
+ end
24
+
25
+ private
26
+
27
+ def destroy_discarded_fields
28
+ discarded_fields.destroy_all
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ module RivetCms
2
+ module OrganizationScoped
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ belongs_to :organization
7
+ before_validation :assign_default_organization
8
+ validates :organization, presence: true
9
+ end
10
+
11
+ private
12
+
13
+ def assign_default_organization
14
+ self.organization ||= RivetCms::Current.organization
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ module RivetCms
2
+ class RevisionImmutableError < StandardError; end
3
+
4
+ module RevisionOwned
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ belongs_to :owner, polymorphic: true
9
+ before_update :guard_published_owner!
10
+ validate :field_matches_owner_organization
11
+ end
12
+
13
+ def owning_revision
14
+ node = owner
15
+ node = node.owner while node.is_a?(ComponentInstance)
16
+ node if node.is_a?(DocumentRevision)
17
+ end
18
+
19
+ def owner_organization_id
20
+ owning_revision&.document&.organization_id
21
+ end
22
+
23
+ private
24
+
25
+ def guard_published_owner!
26
+ raise RevisionImmutableError, "cannot modify a published revision" if owning_revision&.published?
27
+ end
28
+
29
+ def field_matches_owner_organization
30
+ return if field.nil? || owner_organization_id.nil?
31
+ return if field.organization_id == owner_organization_id
32
+
33
+ errors.add(:field, "must belong to the same organization")
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,38 @@
1
+ module RivetCms
2
+ module Sluggable
3
+ extend ActiveSupport::Concern
4
+
5
+ SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
6
+
7
+ included do
8
+ before_validation :generate_slug, if: -> { slug.blank? && respond_to?(:name) && name.present? }
9
+
10
+ validates :slug, presence: true,
11
+ format: { with: SLUG_FORMAT, message: "must be lowercase alphanumeric with hyphens" }
12
+ end
13
+
14
+ private
15
+
16
+ def generate_slug
17
+ base_slug = name.parameterize
18
+ self.slug = unique_slug(base_slug)
19
+ end
20
+
21
+ def unique_slug(base_slug)
22
+ slug = base_slug
23
+ counter = 1
24
+ scope = self.class.unscoped.where.not(id: id)
25
+
26
+ if respond_to?(:organization_id) && organization_id.present?
27
+ scope = scope.where(organization_id: organization_id)
28
+ end
29
+
30
+ while scope.exists?(slug: slug)
31
+ slug = "#{base_slug}-#{counter}"
32
+ counter += 1
33
+ end
34
+
35
+ slug
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ module RivetCms
2
+ module SoftDeletable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ scope :kept, -> { where(deleted_at: nil) }
7
+ scope :discarded, -> { where.not(deleted_at: nil) }
8
+
9
+ default_scope { kept }
10
+ end
11
+
12
+ class_methods do
13
+ def with_discarded
14
+ unscope(where: :deleted_at)
15
+ end
16
+ end
17
+
18
+ def discard
19
+ update(deleted_at: Time.current)
20
+ end
21
+
22
+ def discard!
23
+ update!(deleted_at: Time.current)
24
+ end
25
+
26
+ def undiscard
27
+ update(deleted_at: nil)
28
+ end
29
+
30
+ def undiscard!
31
+ update!(deleted_at: nil)
32
+ end
33
+
34
+ def discarded?
35
+ deleted_at.present?
36
+ end
37
+
38
+ def kept?
39
+ deleted_at.nil?
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,51 @@
1
+ module RivetCms
2
+ module TypedValue
3
+ extend ActiveSupport::Concern
4
+
5
+ VALUE_COLUMNS = %i[
6
+ string_value text_value integer_value boolean_value
7
+ decimal_value datetime_value date_value json_value
8
+ ].freeze
9
+
10
+ SCALAR_COLUMN = {
11
+ "string" => :string_value,
12
+ "text" => :text_value,
13
+ "rich_text" => :text_value,
14
+ "markdown" => :text_value,
15
+ "integer" => :integer_value,
16
+ "decimal" => :decimal_value,
17
+ "enumeration" => :string_value,
18
+ "boolean" => :boolean_value,
19
+ "date" => :date_value,
20
+ "datetime" => :datetime_value
21
+ }.freeze
22
+
23
+ def value
24
+ return media_asset if field&.attachment?
25
+
26
+ column = value_column
27
+ column ? self[column] : nil
28
+ end
29
+
30
+ def value=(raw)
31
+ if field&.attachment?
32
+ self.media_asset = MediaAsset.find_by(id: extract_media_asset_id(raw))
33
+ return
34
+ end
35
+
36
+ column = value_column
37
+ self[column] = raw if column
38
+ end
39
+
40
+ private
41
+
42
+ def extract_media_asset_id(raw)
43
+ raw = raw["id"] || raw[:id] if raw.is_a?(Hash)
44
+ raw.presence
45
+ end
46
+
47
+ def value_column
48
+ SCALAR_COLUMN[field&.field_type]
49
+ end
50
+ end
51
+ end