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
@@ -1,5 +1,223 @@
1
1
  module RivetCms
2
- class ApplicationController < ActionController::Base
3
- #include RivetCmsAuth::Authentication
2
+ # Inherits from the host-configured parent controller so host auth filters and
3
+ # helpers are available. Do not reference this class from initializers — the
4
+ # parent is resolved when Zeitwerk first loads it.
5
+ class ApplicationController < RivetCms.parent_controller.constantize
6
+ layout "rivet_cms/application"
7
+
8
+ # Organization first: built-in authentication scopes its user lookup to it
9
+ before_action :set_current_organization
10
+ before_action :authenticate_rivet_user
11
+ before_action :set_rivet_current_user
12
+ after_action :set_csrf_cookie
13
+
14
+ inertia_config version: -> { RivetCms.asset_version }
15
+
16
+ inertia_share app_version: RivetCms::VERSION,
17
+ nav: -> { navigation_props },
18
+ media_accept: -> { RivetCms.allowed_media_types&.join(",") },
19
+ flash: -> { { notice: flash.notice, alert: flash.alert } },
20
+ auth: -> {
21
+ user = Current.user
22
+ { name: RivetCms.user_name.call(user), email: RivetCms.user_email.call(user) } if user
23
+ },
24
+ paths: -> {
25
+ {
26
+ root: root_path,
27
+ content: content_path,
28
+ media: media_assets_path,
29
+ content_types: content_types_path,
30
+ new_content_type: new_content_type_path,
31
+ content_types_trash: trash_content_types_path,
32
+ components: components_path,
33
+ new_component: new_component_path,
34
+ api_tokens: api_tokens_path,
35
+ api_docs: api_docs_path,
36
+ login: effective_login_path,
37
+ logout: effective_logout_path,
38
+ logout_method: RivetCms.builtin_auth? ? "delete" : RivetCms.logout_method
39
+ }
40
+ }
41
+
42
+ rescue_from RivetCms::AccessDenied, with: :deny_authorization
43
+
44
+ private
45
+
46
+ # Sidebar from the Navigation registry: items a user cannot reach are
47
+ # dropped here, so hiding and denying share one source of truth. Sections
48
+ # appear where their lowest-position item falls.
49
+ def navigation_props
50
+ sections = []
51
+ RivetCms::Navigation.items.each do |item|
52
+ next if item.visible && instance_exec(&item.visible) != true
53
+ next if item.requires && !can?(*item.requires)
54
+
55
+ path = item.path.respond_to?(:call) ? instance_exec(&item.path) : item.path
56
+ section = sections.find { |s| s[:section] == item.section } ||
57
+ (sections << { section: item.section, items: [] }).last
58
+ entry = { key: item.key, label: item.label, icon: item.icon, path: path, exact: item.exact }
59
+ badge = nav_badge(item)
60
+ entry[:badge] = badge if badge&.positive?
61
+ section[:items] << entry
62
+ end
63
+ sections
64
+ end
65
+
66
+ # A raising badge lambda loses its badge, not the sidebar
67
+ def nav_badge(item)
68
+ return nil unless item.badge
69
+
70
+ Integer(instance_exec(&item.badge))
71
+ rescue StandardError => error
72
+ Rails.logger&.error("[RivetCms] nav badge for #{item.key} raised (skipping): #{error.class}: #{error.message}")
73
+ nil
74
+ end
75
+
76
+ # Fails closed: a raising policy denies and logs rather than 500ing the admin
77
+ def can?(action, resource, record: nil)
78
+ check = RivetCms::AccessCheck.new(
79
+ user: Current.user, action: action, resource: resource,
80
+ organization: Current.organization, record: record
81
+ )
82
+ RivetCms.can.call(check) == true
83
+ rescue Exception => error
84
+ Rails.logger&.error("[RivetCms] can policy raised (denying): #{error.class}: #{error.message}")
85
+ false
86
+ end
87
+
88
+ def authorize!(action, resource, record: nil)
89
+ raise RivetCms::AccessDenied, "#{action} #{resource}" unless can?(action, resource, record: record)
90
+ end
91
+
92
+ # List surfaces must not show what a record's own page would refuse, so
93
+ # every list filters through the record phase. Filtering happens after
94
+ # pagination, so a page can come up short; stat counts stay aggregates.
95
+ def permitted(records, action, resource)
96
+ records.select { |record| can?(action, resource, record: record) }
97
+ end
98
+
99
+ # Cross-type entry lists check the entry and its type, so denying a type
100
+ # hides its entries everywhere the type itself is hidden
101
+ def permitted_documents(documents)
102
+ permitted(documents, :read, :content)
103
+ .select { |document| can?(:read, :content, record: document.content_type) }
104
+ end
105
+
106
+ # Trashing something should land back where the delete came from, except
107
+ # when the referer is a page that just died with the trashed record.
108
+ def redirect_after_trash(dead_path, fallback, **flash)
109
+ referer_path = begin
110
+ URI.parse(request.referer.to_s).path
111
+ rescue URI::Error
112
+ nil
113
+ end
114
+ if referer_path == dead_path
115
+ redirect_to fallback, status: :see_other, **flash
116
+ else
117
+ redirect_back fallback_location: fallback, allow_other_host: false, status: :see_other, **flash
118
+ end
119
+ end
120
+
121
+ # Emits onto the :audit stream; call after a mutation succeeded
122
+ def audit(action, subject, **metadata)
123
+ RivetCms::Audit.record(action, subject: subject, actor: Current.user,
124
+ organization: Current.organization, metadata: metadata)
125
+ end
126
+
127
+ def deny_authorization
128
+ message = "You do not have permission to do that"
129
+ # Inertia visits redirect (the client follows and shows the flash).
130
+ # Everything else that is not a plain browser page load — API clients,
131
+ # axios calls whose Accept header Rails reads as browser-like, any
132
+ # non-GET — must get a real 403 so failures surface as failures.
133
+ if !request.inertia? && (request.format.json? || request.xhr? || !request.get?)
134
+ render json: { errors: [ message ] }, status: :forbidden
135
+ else
136
+ # Redirecting to the page we just denied would loop, so only trust a
137
+ # referer that points somewhere else; the dashboard is always reachable.
138
+ referer_path = URI.parse(request.referer.to_s).path rescue nil
139
+ if referer_path.present? && referer_path != request.path
140
+ redirect_back fallback_location: root_path, allow_other_host: false,
141
+ status: (request.get? ? :found : :see_other), alert: message
142
+ else
143
+ redirect_to root_path, status: (request.get? ? :found : :see_other), alert: message
144
+ end
145
+ end
146
+ end
147
+
148
+ # Inertia (axios) reads the XSRF-TOKEN cookie and sends it back as a header.
149
+ def set_csrf_cookie
150
+ cookies["XSRF-TOKEN"] = form_authenticity_token
151
+ end
152
+
153
+ def authenticate_rivet_user
154
+ return builtin_authenticate if RivetCms.builtin_auth?
155
+
156
+ result = RivetCms.authenticate.call(self)
157
+ return if performed? # the lambda rendered/redirected on its own
158
+ return if result # truthy => allowed
159
+
160
+ deny_rivet_access # falsy and unhandled => fail closed
161
+ end
162
+
163
+ def builtin_authenticate
164
+ return if builtin_session_user
165
+
166
+ if request.inertia?
167
+ head :unauthorized
168
+ elsif RivetCms::User.where(organization: Current.organization).none?
169
+ redirect_to setup_path # first run: create the owner account
170
+ else
171
+ redirect_to login_path
172
+ end
173
+ end
174
+
175
+ def builtin_session_user
176
+ return @builtin_session_user if defined?(@builtin_session_user)
177
+
178
+ user = RivetCms::User.where(organization: Current.organization)
179
+ .find_by(id: session[:rivet_cms_user_id])
180
+ # can_sign_in?: deactivation and a wiped password both end the session.
181
+ # The salt comparison ends every session issued before a password change.
182
+ valid = user&.can_sign_in? &&
183
+ ActiveSupport::SecurityUtils.secure_compare(user.password_salt.to_s, session[:rivet_cms_password_salt].to_s)
184
+ @builtin_session_user = valid ? user : nil
185
+ end
186
+
187
+ def deny_rivet_access
188
+ if request.inertia?
189
+ head :unauthorized
190
+ elsif RivetCms.login_path.present?
191
+ redirect_to RivetCms.login_path
192
+ else
193
+ render plain: "RivetCms: authentication required.", status: :forbidden
194
+ end
195
+ end
196
+
197
+ def set_rivet_current_user
198
+ Current.user = RivetCms.builtin_auth? ? builtin_session_user : RivetCms.current_user.call(self)
199
+ end
200
+
201
+ def effective_login_path
202
+ RivetCms.builtin_auth? ? login_path : RivetCms.login_path
203
+ end
204
+
205
+ def effective_logout_path
206
+ RivetCms.builtin_auth? ? logout_path : RivetCms.logout_path
207
+ end
208
+
209
+ def set_current_organization
210
+ RivetCms::Current.organization =
211
+ Organization.find_by(domain: request.host) ||
212
+ Organization.find_by(domain: "localhost") ||
213
+ Organization.first ||
214
+ default_organization
215
+ end
216
+
217
+ def default_organization
218
+ return unless Rails.env.development? || Rails.env.test?
219
+
220
+ Organization.create!(name: "Development Org", domain: "localhost", subdomain: "dev")
221
+ end
4
222
  end
