rivet_cms 0.1.2.pre → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/COPYING +674 -0
  4. data/COPYING.LESSER +165 -0
  5. data/README.md +173 -10
  6. data/app/assets/builds/rivet_cms.css +2 -2
  7. data/app/assets/builds/rivet_cms.js +395 -9505
  8. data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
  9. data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
  10. data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
  11. data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
  12. data/app/controllers/rivet_cms/application_controller.rb +220 -2
  13. data/app/controllers/rivet_cms/auth_controller.rb +28 -0
  14. data/app/controllers/rivet_cms/components_controller.rb +93 -2
  15. data/app/controllers/rivet_cms/content_controller.rb +105 -0
  16. data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
  17. data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
  18. data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
  19. data/app/controllers/rivet_cms/documents_controller.rb +204 -0
  20. data/app/controllers/rivet_cms/fields_controller.rb +77 -74
  21. data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
  22. data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
  23. data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
  24. data/app/controllers/rivet_cms/setup_controller.rb +60 -0
  25. data/app/controllers/rivet_cms/trash_controller.rb +68 -0
  26. data/app/controllers/rivet_cms/users_controller.rb +117 -0
  27. data/app/helpers/rivet_cms/application_helper.rb +13 -3
  28. data/app/helpers/rivet_cms/components_helper.rb +4 -0
  29. data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
  30. data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
  31. data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
  32. data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
  33. data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
  34. data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
  35. data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
  36. data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
  37. data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
  38. data/app/models/rivet_cms/api_token.rb +64 -0
  39. data/app/models/rivet_cms/category.rb +20 -0
  40. data/app/models/rivet_cms/component.rb +10 -0
  41. data/app/models/rivet_cms/component_instance.rb +27 -0
  42. data/app/models/rivet_cms/content_type.rb +36 -23
  43. data/app/models/rivet_cms/content_value.rb +19 -0
  44. data/app/models/rivet_cms/current.rb +6 -0
  45. data/app/models/rivet_cms/document.rb +71 -0
  46. data/app/models/rivet_cms/document_revision.rb +128 -0
  47. data/app/models/rivet_cms/entry.rb +73 -0
  48. data/app/models/rivet_cms/entry_collection.rb +41 -0
  49. data/app/models/rivet_cms/field.rb +203 -61
  50. data/app/models/rivet_cms/media_asset.rb +109 -0
  51. data/app/models/rivet_cms/organization.rb +46 -0
  52. data/app/models/rivet_cms/relation.rb +23 -0
  53. data/app/models/rivet_cms/user.rb +61 -0
  54. data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
  55. data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
  56. data/app/services/rivet_cms/content_query.rb +158 -0
  57. data/app/services/rivet_cms/content_validator.rb +150 -0
  58. data/app/services/rivet_cms/draft_writer.rb +74 -0
  59. data/app/services/rivet_cms/open_api_generator.rb +173 -221
  60. data/app/services/rivet_cms/revision_pruner.rb +64 -0
  61. data/app/services/rivet_cms/webhooks.rb +61 -0
  62. data/app/views/layouts/rivet_cms/application.html.erb +35 -42
  63. data/config/routes.rb +68 -45
  64. data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
  65. data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
  66. data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
  69. data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
  70. data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
  71. data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
  72. data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
  73. data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
  74. data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
  75. data/db/seeds/README.md +70 -0
  76. data/db/seeds/templates/blog.rb +39 -0
  77. data/db/seeds/templates/events.rb +12 -0
  78. data/db/seeds/templates/faq.rb +9 -0
  79. data/db/seeds/templates/pages.rb +35 -0
  80. data/db/seeds/templates/site_settings.rb +22 -0
  81. data/db/seeds/templates/team.rb +11 -0
  82. data/db/seeds/templates/testimonials.rb +11 -0
  83. data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
  84. data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
  85. data/lib/rivet_cms/access_check.rb +12 -0
  86. data/lib/rivet_cms/audit.rb +43 -0
  87. data/lib/rivet_cms/engine.rb +28 -22
  88. data/lib/rivet_cms/hooks.rb +106 -0
  89. data/lib/rivet_cms/navigation.rb +111 -0
  90. data/lib/rivet_cms/safe_pattern.rb +40 -0
  91. data/lib/rivet_cms/seeds.rb +264 -0
  92. data/lib/rivet_cms/version.rb +1 -1
  93. data/lib/rivet_cms.rb +289 -32
  94. data/lib/tasks/rivet_cms_tasks.rake +18 -4
  95. metadata +138 -98
  96. data/MIT-LICENSE +0 -20
  97. data/app/assets/builds/rivet_cms.js.map +0 -7
  98. data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
  99. data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
  100. data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
  101. data/app/helpers/rivet_cms/flash_helper.rb +0 -37
  102. data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
  103. data/app/javascript/controllers/content_type_form_controller.js +0 -53
  104. data/app/javascript/controllers/field_layout_controller.js +0 -709
  105. data/app/javascript/rivet_cms.js +0 -29
  106. data/app/models/rivet_cms/content.rb +0 -4
  107. data/app/models/rivet_cms/field_values/base.rb +0 -11
  108. data/app/models/rivet_cms/field_values/boolean.rb +0 -4
  109. data/app/models/rivet_cms/field_values/integer.rb +0 -4
  110. data/app/models/rivet_cms/field_values/string.rb +0 -4
  111. data/app/models/rivet_cms/field_values/text.rb +0 -4
  112. data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
  113. data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
  114. data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
  115. data/app/views/rivet_cms/content_types/index.html.erb +0 -151
  116. data/app/views/rivet_cms/content_types/new.html.erb +0 -19
  117. data/app/views/rivet_cms/content_types/show.html.erb +0 -147
  118. data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
  119. data/app/views/rivet_cms/fields/_form.html.erb +0 -111
  120. data/app/views/rivet_cms/fields/edit.html.erb +0 -25
  121. data/app/views/rivet_cms/fields/index.html.erb +0 -126
  122. data/app/views/rivet_cms/fields/new.html.erb +0 -25
  123. data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
  124. data/config/i18n-tasks.yml +0 -178
  125. data/config/locales/en.yml +0 -14
  126. data/db/migrate/20250317194359_create_core_tables.rb +0 -90
  127. data/lib/rivet_cms/routes.rb +0 -49
@@ -0,0 +1,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
@@ -1,3 +1,3 @@
1
1
  module RivetCms
2
- VERSION = "0.1.2.pre"
2
+ VERSION = "0.3.0"
3
3
  end
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
- def configuration
10
- @configuration ||= Configuration.new
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
- def configuration=(config)
14
- @configuration = config
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
- def self.configure
19
- self.configuration ||= Configuration.new
20
- yield(configuration) if block_given?
21
- validate_user_class(configuration.user_class)
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
- class Configuration
25
- attr_accessor :user_class, :image_variants, :default_roles, :current_user_method,
26
- :prefixed_ids_length, :prefixed_ids_salt, :prefixed_ids_alphabet,
27
- :sign_out_path
28
-
29
- def initialize
30
- @user_class = "RivetCmsAuth::User"
31
- @image_variants = {
32
- thumbnail: { width: 100, height: 100, resize: "fit" },
33
- medium: { width: 800, height: 600, resize: "limit" }
34
- }
35
- @default_roles = %w[admin editor contributor]
36
- @current_user_method = "current_user"
37
- @prefixed_ids_length = 32
38
- @prefixed_ids_salt = "rivet-cms-#{Rails.env}"
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
- # desc "Explaining what the task does"
2
- # task :rivet_cms do
3
- # # Task goes here
4
- # end
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