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
|
@@ -1,5 +1,223 @@
|
|
|
1
1
|
module RivetCms
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
end
|
|
35
|
+
fields = @content_type.fields.kept.ordered
|
|
12
36
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
47
|
+
def new
|
|
48
|
+
render inertia: "ContentTypes/New"
|
|
18
49
|
end
|
|
19
50
|
|
|
20
51
|
def create
|
|
21
|
-
|
|
52
|
+
content_type = ContentType.new(content_type_params)
|
|
22
53
|
|
|
23
|
-
if
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
+
audit "content_type.updated", @content_type
|
|
65
|
+
redirect_to content_type_path(@content_type), notice: "Content type updated successfully"
|
|
41
66
|
else
|
|
42
|
-
|
|
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.
|
|
48
|
-
|
|
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 =
|
|
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, :
|
|
150
|
+
params.require(:content_type).permit(:name, :slug, :description, :single)
|
|
59
151
|
end
|
|
60
152
|
end
|
|
61
|
-
end
|
|
153
|
+
end
|