5
223
  end
@@ -0,0 +1,28 @@
1
+ module RivetCms
2
+ # Base for the built-in auth surfaces (login, first-run setup, invitation
3
+ # links). Reachable without a session by definition; hard 404 when the host
4
+ # brought its own authentication so they add no surface area in that mode.
5
+ class AuthController < ApplicationController
6
+ skip_before_action :authenticate_rivet_user
7
+ skip_before_action :set_rivet_current_user
8
+ before_action :ensure_builtin_mode
9
+
10
+ private
11
+
12
+ def ensure_builtin_mode
13
+ head :not_found unless RivetCms.builtin_auth?
14
+ end
15
+
16
+ def sign_in(user)
17
+ reset_session # session fixation
18
+ session[:rivet_cms_user_id] = user.id
19
+ # Binding the session to the password salt revokes every session the
20
+ # moment the password changes
21
+ session[:rivet_cms_password_salt] = user.password_salt
22
+ end
23
+
24
+ def users
25
+ User.where(organization: Current.organization)
26
+ end
27
+ end
28
+ end
@@ -1,7 +1,98 @@
1
1
  module RivetCms
2
2
  class ComponentsController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :schema }, only: [ :index, :show ]
6
+ before_action -> { authorize! :delete, :schema }, only: [ :destroy ]
7
+ before_action -> { authorize! :write, :schema }, except: [ :index, :show, :destroy ]
8
+ before_action :set_component, only: [ :show, :update, :destroy ]
9
+ # Record layer: same gates, about this specific component
10
+ before_action -> { authorize! :read, :schema, record: @component }, only: [ :show ]
11
+ before_action -> { authorize! :write, :schema, record: @component }, only: [ :update ]
12
+ before_action -> { authorize! :delete, :schema, record: @component }, only: [ :destroy ]
13
+
3
14
  def index
