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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -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 +207 -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 +17 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +17 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +97 -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,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
|