rivet_cms 0.1.2.pre → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.md +173 -10
- data/app/assets/builds/rivet_cms.css +2 -2
- data/app/assets/builds/rivet_cms.js +395 -9505
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
- data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
- data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
- data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
- data/app/controllers/rivet_cms/application_controller.rb +220 -2
- data/app/controllers/rivet_cms/auth_controller.rb +28 -0
- data/app/controllers/rivet_cms/components_controller.rb +93 -2
- data/app/controllers/rivet_cms/content_controller.rb +105 -0
- data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
- data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
- data/app/controllers/rivet_cms/documents_controller.rb +204 -0
- data/app/controllers/rivet_cms/fields_controller.rb +77 -74
- data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
- data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
- data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
- data/app/controllers/rivet_cms/setup_controller.rb +60 -0
- data/app/controllers/rivet_cms/trash_controller.rb +68 -0
- data/app/controllers/rivet_cms/users_controller.rb +117 -0
- data/app/helpers/rivet_cms/application_helper.rb +13 -3
- data/app/helpers/rivet_cms/components_helper.rb +4 -0
- data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
- data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
- data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
- data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
- data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
- data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
- data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
- data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
- data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
- data/app/models/rivet_cms/api_token.rb +64 -0
- data/app/models/rivet_cms/category.rb +20 -0
- data/app/models/rivet_cms/component.rb +10 -0
- data/app/models/rivet_cms/component_instance.rb +27 -0
- data/app/models/rivet_cms/content_type.rb +36 -23
- data/app/models/rivet_cms/content_value.rb +19 -0
- data/app/models/rivet_cms/current.rb +6 -0
- data/app/models/rivet_cms/document.rb +71 -0
- data/app/models/rivet_cms/document_revision.rb +128 -0
- data/app/models/rivet_cms/entry.rb +73 -0
- data/app/models/rivet_cms/entry_collection.rb +41 -0
- data/app/models/rivet_cms/field.rb +203 -61
- data/app/models/rivet_cms/media_asset.rb +109 -0
- data/app/models/rivet_cms/organization.rb +46 -0
- data/app/models/rivet_cms/relation.rb +23 -0
- data/app/models/rivet_cms/user.rb +61 -0
- data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
- data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
- data/app/services/rivet_cms/content_query.rb +158 -0
- data/app/services/rivet_cms/content_validator.rb +150 -0
- data/app/services/rivet_cms/draft_writer.rb +74 -0
- data/app/services/rivet_cms/open_api_generator.rb +173 -221
- data/app/services/rivet_cms/revision_pruner.rb +64 -0
- data/app/services/rivet_cms/webhooks.rb +61 -0
- data/app/views/layouts/rivet_cms/application.html.erb +35 -42
- data/config/routes.rb +68 -45
- data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
- data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
- data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
- data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
- data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
- data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
- data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
- data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
- data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
- data/db/seeds/README.md +70 -0
- data/db/seeds/templates/blog.rb +39 -0
- data/db/seeds/templates/events.rb +12 -0
- data/db/seeds/templates/faq.rb +9 -0
- data/db/seeds/templates/pages.rb +35 -0
- data/db/seeds/templates/site_settings.rb +22 -0
- data/db/seeds/templates/team.rb +11 -0
- data/db/seeds/templates/testimonials.rb +11 -0
- data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
- data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
- data/lib/rivet_cms/access_check.rb +12 -0
- data/lib/rivet_cms/audit.rb +43 -0
- data/lib/rivet_cms/engine.rb +28 -22
- data/lib/rivet_cms/hooks.rb +106 -0
- data/lib/rivet_cms/navigation.rb +111 -0
- data/lib/rivet_cms/safe_pattern.rb +40 -0
- data/lib/rivet_cms/seeds.rb +264 -0
- data/lib/rivet_cms/version.rb +1 -1
- data/lib/rivet_cms.rb +289 -32
- data/lib/tasks/rivet_cms_tasks.rake +18 -4
- metadata +138 -98
- data/MIT-LICENSE +0 -20
- data/app/assets/builds/rivet_cms.js.map +0 -7
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
- data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
- data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
- data/app/helpers/rivet_cms/flash_helper.rb +0 -37
- data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
- data/app/javascript/controllers/content_type_form_controller.js +0 -53
- data/app/javascript/controllers/field_layout_controller.js +0 -709
- data/app/javascript/rivet_cms.js +0 -29
- data/app/models/rivet_cms/content.rb +0 -4
- data/app/models/rivet_cms/field_values/base.rb +0 -11
- data/app/models/rivet_cms/field_values/boolean.rb +0 -4
- data/app/models/rivet_cms/field_values/integer.rb +0 -4
- data/app/models/rivet_cms/field_values/string.rb +0 -4
- data/app/models/rivet_cms/field_values/text.rb +0 -4
- data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
- data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
- data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
- data/app/views/rivet_cms/content_types/index.html.erb +0 -151
- data/app/views/rivet_cms/content_types/new.html.erb +0 -19
- data/app/views/rivet_cms/content_types/show.html.erb +0 -147
- data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
- data/app/views/rivet_cms/fields/_form.html.erb +0 -111
- data/app/views/rivet_cms/fields/edit.html.erb +0 -25
- data/app/views/rivet_cms/fields/index.html.erb +0 -126
- data/app/views/rivet_cms/fields/new.html.erb +0 -25
- data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
- data/config/i18n-tasks.yml +0 -178
- data/config/locales/en.yml +0 -14
- data/db/migrate/20250317194359_create_core_tables.rb +0 -90
- data/lib/rivet_cms/routes.rb +0 -49
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Content-type templates: reusable schema (content types, fields, components,
|
|
2
|
+
# categories) that a host app can load into an organization as a starting point.
|
|
3
|
+
# Templates live in db/seeds/templates and are applied idempotently, so loading
|
|
4
|
+
# the same template twice upserts rather than duplicating — safe in production.
|
|
5
|
+
module RivetCms
|
|
6
|
+
module Seeds
|
|
7
|
+
TEMPLATES_PATH = "db/seeds/templates/*.rb".freeze
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def registry
|
|
11
|
+
@registry ||= {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Called by each template file to register its definition block.
|
|
15
|
+
def template(name, &block)
|
|
16
|
+
registry[name.to_s] = block
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def available
|
|
20
|
+
load_definitions
|
|
21
|
+
registry.keys.sort
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Apply one, several, or all templates to an organization. Returns the
|
|
25
|
+
# builders that ran.
|
|
26
|
+
def load!(organization:, only: nil)
|
|
27
|
+
load_definitions
|
|
28
|
+
names = Array(only).map(&:to_s).reject(&:empty?)
|
|
29
|
+
names = registry.keys if names.empty?
|
|
30
|
+
|
|
31
|
+
RivetCms::Current.set(organization: organization) do
|
|
32
|
+
names.map { |name| apply(name, organization) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def resolve_organization(domain = nil)
|
|
37
|
+
org = if domain.present?
|
|
38
|
+
RivetCms::Organization.find_by(domain: domain)
|
|
39
|
+
else
|
|
40
|
+
RivetCms::Organization.find_by(default: true) || RivetCms::Organization.first
|
|
41
|
+
end
|
|
42
|
+
return org if org
|
|
43
|
+
|
|
44
|
+
raise "No organization found#{domain.present? ? " for domain #{domain}" : ''}. Create one first."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def load_definitions
|
|
50
|
+
return if @loaded
|
|
51
|
+
|
|
52
|
+
Dir[RivetCms::Engine.root.join(TEMPLATES_PATH)].sort.each { |file| require file }
|
|
53
|
+
@loaded = true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply(name, organization)
|
|
57
|
+
block = registry.fetch(name) { raise ArgumentError, "unknown template: #{name}" }
|
|
58
|
+
builder = Builder.new(organization)
|
|
59
|
+
builder.instance_eval(&block)
|
|
60
|
+
builder.commit
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Collects field definitions inside a content_type/component block.
|
|
65
|
+
class FieldSet
|
|
66
|
+
SCALAR_TYPES = %i[string text rich_text markdown integer decimal enumeration boolean image video file date datetime].freeze
|
|
67
|
+
|
|
68
|
+
attr_reader :definitions
|
|
69
|
+
|
|
70
|
+
def initialize
|
|
71
|
+
@definitions = []
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
SCALAR_TYPES.each do |type|
|
|
75
|
+
define_method(type) do |key, **opts|
|
|
76
|
+
add(type, key, opts)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reference(key, to:, **opts)
|
|
81
|
+
add(:reference, key, opts.merge(to: to))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def component(key, use:, **opts)
|
|
85
|
+
add(:component, key, opts.merge(use: use))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def add(type, key, opts)
|
|
91
|
+
@definitions << opts.merge(type: type, key: key.to_s)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Applies a template's declarations to the database for one organization.
|
|
96
|
+
class Builder
|
|
97
|
+
def initialize(organization)
|
|
98
|
+
@organization = organization
|
|
99
|
+
@categories = []
|
|
100
|
+
@components = []
|
|
101
|
+
@content_types = []
|
|
102
|
+
@category_index = {}
|
|
103
|
+
@component_index = {}
|
|
104
|
+
@content_type_index = {}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def category(name, slug: nil, system: false, position: 0)
|
|
108
|
+
@categories << { name: name, slug: slug || name.parameterize, system: system, position: position }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def component(name, category:, slug: nil, description: nil, &block)
|
|
112
|
+
fields = FieldSet.new
|
|
113
|
+
fields.instance_eval(&block) if block
|
|
114
|
+
@components << { name: name, slug: slug || name.parameterize, category: category, description: description, fields: fields.definitions }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def content_type(name, slug:, description: nil, single: false, &block)
|
|
118
|
+
fields = FieldSet.new
|
|
119
|
+
fields.instance_eval(&block) if block
|
|
120
|
+
@content_types << { name: name, slug: slug, description: description, single: single, fields: fields.definitions }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Shells first (so references and component fields can resolve regardless of
|
|
124
|
+
# declaration order), then fields.
|
|
125
|
+
def commit
|
|
126
|
+
RivetCms::ApplicationRecord.transaction do
|
|
127
|
+
@categories.each { |attrs| upsert_category(attrs) }
|
|
128
|
+
component_records = @components.map { |attrs| upsert_component(attrs) }
|
|
129
|
+
content_type_records = @content_types.map { |attrs| upsert_content_type(attrs) }
|
|
130
|
+
|
|
131
|
+
@components.each_with_index { |attrs, i| build_fields(component_records[i], attrs[:fields]) }
|
|
132
|
+
@content_types.each_with_index { |attrs, i| build_fields(content_type_records[i], attrs[:fields]) if content_type_records[i] }
|
|
133
|
+
end
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
def upsert_category(attrs)
|
|
140
|
+
category = RivetCms::Category.find_or_initialize_by(organization: @organization, slug: attrs[:slug])
|
|
141
|
+
category.name = attrs[:name]
|
|
142
|
+
category.system = attrs[:system] if attrs.key?(:system)
|
|
143
|
+
category.position = attrs[:position] || category.position || 0
|
|
144
|
+
category.save!
|
|
145
|
+
@category_index[attrs[:slug]] = category
|
|
146
|
+
@category_index[attrs[:name]] = category
|
|
147
|
+
category
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve_category(name)
|
|
151
|
+
@category_index[name] || @category_index[name.to_s.parameterize] ||
|
|
152
|
+
upsert_category(name: name, slug: name.to_s.parameterize)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def upsert_component(attrs)
|
|
156
|
+
component = RivetCms::Component.find_or_initialize_by(organization: @organization, slug: attrs[:slug])
|
|
157
|
+
component.name = attrs[:name]
|
|
158
|
+
component.description = attrs[:description]
|
|
159
|
+
component.category = resolve_category(attrs[:category])
|
|
160
|
+
component.save!
|
|
161
|
+
@component_index[attrs[:slug]] = component
|
|
162
|
+
@component_index[attrs[:name]] = component
|
|
163
|
+
component
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def upsert_content_type(attrs)
|
|
167
|
+
# with_discarded so a removed type is recognised rather than colliding
|
|
168
|
+
# with its reserved slug. A removal is deliberate, so seeding leaves it
|
|
169
|
+
# alone instead of silently restoring it and its entries.
|
|
170
|
+
content_type = RivetCms::ContentType.with_discarded.find_or_initialize_by(organization: @organization, slug: attrs[:slug])
|
|
171
|
+
# Skipped entirely, not just left undiscarded: keeping it out of the
|
|
172
|
+
# index stops build_fields rewriting its schema. Reference fields
|
|
173
|
+
# pointing at it are dropped by build_fields rather than seeded broken.
|
|
174
|
+
return nil if content_type.discarded?
|
|
175
|
+
|
|
176
|
+
content_type.name = attrs[:name]
|
|
177
|
+
content_type.description = attrs[:description]
|
|
178
|
+
content_type.single = attrs[:single] || false
|
|
179
|
+
content_type.save!
|
|
180
|
+
@content_type_index[attrs[:slug]] = content_type
|
|
181
|
+
content_type
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_fields(owner, definitions)
|
|
185
|
+
# A reference to a removed type cannot be seeded, so its definition is
|
|
186
|
+
# dropped alongside the field it would have produced; layout_rows pairs
|
|
187
|
+
# the two lists positionally and must not see a gap.
|
|
188
|
+
definitions = definitions.reject { |definition| removed_reference?(definition) }
|
|
189
|
+
return if definitions.empty?
|
|
190
|
+
|
|
191
|
+
owner_key = owner.is_a?(RivetCms::ContentType) ? :content_type_id : :component_id
|
|
192
|
+
records = definitions.map { |definition| upsert_field(owner, owner_key, definition) }
|
|
193
|
+
RivetCms::Field.update_layout!(layout_rows(definitions, records))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def removed_reference?(definition)
|
|
197
|
+
return false unless definition[:type] == :reference
|
|
198
|
+
|
|
199
|
+
lookup_content_type(definition[:to]).nil?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def upsert_field(owner, owner_key, definition)
|
|
203
|
+
field = RivetCms::Field.with_discarded.find_or_initialize_by(owner_key => owner.id, key: definition[:key])
|
|
204
|
+
field.assign_attributes(field_attributes(definition).merge(organization: @organization, deleted_at: nil))
|
|
205
|
+
field.save!
|
|
206
|
+
field
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def field_attributes(definition)
|
|
210
|
+
attributes = {
|
|
211
|
+
label: definition[:label] || definition[:key].titleize,
|
|
212
|
+
field_type: definition[:type],
|
|
213
|
+
required: definition.fetch(:required, false),
|
|
214
|
+
description: definition[:description],
|
|
215
|
+
width: definition[:width] == :half ? "half" : "full",
|
|
216
|
+
config: definition[:config] || {},
|
|
217
|
+
min_items: definition[:min_items],
|
|
218
|
+
max_items: definition[:max_items]
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case definition[:type]
|
|
222
|
+
when :reference
|
|
223
|
+
attributes[:config] = { "content_type_id" => lookup_content_type(definition[:to]).id }
|
|
224
|
+
when :component
|
|
225
|
+
attributes[:config] = { "component_id" => lookup_component(definition[:use]).id }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
attributes
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# nil when the target exists but has been removed; raises only when the
|
|
232
|
+
# template names a type that was never seeded at all.
|
|
233
|
+
def lookup_content_type(slug)
|
|
234
|
+
found = @content_type_index[slug] || RivetCms::ContentType.find_by(organization: @organization, slug: slug)
|
|
235
|
+
return found if found
|
|
236
|
+
return nil if RivetCms::ContentType.with_discarded.exists?(organization: @organization, slug: slug)
|
|
237
|
+
|
|
238
|
+
raise ArgumentError, "reference target content type not found: #{slug}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def lookup_component(name)
|
|
242
|
+
@component_index[name] || @component_index[name.to_s.parameterize] ||
|
|
243
|
+
RivetCms::Component.find_by(organization: @organization, slug: name.to_s.parameterize) ||
|
|
244
|
+
raise(ArgumentError, "component not found: #{name}")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Pair consecutive half-width fields onto one row; everything else gets its own.
|
|
248
|
+
def layout_rows(definitions, records)
|
|
249
|
+
rows = []
|
|
250
|
+
i = 0
|
|
251
|
+
while i < definitions.length
|
|
252
|
+
if definitions[i][:width] == :half && definitions[i + 1] && definitions[i + 1][:width] == :half
|
|
253
|
+
rows << [ records[i].id, records[i + 1].id ]
|
|
254
|
+
i += 2
|
|
255
|
+
else
|
|
256
|
+
rows << [ records[i].id ]
|
|
257
|
+
i += 1
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
rows
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
data/lib/rivet_cms/version.rb
CHANGED
data/lib/rivet_cms.rb
CHANGED
|
@@ -1,47 +1,304 @@
|
|
|
1
1
|
require "rivet_cms/version"
|
|
2
|
-
require "rivet_cms/routes"
|
|
3
2
|
require "rivet_cms/engine"
|
|
3
|
+
require "rivet_cms/safe_pattern"
|
|
4
|
+
require "rivet_cms/hooks"
|
|
5
|
+
require "rivet_cms/audit"
|
|
6
|
+
require "rivet_cms/navigation"
|
|
7
|
+
require "rivet_cms/access_check"
|
|
8
|
+
require "bcrypt"
|
|
9
|
+
require "image_processing"
|
|
10
|
+
require "prefixed_ids"
|
|
11
|
+
require "kaminari"
|
|
12
|
+
require "inertia_rails"
|
|
4
13
|
|
|
5
14
|
module RivetCms
|
|
6
|
-
class Error < StandardError; end
|
|
7
|
-
|
|
8
15
|
class << self
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
# Hard ceiling for library uploads (bytes); hosts can override in an initializer.
|
|
17
|
+
attr_accessor :max_upload_size
|
|
18
|
+
|
|
19
|
+
# MIME types the media library accepts, checked against the sniffed type,
|
|
20
|
+
# not the client-declared one. nil disables the check. SVG is excluded by
|
|
21
|
+
# default because scripted SVGs are an XSS vector; hosts that trust their
|
|
22
|
+
# editors can append "image/svg+xml".
|
|
23
|
+
attr_accessor :allowed_media_types
|
|
24
|
+
|
|
25
|
+
# Host (e.g. "https://cms.example.com") used to build absolute media URLs
|
|
26
|
+
# in the public API. When nil, URLs are relative paths.
|
|
27
|
+
attr_accessor :media_host
|
|
28
|
+
|
|
29
|
+
# Basic webhook endpoints: [{ url: "https://...", events: %w[entry.published] }].
|
|
30
|
+
# events is optional (defaults to all). Delivered by WebhookDeliveryJob;
|
|
31
|
+
# no signing or retries.
|
|
32
|
+
attr_accessor :webhooks
|
|
33
|
+
|
|
34
|
+
# Add a sidebar item; see RivetCms::Navigation for the full contract.
|
|
35
|
+
def register_nav(key, **options)
|
|
36
|
+
Navigation.register(key, **options)
|
|
11
37
|
end
|
|
12
38
|
|
|
13
|
-
|
|
14
|
-
|
|
39
|
+
# Extra admin bundles the layout emits after core's tags. An extension's
|
|
40
|
+
# precompiled JS loads in order and can register its pages on
|
|
41
|
+
# window.RivetCMS before the admin app boots.
|
|
42
|
+
def register_admin_script(name)
|
|
43
|
+
raise ArgumentError, "asset name required" if name.to_s.strip.empty?
|
|
44
|
+
|
|
45
|
+
admin_scripts << name.to_s unless admin_scripts.include?(name.to_s)
|
|
15
46
|
end
|
|
47
|
+
|
|
48
|
+
def register_admin_stylesheet(name)
|
|
49
|
+
raise ArgumentError, "asset name required" if name.to_s.strip.empty?
|
|
50
|
+
|
|
51
|
+
admin_stylesheets << name.to_s unless admin_stylesheets.include?(name.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def admin_scripts
|
|
55
|
+
@admin_scripts ||= []
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def admin_stylesheets
|
|
59
|
+
@admin_stylesheets ||= []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attr_writer :admin_scripts, :admin_stylesheets
|
|
63
|
+
|
|
64
|
+
# Subscribe to a lifecycle event; see RivetCms::Hooks for the event list
|
|
65
|
+
# and the key: contract for reload-safe registration.
|
|
66
|
+
# RivetCms.on(:publish) { |revision| ... }
|
|
67
|
+
def on(event, callable = nil, key: nil, &block)
|
|
68
|
+
Hooks.on(event, callable, key: key, &block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# When true the delivery API allows anonymous reads of published content.
|
|
72
|
+
# When false (default) every request needs an API token. A preview-scoped
|
|
73
|
+
# token is always required to read drafts, regardless of this setting.
|
|
74
|
+
attr_accessor :public_api
|
|
75
|
+
|
|
76
|
+
# How many superseded published revisions to keep per document. The
|
|
77
|
+
# default :all never deletes anything: pruning is something a host opts
|
|
78
|
+
# into, because unbounded storage is a visible problem you can fix later
|
|
79
|
+
# and deleted content is not. Set an integer to prune on publish. The
|
|
80
|
+
# current published revision and the working draft are never pruned.
|
|
81
|
+
attr_reader :revision_retention
|
|
82
|
+
|
|
83
|
+
# Validated at assignment: a bad value here would otherwise surface as a
|
|
84
|
+
# failed publish, or worse, silently mean 0 and destroy history.
|
|
85
|
+
def revision_retention=(value)
|
|
86
|
+
@revision_retention = normalize_retention(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Retention for one document. Override to vary by organization or content
|
|
90
|
+
# type; the scalar config is the default resolver.
|
|
91
|
+
def retention_for(_document)
|
|
92
|
+
revision_retention
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# What the pruner actually reads: an override is validated the same way
|
|
96
|
+
# the config setter is, so a bad override cannot silently destroy history.
|
|
97
|
+
def normalized_retention_for(document)
|
|
98
|
+
normalize_retention(retention_for(document))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Authorization seam: receives one RivetCms::AccessCheck and returns a
|
|
102
|
+
# boolean; default allow. check.action is :read, :write, :publish, or
|
|
103
|
+
# :delete; check.resource is a coarse domain (:content, :schema, :media,
|
|
104
|
+
# :api). The vocabulary grows over time, so policies should allowlist
|
|
105
|
+
# known pairs and deny anything unrecognized. A raising policy denies
|
|
106
|
+
# (fail closed) and logs. Governs the admin UI only; the delivery API
|
|
107
|
+
# is token-gated separately.
|
|
108
|
+
attr_accessor :can
|
|
109
|
+
|
|
110
|
+
# Authentication is delegated to the host app. See the initializer template
|
|
111
|
+
# (rails g rivet_cms:install) for a full example.
|
|
112
|
+
attr_accessor :parent_controller
|
|
113
|
+
attr_accessor :authenticate
|
|
114
|
+
attr_accessor :current_user
|
|
115
|
+
attr_accessor :user_name
|
|
116
|
+
attr_accessor :user_email
|
|
117
|
+
attr_accessor :login_path
|
|
118
|
+
attr_accessor :logout_path
|
|
119
|
+
attr_accessor :logout_method
|
|
120
|
+
|
|
121
|
+
def configure
|
|
122
|
+
yield self
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Ruby content helpers — read CMS content directly from host-app code with
|
|
126
|
+
# the same semantics and options as the delivery API (sort, date filters,
|
|
127
|
+
# pagination, populate, fields, preview).
|
|
128
|
+
|
|
129
|
+
# Published entries of a content type. Options mirror the API's list
|
|
130
|
+
# params; populate accepts an array of keys or :all.
|
|
131
|
+
def entries(type_slug, organization: nil, **options)
|
|
132
|
+
content_type = find_content_type!(type_slug, organization)
|
|
133
|
+
query = ContentQuery.new(content_type, **options)
|
|
134
|
+
populate = query.populate_fields
|
|
135
|
+
|
|
136
|
+
page = query.documents
|
|
137
|
+
revisions = page.map(&:published_revision)
|
|
138
|
+
preload = RevisionPreloader.new(revisions, populate_fields: populate)
|
|
139
|
+
|
|
140
|
+
wrapped = revisions.map do |revision|
|
|
141
|
+
Entry.new(RevisionSerializer.new(revision, fields: query.field_keys, populate: populate, preload: preload).as_json)
|
|
142
|
+
end
|
|
143
|
+
EntryCollection.new(wrapped, page: page.current_page, per_page: page.limit_value,
|
|
144
|
+
total: page.total_count, total_pages: page.total_pages)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One entry by slug, or nil. preview: true serves the draft when present
|
|
148
|
+
# (Ruby callers are trusted host code — no token gate).
|
|
149
|
+
def entry(type_slug, entry_slug, organization: nil, preview: false, populate: nil, fields: nil)
|
|
150
|
+
content_type = find_content_type!(type_slug, organization)
|
|
151
|
+
document = content_type.documents.find_by(slug: entry_slug)
|
|
152
|
+
serialize_document(content_type, document, preview: preview, populate: populate, fields: fields)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The one entry of a single-type content type, or nil.
|
|
156
|
+
def single(type_slug, organization: nil, preview: false, populate: nil, fields: nil)
|
|
157
|
+
content_type = find_content_type!(type_slug, organization)
|
|
158
|
+
document = content_type.documents.find_by(singleton_key: "singleton") || content_type.documents.first
|
|
159
|
+
serialize_document(content_type, document, preview: preview, populate: populate, fields: fields)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Built-in authentication is the default: it is active exactly while the
|
|
163
|
+
# host has not configured its own authenticate lambda. Setting one (as
|
|
164
|
+
# every existing host integration does) opts out entirely.
|
|
165
|
+
def builtin_auth?
|
|
166
|
+
authenticate.equal?(DEFAULT_AUTHENTICATE)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# First-run setup protection. Outside development/test the setup screen
|
|
170
|
+
# requires a code that is printed to the server log, so only someone with
|
|
171
|
+
# server access can claim a fresh deployment. Set explicitly to require a
|
|
172
|
+
# chosen code in every environment (or to share it out of band).
|
|
173
|
+
attr_writer :setup_code
|
|
174
|
+
|
|
175
|
+
# Derived, not memoized: memoizing would make the explicit-config check
|
|
176
|
+
# below true after any read
|
|
177
|
+
def setup_code
|
|
178
|
+
@setup_code || Digest::SHA256.hexdigest("#{Rails.application.secret_key_base}:rivet-cms-setup")[0, 12]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def setup_code_required?
|
|
182
|
+
!(Rails.env.development? || Rails.env.test?) || @setup_code.present?
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# Values above this are a misconfiguration, not a retention policy, and a
|
|
188
|
+
# large enough offset makes the prune query fail at publish time.
|
|
189
|
+
MAX_RETENTION = 1_000_000
|
|
190
|
+
|
|
191
|
+
def normalize_retention(value)
|
|
192
|
+
return :all if value == :all || (value.to_s.casecmp("all").zero? rescue false)
|
|
193
|
+
# Compare the class directly: ActiveSupport::Duration answers both is_a?
|
|
194
|
+
# and instance_of? for Integer, so 90.days would otherwise slip through
|
|
195
|
+
# and silently mean 7,776,000 revisions.
|
|
196
|
+
return value if value.class == Integer && !value.negative? && value <= MAX_RETENTION
|
|
197
|
+
# Base 10 explicitly: Integer("0010") would otherwise read as octal
|
|
198
|
+
return Integer(value, 10) if value.is_a?(String) && value.match?(/\A\d+\z/) && Integer(value, 10) <= MAX_RETENTION
|
|
199
|
+
|
|
200
|
+
raise ArgumentError,
|
|
201
|
+
"RivetCms.revision_retention must be :all or an Integer between 0 and #{MAX_RETENTION}, " \
|
|
202
|
+
"got #{value.inspect}. Time-based retention is not supported."
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def find_content_type!(type_slug, organization)
|
|
206
|
+
org = organization || Current.organization ||
|
|
207
|
+
Organization.find_by(default: true) || Organization.first
|
|
208
|
+
raise ContentQuery::Error, "no organization available" if org.nil?
|
|
209
|
+
|
|
210
|
+
org.content_types.find_by(slug: type_slug.to_s) ||
|
|
211
|
+
raise(ContentQuery::Error, "unknown content type: #{type_slug}")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def serialize_document(content_type, document, preview:, populate:, fields:)
|
|
215
|
+
return nil if document.nil?
|
|
216
|
+
|
|
217
|
+
revision = preview ? (document.draft_revision || document.published_revision) : document.published_revision
|
|
218
|
+
return nil if revision.nil?
|
|
219
|
+
|
|
220
|
+
query = ContentQuery.new(content_type, populate: populate, fields: fields)
|
|
221
|
+
populate_fields = query.populate_fields
|
|
222
|
+
preload = RevisionPreloader.new([ revision ], populate_fields: populate_fields, preview: preview)
|
|
223
|
+
Entry.new(RevisionSerializer.new(revision, fields: query.field_keys, populate: populate_fields,
|
|
224
|
+
preview: preview, preload: preload).as_json)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
self.max_upload_size = 100 * 1024 * 1024
|
|
229
|
+
self.allowed_media_types = %w[
|
|
230
|
+
image/png image/jpeg image/gif image/webp image/avif
|
|
231
|
+
video/mp4 video/webm video/quicktime
|
|
232
|
+
audio/mpeg audio/wav audio/ogg
|
|
233
|
+
application/pdf application/zip
|
|
234
|
+
text/plain text/csv
|
|
235
|
+
application/msword application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
|
236
|
+
application/vnd.ms-excel application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
|
237
|
+
]
|
|
238
|
+
self.media_host = nil
|
|
239
|
+
self.public_api = false
|
|
240
|
+
self.webhooks = []
|
|
241
|
+
self.revision_retention = 0
|
|
242
|
+
|
|
243
|
+
self.parent_controller = "ActionController::Base"
|
|
244
|
+
self.login_path = nil
|
|
245
|
+
self.logout_path = nil
|
|
246
|
+
self.logout_method = "delete"
|
|
247
|
+
|
|
248
|
+
# authenticate returns truthy to allow the request and falsy to deny it; the
|
|
249
|
+
# engine turns a denial into a redirect to login_path or a 403 (a lambda may
|
|
250
|
+
# also render/redirect itself). Unconfigured means built-in authentication:
|
|
251
|
+
# this lambda is an identity sentinel for builtin_auth? and is never called
|
|
252
|
+
# (the controller branches to the built-in session flow first). Fails
|
|
253
|
+
# closed if something ever invokes it anyway.
|
|
254
|
+
DEFAULT_AUTHENTICATE = ->(_controller) { false }
|
|
255
|
+
self.authenticate = DEFAULT_AUTHENTICATE
|
|
256
|
+
|
|
257
|
+
self.current_user = ->(_controller) { nil }
|
|
258
|
+
|
|
259
|
+
# Default policy: allow everything. CE has no permission system, so any
|
|
260
|
+
# authenticated user can do anything in both built-in and host-auth mode.
|
|
261
|
+
# Installing config.can replaces this wholesale; that is the seam Pro's
|
|
262
|
+
# role and record-scoped permissions attach through.
|
|
263
|
+
self.can = ->(_check) { true }
|
|
264
|
+
|
|
265
|
+
self.user_name = lambda do |user|
|
|
266
|
+
%i[full_name name display_name email email_address].each do |attr|
|
|
267
|
+
next unless user.respond_to?(attr)
|
|
268
|
+
|
|
269
|
+
value = user.public_send(attr)
|
|
270
|
+
return value.to_s if value.present?
|
|
271
|
+
end
|
|
272
|
+
nil
|
|
16
273
|
end
|
|
17
274
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
275
|
+
self.user_email = lambda do |user|
|
|
276
|
+
%i[email email_address].each do |attr|
|
|
277
|
+
next unless user.respond_to?(attr)
|
|
278
|
+
|
|
279
|
+
value = user.public_send(attr)
|
|
280
|
+
return value.to_s if value.present?
|
|
281
|
+
end
|
|
282
|
+
nil
|
|
22
283
|
end
|
|
23
284
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@prefixed_ids_alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
40
|
-
@sign_out_path = if defined?(RivetCmsAuth::Engine)
|
|
41
|
-
lambda { RivetCmsAuth::Engine.routes.url_helpers.auth_sign_out_path }
|
|
42
|
-
else
|
|
43
|
-
"/" # Default to root if no auth engine
|
|
44
|
-
end
|
|
285
|
+
# Digest of the precompiled admin assets, used as the Inertia asset version
|
|
286
|
+
# so clients do a full reload when the gem ships a new build. Recomputed each
|
|
287
|
+
# request in development so a rebuild auto-reloads the browser; memoized
|
|
288
|
+
# elsewhere (assets are static at runtime).
|
|
289
|
+
def self.asset_version
|
|
290
|
+
return compute_asset_version if Rails.env.development?
|
|
291
|
+
|
|
292
|
+
@asset_version ||= compute_asset_version
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def self.compute_asset_version
|
|
296
|
+
build_path = Engine.root.join("app/assets/builds")
|
|
297
|
+
asset_digests = %w[rivet_cms.js rivet_cms.css].filter_map do |filename|
|
|
298
|
+
asset = build_path.join(filename)
|
|
299
|
+
Digest::MD5.file(asset).hexdigest if asset.exist?
|
|
45
300
|
end
|
|
301
|
+
|
|
302
|
+
asset_digests.any? ? Digest::MD5.hexdigest([ VERSION, *asset_digests ].join(":")) : VERSION
|
|
46
303
|
end
|
|
47
304
|
end
|
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
require "rivet_cms/seeds"
|
|
2
|
+
|
|
3
|
+
namespace :rivet_cms do
|
|
4
|
+
desc "Load content-type templates into an organization (TEMPLATES=blog,pages ORG=example.com)"
|
|
5
|
+
task seed: :environment do
|
|
6
|
+
org = RivetCms::Seeds.resolve_organization(ENV["ORG"])
|
|
7
|
+
names = ENV["TEMPLATES"]&.split(",")&.map(&:strip)&.reject(&:empty?)
|
|
8
|
+
|
|
9
|
+
RivetCms::Seeds.load!(organization: org, only: names)
|
|
10
|
+
loaded = names || RivetCms::Seeds.available
|
|
11
|
+
puts "Loaded #{loaded.size} template(s) into #{org.name}: #{loaded.join(', ')}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
desc "List available content-type templates"
|
|
15
|
+
task templates: :environment do
|
|
16
|
+
RivetCms::Seeds.available.each { |name| puts name }
|
|
17
|
+
end
|
|
18
|
+
end
|