4
- @components = RivetCms::Component.all
15
+ field_counts = Field.where(component_id: Current.organization.components.select(:id)).group(:component_id).count
16
+
17
+ render inertia: "Components/Index", props: {
18
+ components: permitted(Current.organization.components.includes(:category), :read, :schema).map { |c|
19
+ component_props(c).merge(fields_count: field_counts.fetch(c.id, 0))
20
+ }
21
+ }
22
+ end
23
+
24
+ def new
25
+ render inertia: "Components/New", props: {
26
+ categories: Current.organization.categories.order(:name).map { |c| category_props(c) },
27
+ create_category_path: create_category_components_path
28
+ }
29
+ end
30
+
31
+ def create
32
+ component = Component.new(component_params)
33
+
34
+ if component.save
35
+ audit "component.created", component
36
+ redirect_to component_path(component), notice: "Component created successfully"
37
+ else
38
+ redirect_to new_component_path, inertia: { errors: component.errors }
39
+ end
40
+ end
41
+
42
+ def show
43
+ render inertia: "Components/Show", props: {
44
+ component: component_props(@component),
45
+ categories: Current.organization.categories.order(:name).map { |c| category_props(c) },
46
+ create_category_path: create_category_components_path,
47
+ fields: @component.fields.kept.ordered.map { |f| field_props(f) },
48
+ field_types: Field::FIELD_TYPE_LABELS.except("component"),
49
+ reference_targets: permitted(Current.organization.content_types.order(:name), :read, :schema).map { |ct| { id: ct.id, name: ct.name } },
50
+ embeddable_components: []
51
+ }
52
+ end
53
+
54
+ def update
55
+ if @component.update(component_params)
56
+ audit "component.updated", @component
57
+ redirect_to component_path(@component), notice: "Component updated successfully"
58
+ else
59
+ redirect_to component_path(@component), inertia: { errors: @component.errors }
60
+ end
61
+ end
62
+
63
+ def destroy
64
+ @component.destroy
65
+ audit "component.deleted", @component
66
+ redirect_to components_path, notice: "Component deleted"
67
+ rescue ActiveRecord::InvalidForeignKey
68
+ redirect_to components_path, alert: "Component is in use and cannot be deleted"
69
+ end
70
+
71
+ def create_category
72
+ category = Category.new(category_params)
73
+ category.slug = category.name.to_s.parameterize if category.slug.blank?
74
+ category.system = false if category.system.nil?
75
+
76
+ if category.save
77
+ audit "category.created", category
78
+ render json: { id: category.id, name: category.name }
79
+ else
80
+ render json: { errors: category.errors.full_messages }, status: :unprocessable_entity
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def set_component
87
+ @component = Current.organization.components.find(params[:id])
88
+ end
89
+
90
+ def component_params
91
+ params.require(:component).permit(:name, :slug, :description, :category_id)
92
+ end
93
+
94
+ def category_params
95
+ params.require(:category).permit(:name)
5
96
  end
