rivet_cms 0.1.2.pre → 0.3.1

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 +63 -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 +207 -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 +17 -0
  67. data/db/migrate/20251121040115_create_rivet_cms_categories.rb +17 -0
  68. data/db/migrate/20260119185946_create_content_system_tables.rb +97 -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,123 @@
1
+ module RivetCms
2
+ class RevisionSerializer
3
+ RICH_TEXT_TAGS = %w[p br strong em b i u s del a img ul ol li h1 h2 h3 h4 h5 h6 blockquote pre code hr].freeze
4
+ RICH_TEXT_ATTRS = %w[href src alt title class target rel width height style].freeze
5
+
6
+ # fields: root-level key whitelist (nil = all). populate: reference Field
7
+ # records to expand one level deep. preview: serve draft targets and skip
8
+ # the published-only relation filter. preload: a shared RevisionPreloader
9
+ # (built automatically for single-revision use).
10
+ def initialize(revision, fields: nil, populate: [], preview: false, preload: nil)
11
+ @revision = revision
12
+ @field_keys = fields
13
+ @populate = populate
14
+ @populate_field_ids = populate.map(&:id).to_set
15
+ @preview = preview
16
+ @preload = preload
17
+ end
18
+
19
+ def as_json(*)
20
+ return nil if @revision.nil?
21
+
22
+ @preload ||= RevisionPreloader.new([ @revision ], populate_fields: @populate, preview: @preview)
23
+ document = @preload.document_for(@revision)
24
+ fields = @preload.fields_for_content_type(document.content_type_id)
25
+ fields = fields.select { |field| @field_keys.include?(field.key) } if @field_keys
26
+
27
+ {
28
+ id: document.prefix_id,
29
+ slug: document.slug,
30
+ content_type: document.content_type.slug,
31
+ state: @revision.state,
32
+ data: serialize_owner(@revision, fields, root: true)
33
+ }
34
+ end
35
+
36
+ private
37
+
38
+ def serialize_owner(owner, fields, root:)
39
+ fields.each_with_object({}) do |field, data|
40
+ data[field.key] = serialize_field(owner, field, root: root)
41
+ end
42
+ end
43
+
44
+ def serialize_field(owner, field, root:)
45
+ case field.field_type
46
+ when "reference"
47
+ serialize_reference(owner, field, root: root)
48
+ when "component"
49
+ collapse(field, @preload.component_instances(owner, field).map { |instance|
50
+ serialize_owner(instance, @preload.fields_for_component(instance.component_id), root: false)
51
+ })
52
+ when "image", "video", "file"
53
+ attachment_json(@preload.value(owner, field)&.media_asset)
54
+ when "rich_text"
55
+ sanitize(@preload.value(owner, field)&.value)
56
+ else
57
+ scalar_json(@preload.value(owner, field)&.value)
58
+ end
59
+ end
60
+
61
+ # ActiveSupport encodes BigDecimal as a JSON string; the API promises a number.
62
+ def scalar_json(value)
63
+ value.is_a?(BigDecimal) ? value.to_f : value
64
+ end
65
+
66
+ def serialize_reference(owner, field, root:)
67
+ relations = visible_relations(@preload.relations(owner, field))
68
+
69
+ if root && @populate_field_ids.include?(field.id)
70
+ collapse(field, relations.filter_map { |relation| populated_json(relation) })
71
+ else
72
+ collapse(field, relations.map { |relation| reference_json(relation) })
73
+ end
74
+ end
75
+
76
+ # Published scope must not reveal draft-only documents, even as {id, slug}.
77
+ # Targets whose content type has been removed are hidden the same way, in
78
+ # both scopes: their type is no longer served, so neither are they.
79
+ def visible_relations(relations)
80
+ # A target can be missing entirely (trashed entry) or belong to a removed
81
+ # type; either way it is no longer served, so neither is the reference.
82
+ visible = relations.reject { |relation| relation.target_document.nil? || relation.target_document.content_type.nil? }
83
+ return visible if @preview
84
+
85
+ visible.reject { |relation| relation.target_document.published_revision_id.nil? }
86
+ end
87
+
88
+ def reference_json(relation)
89
+ { id: relation.target_document.prefix_id, slug: relation.target_document.slug }
90
+ end
91
+
92
+ def populated_json(relation)
93
+ target = relation.target_document
94
+ revision = @preload.target_revision(target.id)
95
+ return nil if revision.nil?
96
+
97
+ {
98
+ id: target.prefix_id,
99
+ slug: target.slug,
100
+ content_type: target.content_type.slug,
101
+ state: revision.state,
102
+ data: serialize_owner(revision, @preload.fields_for_content_type(target.content_type_id), root: false)
103
+ }
104
+ end
105
+
106
+ def attachment_json(asset)
107
+ return nil unless asset
108
+
109
+ { id: asset.prefix_id, filename: asset.filename, content_type: asset.content_type, byte_size: asset.byte_size,
110
+ title: asset.title, alt: asset.alt, description: asset.description, url: asset.url }
111
+ end
112
+
113
+ def collapse(field, items)
114
+ field.max_items == 1 ? items.first : items
115
+ end
116
+
117
+ def sanitize(html)
118
+ return nil if html.blank?
119
+
120
+ ActionController::Base.helpers.sanitize(html.to_s, tags: RICH_TEXT_TAGS, attributes: RICH_TEXT_ATTRS)
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,158 @@
1
+ module RivetCms
2
+ # Builds the published-document query for one content type: whitelisted sort,
3
+ # date-range filters, pagination clamp, and populate/fields validation.
4
+ # Shared by the delivery API controller and the Ruby content helpers so both
5
+ # access paths keep identical semantics.
6
+ class ContentQuery
7
+ class Error < StandardError; end
8
+
9
+ DEFAULT_PER_PAGE = 25
10
+ MAX_PER_PAGE = 100
11
+ DOCUMENT_SORTS = { "created_at" => :created_at, "updated_at" => :updated_at, "slug" => :slug }.freeze
12
+
13
+ def initialize(content_type, sort: nil, filters: {}, page: nil, per_page: nil, populate: nil, fields: nil)
14
+ @content_type = content_type
15
+ @sort = sort
16
+ @filters = normalize_filters(filters)
17
+ @page = page
18
+ @per_page = per_page
19
+ @populate = populate
20
+ @fields = fields
21
+ end
22
+
23
+ # Kaminari page of published documents, ordered and filtered.
24
+ def documents
25
+ base = @content_type.documents.where.not(published_revision_id: nil).includes(:published_revision)
26
+ ordered(filtered(base)).page(@page).per(per_page)
27
+ end
28
+
29
+ # Validated reference Field records to expand, already dropped when a
30
+ # fields selection excludes them so their targets are never preloaded.
31
+ def populate_fields
32
+ @populate_fields ||= begin
33
+ populate = resolve_populate
34
+ keys = field_keys
35
+ keys ? populate.select { |field| keys.include?(field.key) } : populate
36
+ end
37
+ end
38
+
39
+ def field_keys
40
+ return @field_keys if defined?(@field_keys)
41
+
42
+ keys = normalize_list(@fields)
43
+ return @field_keys = nil if keys.empty?
44
+
45
+ unknown = keys - @content_type.fields.kept.pluck(:key)
46
+ raise Error, "unknown field: #{unknown.first}" if unknown.any?
47
+
48
+ @field_keys = keys
49
+ end
50
+
51
+ private
52
+
53
+ def per_page
54
+ requested = @per_page.to_i
55
+ requested = DEFAULT_PER_PAGE if requested <= 0
56
+ requested.clamp(1, MAX_PER_PAGE)
57
+ end
58
+
59
+ def reference_fields
60
+ @reference_fields ||= @content_type.fields.kept.reference.ordered.to_a
61
+ end
62
+
63
+ def resolve_populate
64
+ return reference_fields if @populate == :all || @populate.to_s.strip == "*"
65
+
66
+ normalize_list(@populate).map do |key|
67
+ reference_fields.find { |field| field.key == key } ||
68
+ raise(Error, "cannot populate field: #{key}")
69
+ end
70
+ end
71
+
72
+ # Accepts the API's comma-separated string or a Ruby array of keys/symbols.
73
+ def normalize_list(raw)
74
+ list = raw.is_a?(Array) ? raw.map(&:to_s) : raw.to_s.split(",")
75
+ list.map(&:strip).reject(&:blank?)
76
+ end
77
+
78
+ # Bounds may use string or symbol gte/lte keys; values may be strings or
79
+ # Date/Time objects.
80
+ def normalize_filters(filters)
81
+ (filters || {}).each_with_object({}) do |(key, bounds), normalized|
82
+ next unless bounds.is_a?(Hash)
83
+
84
+ gte = bounds["gte"] || bounds[:gte]
85
+ lte = bounds["lte"] || bounds[:lte]
86
+ normalized[key.to_s] = { "gte" => gte, "lte" => lte } if gte.present? || lte.present?
87
+ end
88
+ end
89
+
90
+ def ordered(scope)
91
+ key, direction = parse_sort
92
+ return scope.order(DOCUMENT_SORTS.fetch(key) => direction) if DOCUMENT_SORTS.key?(key)
93
+
94
+ if key == "published_at"
95
+ return scope
96
+ .joins("INNER JOIN rivet_cms_document_revisions pub ON pub.id = rivet_cms_documents.published_revision_id")
97
+ .order(Arel.sql("pub.published_at #{direction}"))
98
+ end
99
+
100
+ field = date_field(key)
101
+ raise Error, "unknown sort field: #{key}" if field.nil?
102
+
103
+ scope.joins(field_join(field, "cv_sort")).order(Arel.sql("cv_sort.#{date_column(field)} #{direction}"))
104
+ end
105
+
106
+ def filtered(scope)
107
+ @filters.each_with_index do |(key, bounds), index|
108
+ field = date_field(key)
109
+ raise Error, "unknown filter field: #{key}" if field.nil?
110
+
111
+ alias_name = "cv_f#{index}"
112
+ scope = scope.joins(field_join(field, alias_name))
113
+ scope = apply_bound(scope, field, alias_name, ">=", bounds["gte"]) if bounds["gte"].present?
114
+ scope = apply_bound(scope, field, alias_name, "<=", bounds["lte"]) if bounds["lte"].present?
115
+ end
116
+ scope
117
+ end
118
+
119
+ def parse_sort
120
+ raw = @sort.to_s
121
+ return [ "published_at", :desc ] if raw.blank?
122
+
123
+ raw.start_with?("-") ? [ raw[1..], :desc ] : [ raw, :asc ]
124
+ end
125
+
126
+ def field_join(field, alias_name)
127
+ <<~SQL.squish
128
+ LEFT JOIN rivet_cms_content_values #{alias_name}
129
+ ON #{alias_name}.owner_type = 'RivetCms::DocumentRevision'
130
+ AND #{alias_name}.owner_id = rivet_cms_documents.published_revision_id
131
+ AND #{alias_name}.field_id = #{field.id.to_i}
132
+ SQL
133
+ end
134
+
135
+ def apply_bound(scope, field, alias_name, operator, value)
136
+ scope.where("#{alias_name}.#{date_column(field)} #{operator} ?", parse_date(field, value))
137
+ end
138
+
139
+ def date_field(key)
140
+ @content_type.fields.kept.find_by(key: key, field_type: [ :date, :datetime ])
141
+ end
142
+
143
+ def date_column(field)
144
+ field.date? ? "date_value" : "datetime_value"
145
+ end
146
+
147
+ def parse_date(field, value)
148
+ return value if value.respond_to?(:strftime)
149
+
150
+ parsed = field.date? ? Date.parse(value) : Time.zone.parse(value)
151
+ raise Error, "invalid date: #{value}" if parsed.nil?
152
+
153
+ parsed
154
+ rescue ArgumentError, TypeError, Date::Error
155
+ raise Error, "invalid date: #{value}"
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,150 @@
1
+ module RivetCms
2
+ class ContentValidator
3
+ Error = Struct.new(:field_key, :message)
4
+
5
+ attr_reader :errors
6
+
7
+ def initialize(revision)
8
+ @revision = revision
9
+ @errors = []
10
+ end
11
+
12
+ def valid?
13
+ @errors.empty?
14
+ end
15
+
16
+ def validate
17
+ raise TrashedEntryError, "entry is in the trash; restore it before validating" if @revision.document.nil?
18
+ raise RemovedContentTypeError, "content type was removed; restore it before validating" if content_type.nil?
19
+
20
+ validate_owner(@revision, content_type.fields.kept)
21
+ self
22
+ end
23
+
24
+ def messages
25
+ @errors.map { |e| "#{e.field_key} #{e.message}" }
26
+ end
27
+
28
+ private
29
+
30
+ def content_type
31
+ @revision.document.content_type
32
+ end
33
+
34
+ def validate_owner(owner, fields)
35
+ values_by_field = owner.content_values.includes(:field, :media_asset).index_by(&:field_id)
36
+ # Only targets that still exist: copy_owned_into drops the rest, so
37
+ # counting them would let a required reference publish empty.
38
+ relation_counts = owner.relations.where(target_document_id: Document.select(:id)).group(:field_id).count
39
+ instances_by_field = owner.component_instances.includes(component: :fields).group_by(&:field_id)
40
+
41
+ fields.each do |field|
42
+ case field.field_type
43
+ when "reference"
44
+ validate_cardinality(field, relation_counts.fetch(field.id, 0))
45
+ when "component"
46
+ validate_component_field(field, instances_by_field.fetch(field.id, []))
47
+ when "image", "video", "file"
48
+ validate_attachment_field(field, values_by_field[field.id]&.media_asset)
49
+ else
50
+ validate_scalar_field(field, values_by_field[field.id]&.value)
51
+ end
52
+ end
53
+ end
54
+
55
+ def validate_scalar_field(field, value)
56
+ return add(field, "is required") if field.required? && blank_value?(value)
57
+ return if blank_value?(value)
58
+
59
+ validate_config_constraints(field, value)
60
+ end
61
+
62
+ def validate_component_field(field, instances)
63
+ validate_cardinality(field, instances.size)
64
+ instances.each { |instance| validate_owner(instance, instance.component.fields.kept) }
65
+ end
66
+
67
+ def validate_attachment_field(field, asset)
68
+ return add(field, "is required") if field.required? && asset.nil?
69
+ return unless asset
70
+
71
+ validate_media(field, asset)
72
+ end
73
+
74
+ def validate_cardinality(field, count)
75
+ minimum = field.min_items || (field.required? ? 1 : 0)
76
+ add(field, "requires at least #{minimum}") if count < minimum
77
+ add(field, "allows at most #{field.max_items}") if field.max_items && count > field.max_items
78
+ end
79
+
80
+ def validate_config_constraints(field, value)
81
+ config = field.config || {}
82
+
83
+ if field.integer? || field.decimal?
84
+ min = numeric_bound(config["min"])
85
+ max = numeric_bound(config["max"])
86
+ add(field, "is too small") if min && value < min
87
+ add(field, "is too large") if max && value > max
88
+ elsif field.enumeration?
89
+ add(field, "is not a valid choice") unless Array(config["choices"]).include?(value)
90
+ else
91
+ length = value.to_s.length
92
+ min_length = numeric_bound(config["min_length"])&.to_i
93
+ max_length = numeric_bound(config["max_length"])&.to_i
94
+ add(field, "is too short") if min_length && length < min_length
95
+ add(field, "is too long") if max_length && length > max_length
96
+ validate_pattern(field, value, config["pattern"])
97
+ end
98
+ end
99
+
100
+ def validate_pattern(field, value, pattern)
101
+ return if pattern.blank?
102
+ return unless field.string? || field.text?
103
+
104
+ add(field, "does not match the required format") unless SafePattern.match?(pattern, value)
105
+ end
106
+
107
+ # Config values arrive from the admin UI as strings; comparing them
108
+ # against typed values directly would raise.
109
+ def numeric_bound(raw)
110
+ return nil if raw.blank?
111
+
112
+ Float(raw)
113
+ rescue ArgumentError, TypeError
114
+ nil
115
+ end
116
+
117
+ def validate_media(field, asset)
118
+ config = field.config || {}
119
+ add(field, "has a disallowed file type") unless allowed_extension?(config["allowed_types"], asset)
120
+
121
+ max_mb = config["max_size_mb"].presence&.to_f
122
+ add(field, "is too large (max #{max_mb} MB)") if max_mb && asset.byte_size.to_i > max_mb * 1.megabyte
123
+ end
124
+
125
+ def allowed_extension?(allowed, asset)
126
+ list = normalize_extensions(allowed)
127
+ return true if list.empty?
128
+
129
+ list.include?(normalize_extension(File.extname(asset.filename.to_s)))
130
+ end
131
+
132
+ def normalize_extensions(allowed)
133
+ list = allowed.is_a?(::String) ? allowed.split(",") : Array(allowed)
134
+ list.map { |ext| normalize_extension(ext) }.reject(&:empty?)
135
+ end
136
+
137
+ def normalize_extension(ext)
138
+ ext = ext.to_s.strip.delete_prefix(".").downcase
139
+ ext == "jpeg" ? "jpg" : ext
140
+ end
141
+
142
+ def add(field, message)
143
+ @errors << Error.new(field.key, message)
144
+ end
145
+
146
+ def blank_value?(value)
147
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,74 @@
1
+ module RivetCms
2
+ class DraftWriter
3
+ # References whose target was trashed are dropped on save; the caller
4
+ # reports that rather than letting the link vanish silently.
5
+ attr_reader :dropped_references
6
+
7
+ def initialize(revision)
8
+ @revision = revision
9
+ @dropped_references = 0
10
+ end
11
+
12
+ def write(values)
13
+ raise TrashedEntryError, "entry is in the trash; restore it before editing" if @revision.document.nil?
14
+
15
+ content_type = @revision.document.content_type
16
+ raise RemovedContentTypeError, "content type was removed; restore it before editing" if content_type.nil?
17
+
18
+ write_values(@revision, content_type.fields.kept, values)
19
+ @revision
20
+ end
21
+
22
+ private
23
+
24
+ def write_values(owner, fields, values)
25
+ fields_by_key = fields.index_by(&:key)
26
+
27
+ values.each do |key, raw|
28
+ field = fields_by_key[key.to_s]
29
+ next unless field
30
+
31
+ case field.field_type
32
+ when "reference"
33
+ write_relations(owner, field, raw)
34
+ when "component"
35
+ write_components(owner, field, raw)
36
+ else
37
+ write_scalar(owner, field, raw)
38
+ end
39
+ end
40
+ end
41
+
42
+ def write_scalar(owner, field, raw)
43
+ value = owner.content_values.find_or_initialize_by(field: field)
44
+ value.value = raw
45
+ value.save!
46
+ end
47
+
48
+ def write_relations(owner, field, ids)
49
+ owner.relations.where(field_id: field.id).destroy_all
50
+
51
+ # Silently drop targets that have been trashed since: the editor round
52
+ # trips their ids, and a required belongs_to would raise on save.
53
+ given = Array(ids).reject(&:blank?)
54
+ live_ids = Document.where(id: given).pluck(:id).to_set
55
+ live = given.select { |id| live_ids.include?(id.to_i) }
56
+ @dropped_references += given.size - live.size
57
+ live.each_with_index do |target_id, index|
58
+ owner.relations.create!(field: field, target_document_id: target_id, position: index)
59
+ end
60
+ end
61
+
62
+ def write_components(owner, field, entries)
63
+ component = Component.find_by(id: field.config&.dig("component_id"), organization_id: field.organization_id)
64
+ return unless component
65
+
66
+ owner.component_instances.where(field_id: field.id).destroy_all
67
+
68
+ Array(entries).each_with_index do |entry, index|
69
+ instance = owner.component_instances.create!(field: field, component: component, position: index)
70
+ write_values(instance, component.fields.kept, entry["values"] || {})
71
+ end
72
+ end
73
+ end
74
+ end