6
97
  end
7
- end
98
+ end
@@ -0,0 +1,105 @@
1
+ module RivetCms
2
+ class ContentController < ApplicationController
3
+ # The read API is token-gated (or public when RivetCms.public_api), so host
4
+ # admin auth/CSRF must not run, and the org comes from the token, not the host.
5
+ skip_before_action :authenticate_rivet_user
6
+ skip_before_action :set_rivet_current_user
7
+ skip_before_action :set_current_organization
8
+ skip_after_action :set_csrf_cookie
9
+
10
+ before_action :resolve_api_access
11
+
12
+ def index
13
+ populate = query.populate_fields
14
+
15
+ page = query.documents
16
+ revisions = page.map(&:published_revision)
17
+ preload = RevisionPreloader.new(revisions, populate_fields: populate)
18
+
19
+ render json: {
20
+ data: revisions.map { |revision|
21
+ RevisionSerializer.new(revision, fields: query.field_keys, populate: populate, preload: preload).as_json
22
+ },
23
+ meta: { page: page.current_page, per_page: page.limit_value, total: page.total_count, total_pages: page.total_pages }
24
+ }
25
+ end
26
+
27
+ def show
28
+ return head :forbidden if preview_requested? && !preview_scope?
29
+
30
+ populate = query.populate_fields
31
+
32
+ document = content_type.documents.find_by!(slug: params[:slug])
33
+ preview = preview_requested?
34
+ revision = preview ? (document.draft_revision || document.published_revision) : document.published_revision
35
+ return head :not_found if revision.nil?
36
+
37
+ preload = RevisionPreloader.new([ revision ], populate_fields: populate, preview: preview)
38
+ render json: RevisionSerializer.new(revision, fields: query.field_keys, populate: populate, preview: preview, preload: preload).as_json
39
+ end
40
+
41
+ private
42
+
43
+ def resolve_api_access
44
+ raw = bearer_token
45
+ if raw.present?
46
+ token = ApiToken.authenticate(raw)
47
+ return render_unauthorized if token.nil?
48
+
49
+ RivetCms::Current.organization = token.organization
50
+ @api_scope = token.scope.to_sym
51
+ token.touch_used!
52
+ elsif RivetCms.public_api
53
+ set_current_organization
54
+ @api_scope = :published
55
+ else
56
+ render_unauthorized
57
+ end
58
+ end
59
+
60
+ def bearer_token
61
+ request.authorization.to_s[/\ABearer (.+)\z/i, 1]
62
+ end
63
+
64
+ def render_unauthorized
65
+ response.set_header("WWW-Authenticate", 'Bearer realm="RivetCms delivery API"')
66
+ render json: { error: "Invalid or missing API token" }, status: :unauthorized
67
+ end
68
+
69
+ def preview_requested?
70
+ ActiveModel::Type::Boolean.new.cast(params[:preview])
71
+ end
72
+
73
+ def preview_scope?
74
+ @api_scope == :preview
75
+ end
76
+
77
+ def content_type
78
+ @content_type ||= Current.organization.content_types.find_by!(slug: params[:content_type_slug])
79
+ end
80
+
81
+ def query
82
+ @query ||= ContentQuery.new(
83
+ content_type,
84
+ sort: params[:sort],
85
+ filters: date_range_params,
86
+ page: params[:page],
87
+ per_page: params[:per_page],
88
+ populate: params[:populate].to_s,
89
+ fields: params[:fields].to_s
90
+ )
91
+ end
92
+
93
+ def date_range_params
94
+ params.to_unsafe_h.select { |_key, value| value.is_a?(Hash) && (value.key?("gte") || value.key?("lte")) }
95
+ end
96
+
97
+ rescue_from ContentQuery::Error do |error|
98
+ render json: { error: error.message }, status: :bad_request
99
+ end
100
+
101
+ rescue_from ActiveRecord::RecordNotFound do
102
+ render json: { error: "Not found" }, status: :not_found
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,32 @@
1
+ module RivetCms
2
+ class ContentManagerController < ApplicationController
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :content }
6
+
7
+ def index
8
+ content_types = Current.organization.content_types.order(:name)
9
+ documents = Document.where(organization: Current.organization).in_visible_types.order(updated_at: :desc)
10
+ if params[:type].present?
11
+ documents = documents.joins(:content_type).where(rivet_cms_content_types: { slug: params[:type] })
12
+ end
13
+ documents = documents.search(params[:q]) if params[:q].present?
14
+ page = documents.includes(:draft_revision, :content_type).page(params[:page]).per(25)
15
+ visible = permitted_documents(page)
16
+ titles = document_titles(visible)
17
+
18
+ render inertia: "ContentManager/Index", props: {
19
+ content_types: permitted(content_types, :read, :content).map { |content_type| content_type_ref_props(content_type) },
20
+ documents: visible.map { |document|
21
+ document_list_props(document, titles).merge(
22
+ content_type_name: document.content_type.name,
23
+ content_type_slug: document.content_type.slug
24
+ )
25
+ },
26
+ q: params[:q].presence,
27
+ type: params[:type].presence,
28
+ pagination: { page: page.current_page, total_pages: page.total_pages }
29
+ }
30
+ end
31
+ end
32
+ end
@@ -1,61 +1,153 @@
1
1
  module RivetCms
2
2
  class ContentTypesController < ApplicationController
3
- before_action :set_content_type, only: [:show, :edit, :update, :destroy]
3
+ include InertiaProps
4
+
5
+ before_action -> { authorize! :read, :schema }, only: [ :index, :show, :trash ]
6
+ # Restore mirrors removal, gates included: un-removing a type puts its
7
+ # entries back on the delivery API, so it is no smaller an action.
8
+ before_action -> { authorize! :delete, :schema }, only: [ :destroy, :purge, :restore ]
9
+ before_action -> { authorize! :write, :schema }, except: [ :index, :show, :trash, :destroy, :purge, :restore ]
10
+ before_action :set_content_type, only: [ :show, :update, :destroy ]
11
+ before_action :set_removed_content_type, only: [ :restore, :purge ]
12
+ # Purging destroys content, so it needs the content gate as well as schema
13
+ before_action -> { authorize! :delete, :content }, only: [ :purge ]
14
+ before_action :authorize_cascade!, only: [ :destroy, :restore ]
15
+ # Record layer: same gates, about this specific type
16
+ before_action -> { authorize! :read, :schema, record: @content_type }, only: [ :show ]
17
+ before_action -> { authorize! :write, :schema, record: @content_type }, only: [ :update ]
18
+ before_action -> { authorize! :delete, :schema, record: @content_type }, only: [ :destroy, :restore, :purge ]
19
+ before_action -> { authorize! :delete, :content, record: @content_type }, only: [ :purge ]
4
20
 
5
21
  def index
6
- @content_types = ContentType.order(:name)
22
+ # Entry counts are content data, not schema; omit them without content read
23
+ entry_counts = can?(:read, :content) ? Document.where(organization: Current.organization).in_visible_types.group(:content_type_id).count : nil
24
+
25
+ render inertia: "ContentTypes/Index", props: {
26
+ removed_count: ContentType.with_discarded.discarded.where(organization: Current.organization).count,
27
+ content_types: permitted(Current.organization.content_types, :read, :schema).map { |ct|
28
+ props = content_type_props(ct)
29
+ entry_counts ? props.merge(documents_count: entry_counts.fetch(ct.id, 0)) : props
30
+ }
31
+ }
7
32
  end
8
33
 
9
34
  def show
10
- @contents = @content_type.contents.order(created_at: :desc).limit(10)
11
- end
35
+ fields = @content_type.fields.kept.ordered
12
36
 
13
- def new
14
- @content_type = ContentType.new
37
+ render inertia: "ContentTypes/Show", props: {
38
+ content_type: content_type_props(@content_type),
39
+ fields: fields.map { |f| field_props(f) },
40
+ field_types: Field::FIELD_TYPE_LABELS,
41
+ # Selectable targets for "reference" and "component" field options
42
+ reference_targets: permitted(Current.organization.content_types.where.not(id: @content_type.id).order(:name), :read, :schema).map { |ct| { id: ct.id, name: ct.name } },
43
+ embeddable_components: permitted(Current.organization.components.order(:name), :read, :schema).map { |c| { id: c.id, name: c.name } }
44
+ }
15
45
  end
16
46
 
17
- def edit
47
+ def new
48
+ render inertia: "ContentTypes/New"
18
49
  end
19
50
 
20
51
  def create
21
- @content_type = ContentType.new(content_type_params)
52
+ content_type = ContentType.new(content_type_params)
22
53
 
23
- if @content_type.save
24
- redirect_to @content_type, notice: 'Content type was successfully created.'
54
+ if content_type.save
55
+ audit "content_type.created", content_type
56
+ redirect_to content_type_path(content_type), notice: "Content type created successfully"
25
57
  else
26
- respond_to do |format|
27
- format.html { render :new, status: :unprocessable_entity }
28
- format.turbo_stream {
29
- render turbo_stream: turbo_stream.update("content_type_form",
30
- partial: "form",
31
- locals: { content_type: @content_type }
32
- )
33
- }
34
- end
58
+ redirect_to new_content_type_path, inertia: { errors: content_type.errors }
35
59
  end
36
60
  end
37
61
 
38
62
  def update
39
63
  if @content_type.update(content_type_params)
40
- redirect_to @content_type, notice: 'Content type was successfully updated.'
64
+ audit "content_type.updated", @content_type
65
+ redirect_to content_type_path(@content_type), notice: "Content type updated successfully"
41
66
  else
42
- render :edit
67
+ redirect_to content_type_path(@content_type), inertia: { errors: @content_type.errors }
43
68
  end
44
69
  end
45
70
 
71
+ # Removed types are kept, so they need somewhere to be seen and restored
72
+ def trash
73
+ removed = permitted(ContentType.with_discarded.discarded.where(organization: Current.organization).order(:deleted_at), :read, :schema)
74
+ entry_counts = can?(:read, :content) ? Document.with_discarded.where(content_type_id: removed.map(&:id)).group(:content_type_id).count : nil
75
+
76
+ render inertia: "ContentTypes/Trash", props: {
77
+ content_types: removed.map { |content_type|
78
+ props = content_type_props(content_type).merge(
79
+ removed_at: content_type.deleted_at.iso8601,
80
+ paths: { restore: restore_content_type_path(content_type), purge: purge_content_type_path(content_type) }
81
+ )
82
+ entry_counts ? props.merge(documents_count: entry_counts.fetch(content_type.id, 0)) : props
83
+ }
84
+ }
85
+ end
86
+
87
+ def restore
88
+ @content_type.undiscard!
89
+ audit "content_type.restored", @content_type
90
+ redirect_to content_type_path(@content_type), notice: "#{@content_type.name} was restored with its entries"
91
+ end
92
+
93
+ # The one place content is really destroyed. Only reachable from the trash,
94
+ # and only when the typed name matches, so it cannot be a slip.
95
+ def purge
96
+ unless params[:confirm].to_s.strip == @content_type.name.to_s.strip
97
+ return redirect_back fallback_location: trash_content_types_path, allow_other_host: false,
98
+ status: :see_other, alert: "Type the name exactly to permanently delete it"
99
+ end
100
+
101
+ name = @content_type.name
102
+ ContentType.transaction do
103
+ # with_discarded: trashed entries still hold the FK and must go too
104
+ documents = Document.with_discarded.where(content_type_id: @content_type.id)
105
+ # Incoming links hold a foreign key, so they go before their targets
106
+ Relation.where(target_document_id: documents.select(:id)).delete_all
107
+ documents.find_each(&:destroy!)
108
+ @content_type.destroy!
109
+ end
110
+
111
+ audit "content_type.purged", @content_type
112
+ # Back to whichever trash (global or scoped) the purge came from
113
+ redirect_back fallback_location: trash_content_types_path, allow_other_host: false,
114
+ status: :see_other, notice: "#{name} and its entries were permanently deleted"
115
+ rescue ActiveRecord::ActiveRecordError => error
116
+ Rails.logger&.error("[RivetCms] purge failed for content type #{@content_type.id}: #{error.class}")
117
+ redirect_back fallback_location: trash_content_types_path, allow_other_host: false,
118
+ status: :see_other, alert: "#{name} could not be deleted; nothing was removed"
119
+ end
120
+
46
121
  def destroy
47
- @content_type.destroy
48
- redirect_to content_types_url, notice: 'Content type was successfully destroyed.'
122
+ @content_type.discard!
123
+ audit "content_type.removed", @content_type
124
+ # The type's own page dies with the removal; anywhere else goes back
125
+ redirect_after_trash(content_type_path(@content_type), content_types_path,
126
+ notice: "#{@content_type.name} was removed. Its entries are kept and it can be restored from the trash.")
49
127
  end
50
128
 
51
129
  private
52
130
 
131
+ # Removing a type hides its entries from the admin and the delivery API,
132
+ # so it is a content-level action as well as a schema one.
133
+ def authorize_cascade!
134
+ return unless Document.with_discarded.where(content_type_id: @content_type.id).exists?
135
+
136
+ # Both phases, like every other gate: recordless first, then the record
137
+ authorize! :delete, :content
138
+ authorize! :delete, :content, record: @content_type
139
+ end
140
+
141
+ def set_removed_content_type
142
+ @content_type = ContentType.with_discarded.discarded.where(organization: Current.organization).find(params[:id])
143
+ end
144
+
53
145
  def set_content_type
54
- @content_type = ContentType.find(params[:id])
146
+ @content_type = Current.organization.content_types.find(params[:id])
55
147
  end
56
148
 
57
149
  def content_type_params
58
- params.require(:content_type).permit(:name, :slug, :description, :is_single)
150
+ params.require(:content_type).permit(:name, :slug, :description, :single)
59
151
  end
60
152
  end
61
- end
153
+ end