rivet_cms 0.1.2.pre → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -0
- data/COPYING +674 -0
- data/COPYING.LESSER +165 -0
- data/README.md +173 -10
- data/app/assets/builds/rivet_cms.css +2 -2
- data/app/assets/builds/rivet_cms.js +395 -9505
- data/app/assets/stylesheets/rivet_cms/application.tailwind.css +145 -22
- data/app/controllers/concerns/rivet_cms/inertia_props.rb +239 -0
- data/app/controllers/rivet_cms/api_docs_controller.rb +47 -0
- data/app/controllers/rivet_cms/api_tokens_controller.rb +43 -0
- data/app/controllers/rivet_cms/application_controller.rb +220 -2
- data/app/controllers/rivet_cms/auth_controller.rb +28 -0
- data/app/controllers/rivet_cms/components_controller.rb +93 -2
- data/app/controllers/rivet_cms/content_controller.rb +105 -0
- data/app/controllers/rivet_cms/content_manager_controller.rb +32 -0
- data/app/controllers/rivet_cms/content_types_controller.rb +118 -26
- data/app/controllers/rivet_cms/dashboard_controller.rb +55 -5
- data/app/controllers/rivet_cms/documents_controller.rb +204 -0
- data/app/controllers/rivet_cms/fields_controller.rb +77 -74
- data/app/controllers/rivet_cms/invitations_controller.rb +36 -0
- data/app/controllers/rivet_cms/media_assets_controller.rb +76 -0
- data/app/controllers/rivet_cms/sessions_controller.rb +122 -0
- data/app/controllers/rivet_cms/setup_controller.rb +60 -0
- data/app/controllers/rivet_cms/trash_controller.rb +68 -0
- data/app/controllers/rivet_cms/users_controller.rb +117 -0
- data/app/helpers/rivet_cms/application_helper.rb +13 -3
- data/app/helpers/rivet_cms/components_helper.rb +4 -0
- data/app/helpers/rivet_cms/content_types_helper.rb +4 -0
- data/app/helpers/rivet_cms/dashboard_helper.rb +4 -0
- data/app/jobs/rivet_cms/webhook_delivery_job.rb +35 -0
- data/app/models/concerns/rivet_cms/has_fields.rb +31 -0
- data/app/models/concerns/rivet_cms/organization_scoped.rb +17 -0
- data/app/models/concerns/rivet_cms/revision_owned.rb +36 -0
- data/app/models/concerns/rivet_cms/sluggable.rb +38 -0
- data/app/models/concerns/rivet_cms/soft_deletable.rb +42 -0
- data/app/models/concerns/rivet_cms/typed_value.rb +51 -0
- data/app/models/rivet_cms/api_token.rb +64 -0
- data/app/models/rivet_cms/category.rb +20 -0
- data/app/models/rivet_cms/component.rb +10 -0
- data/app/models/rivet_cms/component_instance.rb +27 -0
- data/app/models/rivet_cms/content_type.rb +36 -23
- data/app/models/rivet_cms/content_value.rb +19 -0
- data/app/models/rivet_cms/current.rb +6 -0
- data/app/models/rivet_cms/document.rb +71 -0
- data/app/models/rivet_cms/document_revision.rb +128 -0
- data/app/models/rivet_cms/entry.rb +73 -0
- data/app/models/rivet_cms/entry_collection.rb +41 -0
- data/app/models/rivet_cms/field.rb +203 -61
- data/app/models/rivet_cms/media_asset.rb +109 -0
- data/app/models/rivet_cms/organization.rb +46 -0
- data/app/models/rivet_cms/relation.rb +23 -0
- data/app/models/rivet_cms/user.rb +61 -0
- data/app/serializers/rivet_cms/revision_preloader.rb +146 -0
- data/app/serializers/rivet_cms/revision_serializer.rb +123 -0
- data/app/services/rivet_cms/content_query.rb +158 -0
- data/app/services/rivet_cms/content_validator.rb +150 -0
- data/app/services/rivet_cms/draft_writer.rb +74 -0
- data/app/services/rivet_cms/open_api_generator.rb +173 -221
- data/app/services/rivet_cms/revision_pruner.rb +64 -0
- data/app/services/rivet_cms/webhooks.rb +61 -0
- data/app/views/layouts/rivet_cms/application.html.erb +35 -42
- data/config/routes.rb +68 -45
- data/db/migrate/20251119193607_create_rivet_cms_organizations.rb +17 -0
- data/db/migrate/20251120050818_create_rivet_cms_content_types.rb +14 -0
- data/db/migrate/20251121020625_create_rivet_cms_components.rb +14 -0
- data/db/migrate/20251121040115_create_rivet_cms_categories.rb +14 -0
- data/db/migrate/20260119185946_create_content_system_tables.rb +95 -0
- data/db/migrate/20260722000001_create_rivet_cms_media_assets.rb +15 -0
- data/db/migrate/20260722000002_create_rivet_cms_api_tokens.rb +16 -0
- data/db/migrate/20260806000001_add_title_and_alt_to_rivet_cms_media_assets.rb +7 -0
- data/db/migrate/20260806000002_add_deleted_at_to_rivet_cms_content_types.rb +6 -0
- data/db/migrate/20260807000001_add_deleted_at_to_rivet_cms_documents.rb +6 -0
- data/db/migrate/20260808000001_create_rivet_cms_users.rb +15 -0
- data/db/seeds/README.md +70 -0
- data/db/seeds/templates/blog.rb +39 -0
- data/db/seeds/templates/events.rb +12 -0
- data/db/seeds/templates/faq.rb +9 -0
- data/db/seeds/templates/pages.rb +35 -0
- data/db/seeds/templates/site_settings.rb +22 -0
- data/db/seeds/templates/team.rb +11 -0
- data/db/seeds/templates/testimonials.rb +11 -0
- data/lib/generators/rivet_cms/install/install_generator.rb +13 -0
- data/lib/generators/rivet_cms/install/templates/initializer.rb +77 -0
- data/lib/rivet_cms/access_check.rb +12 -0
- data/lib/rivet_cms/audit.rb +43 -0
- data/lib/rivet_cms/engine.rb +28 -22
- data/lib/rivet_cms/hooks.rb +106 -0
- data/lib/rivet_cms/navigation.rb +111 -0
- data/lib/rivet_cms/safe_pattern.rb +40 -0
- data/lib/rivet_cms/seeds.rb +264 -0
- data/lib/rivet_cms/version.rb +1 -1
- data/lib/rivet_cms.rb +289 -32
- data/lib/tasks/rivet_cms_tasks.rake +18 -4
- metadata +138 -98
- data/MIT-LICENSE +0 -20
- data/app/assets/builds/rivet_cms.js.map +0 -7
- data/app/assets/stylesheets/rivet_cms/brand_colors.css +0 -168
- data/app/controllers/rivet_cms/api/docs_controller.rb +0 -38
- data/app/helpers/rivet_cms/brand_color_helper.rb +0 -71
- data/app/helpers/rivet_cms/flash_helper.rb +0 -37
- data/app/helpers/rivet_cms/sign_out_helper.rb +0 -11
- data/app/javascript/controllers/content_type_form_controller.js +0 -53
- data/app/javascript/controllers/field_layout_controller.js +0 -709
- data/app/javascript/rivet_cms.js +0 -29
- data/app/models/rivet_cms/content.rb +0 -4
- data/app/models/rivet_cms/field_values/base.rb +0 -11
- data/app/models/rivet_cms/field_values/boolean.rb +0 -4
- data/app/models/rivet_cms/field_values/integer.rb +0 -4
- data/app/models/rivet_cms/field_values/string.rb +0 -4
- data/app/models/rivet_cms/field_values/text.rb +0 -4
- data/app/views/rivet_cms/api/docs/show.html.erb +0 -47
- data/app/views/rivet_cms/content_types/_form.html.erb +0 -98
- data/app/views/rivet_cms/content_types/edit.html.erb +0 -27
- data/app/views/rivet_cms/content_types/index.html.erb +0 -151
- data/app/views/rivet_cms/content_types/new.html.erb +0 -19
- data/app/views/rivet_cms/content_types/show.html.erb +0 -147
- data/app/views/rivet_cms/dashboard/index.html.erb +0 -263
- data/app/views/rivet_cms/fields/_form.html.erb +0 -111
- data/app/views/rivet_cms/fields/edit.html.erb +0 -25
- data/app/views/rivet_cms/fields/index.html.erb +0 -126
- data/app/views/rivet_cms/fields/new.html.erb +0 -25
- data/app/views/rivet_cms/shared/_navigation.html.erb +0 -153
- data/config/i18n-tasks.yml +0 -178
- data/config/locales/en.yml +0 -14
- data/db/migrate/20250317194359_create_core_tables.rb +0 -90
- data/lib/rivet_cms/routes.rb +0 -49
|
@@ -1,82 +1,224 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
class Field < ApplicationRecord
|
|
3
|
-
|
|
3
|
+
include SoftDeletable
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
attribute :width, :string, default: 'full'
|
|
5
|
+
has_prefix_id :fld
|
|
6
|
+
include OrganizationScoped
|
|
8
7
|
|
|
9
|
-
belongs_to :content_type
|
|
8
|
+
belongs_to :content_type, optional: true
|
|
10
9
|
belongs_to :component, optional: true
|
|
11
|
-
has_many :
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
10
|
+
has_many :content_values, dependent: :destroy
|
|
11
|
+
|
|
12
|
+
enum :field_type, {
|
|
13
|
+
string: 0,
|
|
14
|
+
text: 1,
|
|
15
|
+
rich_text: 2,
|
|
16
|
+
markdown: 3,
|
|
17
|
+
integer: 4,
|
|
18
|
+
boolean: 5,
|
|
19
|
+
image: 6,
|
|
20
|
+
video: 7,
|
|
21
|
+
file: 8,
|
|
22
|
+
reference: 9,
|
|
23
|
+
component: 10,
|
|
24
|
+
date: 11,
|
|
25
|
+
datetime: 12,
|
|
26
|
+
decimal: 13,
|
|
27
|
+
enumeration: 14
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
enum :width, { full: "full", half: "half" }, prefix: true
|
|
31
|
+
|
|
32
|
+
validates :key, presence: true, format: { with: /\A[a-z][a-z0-9_]*\z/, message: "must be lowercase snake_case" }
|
|
33
|
+
validates :key, uniqueness: { scope: :content_type_id, conditions: -> { where(deleted_at: nil) } }, if: -> { content_type_id.present? }
|
|
34
|
+
validates :key, uniqueness: { scope: :component_id, conditions: -> { where(deleted_at: nil) } }, if: -> { component_id.present? }
|
|
35
|
+
validates :label, presence: true
|
|
36
|
+
validates :field_type, presence: true
|
|
37
|
+
validate :belongs_to_one_owner
|
|
38
|
+
validate :components_cannot_embed_components
|
|
39
|
+
validate :config_contents
|
|
40
|
+
|
|
41
|
+
before_validation :derive_key_from_label, on: :create
|
|
24
42
|
before_create :set_default_position
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
before_create :set_default_row
|
|
44
|
+
|
|
45
|
+
scope :ordered, -> { order(row: :asc, position: :asc) }
|
|
46
|
+
|
|
47
|
+
# Human-readable field type labels for UI
|
|
48
|
+
FIELD_TYPE_LABELS = {
|
|
49
|
+
"string" => "Short text",
|
|
50
|
+
"text" => "Long text",
|
|
51
|
+
"rich_text" => "Rich text",
|
|
52
|
+
"markdown" => "Markdown",
|
|
53
|
+
"integer" => "Number",
|
|
54
|
+
"decimal" => "Decimal",
|
|
55
|
+
"enumeration" => "Select",
|
|
56
|
+
"boolean" => "True/False",
|
|
57
|
+
"image" => "Image",
|
|
58
|
+
"video" => "Video",
|
|
59
|
+
"file" => "File",
|
|
60
|
+
"reference" => "Reference",
|
|
61
|
+
"component" => "Component",
|
|
62
|
+
"date" => "Date",
|
|
63
|
+
"datetime" => "Date & Time"
|
|
64
|
+
}.freeze
|
|
65
|
+
|
|
66
|
+
def field_type_label
|
|
67
|
+
FIELD_TYPE_LABELS[field_type] || field_type.humanize
|
|
68
|
+
end
|
|
69
|
+
|
|
27
70
|
def self.field_types_for_select
|
|
28
|
-
[
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def field_type_name
|
|
41
|
-
case field_type
|
|
42
|
-
when 'string' then 'Short text'
|
|
43
|
-
when 'text' then 'Long text'
|
|
44
|
-
when 'integer' then 'Number'
|
|
45
|
-
when 'boolean' then 'True/False'
|
|
46
|
-
when 'media' then 'Media'
|
|
47
|
-
when 'relation' then 'Relation'
|
|
48
|
-
when 'component' then 'Component'
|
|
49
|
-
else field_type.humanize
|
|
71
|
+
FIELD_TYPE_LABELS.map { |value, label| [ label, value ] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def attachment?
|
|
75
|
+
image? || video? || file?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.reorder!(ordered_ids)
|
|
79
|
+
transaction do
|
|
80
|
+
ordered_ids.each_with_index do |id, index|
|
|
81
|
+
where(id: id).update_all(position: index)
|
|
82
|
+
end
|
|
50
83
|
end
|
|
51
84
|
end
|
|
52
|
-
|
|
53
|
-
# Update
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
85
|
+
|
|
86
|
+
# Update layout with row structure
|
|
87
|
+
# rows_config: array of arrays, each inner array contains numeric field
|
|
88
|
+
# IDs for that row, e.g. [[1], [2, 3], [4]]. Prefixed ids do not resolve
|
|
89
|
+
# through where(id:) and would silently match nothing.
|
|
90
|
+
def self.update_layout!(rows_config)
|
|
57
91
|
transaction do
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
92
|
+
rows_config.each_with_index do |field_ids, row_index|
|
|
93
|
+
field_ids.each_with_index do |field_id, position_in_row|
|
|
94
|
+
where(id: field_id).update_all(row: row_index, position: position_in_row)
|
|
95
|
+
end
|
|
61
96
|
end
|
|
62
97
|
end
|
|
63
98
|
end
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
99
|
+
|
|
100
|
+
# Get the other field paired in the same row (if any)
|
|
101
|
+
def paired_field
|
|
102
|
+
return nil unless width_half?
|
|
103
|
+
|
|
104
|
+
sibling_fields.where(row: row, width: "half").where.not(id: id).first
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def paired?
|
|
108
|
+
paired_field.present?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Move this field to its own new row
|
|
112
|
+
def unpair!
|
|
113
|
+
return unless paired?
|
|
114
|
+
|
|
115
|
+
move_to_own_row!
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Move this field to its own new row
|
|
119
|
+
def move_to_own_row!
|
|
120
|
+
max_row = sibling_fields.maximum(:row) || 0
|
|
121
|
+
update!(row: max_row + 1, position: 0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Pair this field with another field on the same row
|
|
125
|
+
def pair_with!(other_field)
|
|
126
|
+
return unless width_half? && other_field.width_half?
|
|
127
|
+
return if paired? || other_field.paired?
|
|
128
|
+
|
|
129
|
+
other_field.update!(row: row, position: 1)
|
|
130
|
+
end
|
|
131
|
+
|
|
67
132
|
private
|
|
68
|
-
|
|
133
|
+
|
|
134
|
+
def sibling_fields
|
|
135
|
+
scope = content_type_id.present? ? { content_type_id: content_type_id } : { component_id: component_id }
|
|
136
|
+
self.class.kept.where(scope)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def derive_key_from_label
|
|
140
|
+
return if key.present?
|
|
141
|
+
|
|
142
|
+
self.key = label.to_s.parameterize(separator: "_")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def components_cannot_embed_components
|
|
146
|
+
return unless component_id.present? && field_type == "component"
|
|
147
|
+
|
|
148
|
+
errors.add(:field_type, "component fields cannot be nested inside components")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def config_contents
|
|
152
|
+
validate_pattern_config
|
|
153
|
+
validate_choices_config
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def validate_pattern_config
|
|
157
|
+
pattern = config&.dig("pattern")
|
|
158
|
+
return if pattern.blank?
|
|
159
|
+
|
|
160
|
+
unless field_type.in?(%w[string text])
|
|
161
|
+
return errors.add(:config, "pattern is only supported on text fields")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if pattern.length > SafePattern::MAX_LENGTH
|
|
165
|
+
errors.add(:config, "pattern must be #{SafePattern::MAX_LENGTH} characters or less")
|
|
166
|
+
elsif !SafePattern.valid?(pattern)
|
|
167
|
+
errors.add(:config, "pattern is not a valid regular expression")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_choices_config
|
|
172
|
+
choices = config&.dig("choices")
|
|
173
|
+
return if choices.blank?
|
|
174
|
+
return errors.add(:config, "choices are only supported on select fields") unless enumeration?
|
|
175
|
+
|
|
176
|
+
unless choices.is_a?(Array) && choices.all? { |choice| choice.is_a?(::String) && choice.present? && choice.length <= 255 }
|
|
177
|
+
return errors.add(:config, "choices must be a list of short text values")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
errors.add(:config, "choices must be unique") if choices.uniq.length != choices.length
|
|
181
|
+
errors.add(:config, "allows at most 100 choices") if choices.length > 100
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def belongs_to_one_owner
|
|
185
|
+
has_content_type = content_type_id.present? || content_type.present?
|
|
186
|
+
has_component = component_id.present? || component.present?
|
|
187
|
+
|
|
188
|
+
if has_content_type && has_component
|
|
189
|
+
errors.add(:base, "Field cannot belong to both content_type and component")
|
|
190
|
+
elsif !has_content_type && !has_component
|
|
191
|
+
errors.add(:base, "Field must belong to either content_type or component")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
69
195
|
def set_default_position
|
|
70
|
-
return if position.present?
|
|
71
|
-
|
|
72
|
-
max_position =
|
|
196
|
+
return if position.present? && position > 0
|
|
197
|
+
|
|
198
|
+
max_position = if content_type_id.present?
|
|
199
|
+
self.class.with_discarded.where(content_type_id: content_type_id).maximum(:position) || 0
|
|
200
|
+
elsif component_id.present?
|
|
201
|
+
self.class.with_discarded.where(component_id: component_id).maximum(:position) || 0
|
|
202
|
+
else
|
|
203
|
+
0
|
|
204
|
+
end
|
|
205
|
+
|
|
73
206
|
self.position = max_position + 1
|
|
74
207
|
end
|
|
75
208
|
|
|
76
|
-
def
|
|
77
|
-
if
|
|
78
|
-
|
|
209
|
+
def set_default_row
|
|
210
|
+
# Always set a new row for new fields (don't skip if row is 0)
|
|
211
|
+
return if persisted? && row.present?
|
|
212
|
+
|
|
213
|
+
max_row = if content_type_id.present?
|
|
214
|
+
self.class.with_discarded.where(content_type_id: content_type_id).maximum(:row) || -1
|
|
215
|
+
elsif component_id.present?
|
|
216
|
+
self.class.with_discarded.where(component_id: component_id).maximum(:row) || -1
|
|
217
|
+
else
|
|
218
|
+
-1
|
|
79
219
|
end
|
|
220
|
+
|
|
221
|
+
self.row = max_row + 1
|
|
80
222
|
end
|
|
81
223
|
end
|
|
82
|
-
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class MediaAsset < ApplicationRecord
|
|
3
|
+
include OrganizationScoped
|
|
4
|
+
|
|
5
|
+
has_prefix_id :media
|
|
6
|
+
|
|
7
|
+
has_one_attached :file
|
|
8
|
+
|
|
9
|
+
enum :kind, { image: 0, video: 1, file: 2 }
|
|
10
|
+
|
|
11
|
+
validates :file, presence: true
|
|
12
|
+
validate :file_size_within_limit
|
|
13
|
+
validate :file_type_allowed
|
|
14
|
+
|
|
15
|
+
before_save :cache_file_metadata
|
|
16
|
+
|
|
17
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
18
|
+
scope :search, ->(query) {
|
|
19
|
+
pattern = "%#{sanitize_sql_like(query.downcase)}%"
|
|
20
|
+
where("LOWER(filename) LIKE :q OR LOWER(title) LIKE :q OR LOWER(alt) LIKE :q", q: pattern)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
THUMBNAIL_SIZE = [ 480, 480 ].freeze
|
|
24
|
+
|
|
25
|
+
def url
|
|
26
|
+
return nil unless file.attached?
|
|
27
|
+
|
|
28
|
+
helpers = Rails.application.routes.url_helpers
|
|
29
|
+
if RivetCms.media_host.present?
|
|
30
|
+
helpers.rails_blob_url(file, host: RivetCms.media_host)
|
|
31
|
+
else
|
|
32
|
+
helpers.rails_blob_path(file, only_path: true)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resized representation for grid/picker cells: a variant for images, a
|
|
37
|
+
# preview (first page/frame) for PDFs and videos when the host has poppler
|
|
38
|
+
# or ffmpeg installed. Nil when neither applies so callers fall back to a
|
|
39
|
+
# glyph. Processing happens lazily on first request.
|
|
40
|
+
def thumbnail_url
|
|
41
|
+
return nil unless file.attached?
|
|
42
|
+
|
|
43
|
+
representation =
|
|
44
|
+
if image? && file.blob.variable?
|
|
45
|
+
file.variant(resize_to_limit: THUMBNAIL_SIZE)
|
|
46
|
+
elsif file.blob.previewable?
|
|
47
|
+
file.preview(resize_to_limit: THUMBNAIL_SIZE)
|
|
48
|
+
end
|
|
49
|
+
return nil unless representation
|
|
50
|
+
|
|
51
|
+
helpers = Rails.application.routes.url_helpers
|
|
52
|
+
if RivetCms.media_host.present?
|
|
53
|
+
helpers.rails_representation_url(representation, host: RivetCms.media_host)
|
|
54
|
+
else
|
|
55
|
+
helpers.rails_representation_path(representation, only_path: true)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Rich text embeds media by URL (which contains the blob signed id),
|
|
60
|
+
# so field references alone don't tell us an asset is in use.
|
|
61
|
+
def embedded_in_content?
|
|
62
|
+
return false unless file.attached?
|
|
63
|
+
|
|
64
|
+
pattern = file.blob.signed_id.gsub(/[!%_]/) { |c| "!#{c}" }
|
|
65
|
+
ContentValue.where("text_value LIKE ? ESCAPE '!'", "%#{pattern}%").exists?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.kind_for(content_type)
|
|
69
|
+
case content_type.to_s
|
|
70
|
+
when /\Aimage\// then :image
|
|
71
|
+
when /\Avideo\// then :video
|
|
72
|
+
else :file
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def file_size_within_limit
|
|
79
|
+
limit = RivetCms.max_upload_size
|
|
80
|
+
return unless limit && file.attached?
|
|
81
|
+
return if file.byte_size <= limit
|
|
82
|
+
|
|
83
|
+
errors.add(:file, "is too large (max #{ActiveSupport::NumberHelper.number_to_human_size(limit)})")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def file_type_allowed
|
|
87
|
+
allowed = RivetCms.allowed_media_types
|
|
88
|
+
return unless allowed && file.attached?
|
|
89
|
+
|
|
90
|
+
blob = file.blob
|
|
91
|
+
blob.identify_without_saving unless blob.identified?
|
|
92
|
+
return if allowed.include?(blob.content_type)
|
|
93
|
+
|
|
94
|
+
errors.add(:file, "type #{blob.content_type} is not allowed")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def cache_file_metadata
|
|
98
|
+
return unless file.attached?
|
|
99
|
+
|
|
100
|
+
blob = file.blob
|
|
101
|
+
blob.identify_without_saving unless blob.identified?
|
|
102
|
+
|
|
103
|
+
self.filename = blob.filename.to_s
|
|
104
|
+
self.content_type = blob.content_type
|
|
105
|
+
self.byte_size = blob.byte_size
|
|
106
|
+
self.kind = self.class.kind_for(blob.content_type)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class Organization < ApplicationRecord
|
|
3
|
+
# Organization has no prefix id, so prefixed_ids does not patch its
|
|
4
|
+
# has_many finders; extend explicitly so find accepts prefixed ids.
|
|
5
|
+
PREFIXED_FINDERS = PrefixedIds::Finder::ClassMethods
|
|
6
|
+
|
|
7
|
+
# content_types is soft-delete scoped, so dependent: :destroy would skip
|
|
8
|
+
# discarded rows and leave them holding the FK; destroy those explicitly.
|
|
9
|
+
# Documents are destroyed first because ContentType restricts on them.
|
|
10
|
+
has_many :content_types, dependent: :destroy, extend: PREFIXED_FINDERS
|
|
11
|
+
before_destroy :destroy_all_content, prepend: true
|
|
12
|
+
has_many :components, dependent: :destroy, extend: PREFIXED_FINDERS
|
|
13
|
+
has_many :categories, dependent: :destroy, extend: PREFIXED_FINDERS
|
|
14
|
+
has_many :media_assets, dependent: :destroy, extend: PREFIXED_FINDERS
|
|
15
|
+
has_many :api_tokens, dependent: :destroy, extend: PREFIXED_FINDERS
|
|
16
|
+
# Built-in auth users hold the org FK; without this, destroy raises on the
|
|
17
|
+
# restrictive foreign key
|
|
18
|
+
has_many :users, dependent: :delete_all, extend: PREFIXED_FINDERS
|
|
19
|
+
|
|
20
|
+
validates :name, presence: true
|
|
21
|
+
validates :domain, presence: true, uniqueness: true
|
|
22
|
+
validates :subdomain, uniqueness: true, allow_blank: true
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Destroying an organization is a deliberate, total removal, so it clears
|
|
27
|
+
# the content that ContentType otherwise refuses to cascade, including
|
|
28
|
+
# rows behind the soft-delete scope.
|
|
29
|
+
def destroy_all_content
|
|
30
|
+
types = ContentType.with_discarded.where(organization_id: id)
|
|
31
|
+
# organization_id, not the type join: that column is what holds the FK
|
|
32
|
+
# Either column can hold the link if data has drifted, so cover both
|
|
33
|
+
documents = Document.with_discarded.where(organization_id: id)
|
|
34
|
+
.or(Document.with_discarded.where(content_type_id: types.select(:id)))
|
|
35
|
+
# Relations point at documents by foreign key, so incoming links have to
|
|
36
|
+
# go before their targets or the cascade order decides whether we crash.
|
|
37
|
+
Relation.where(target_document_id: documents.select(:id)).delete_all
|
|
38
|
+
documents.find_each(&:destroy!)
|
|
39
|
+
types.find_each(&:destroy!)
|
|
40
|
+
rescue ActiveRecord::RecordNotDestroyed => error
|
|
41
|
+
# Otherwise destroy returns false with an empty errors hash
|
|
42
|
+
errors.add(:base, "could not remove content: #{error.message}")
|
|
43
|
+
throw :abort
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class Relation < ApplicationRecord
|
|
3
|
+
include RevisionOwned
|
|
4
|
+
|
|
5
|
+
has_prefix_id :rel
|
|
6
|
+
|
|
7
|
+
belongs_to :field
|
|
8
|
+
belongs_to :target_document, class_name: "RivetCms::Document"
|
|
9
|
+
|
|
10
|
+
validate :target_in_same_organization
|
|
11
|
+
|
|
12
|
+
scope :ordered, -> { order(:position) }
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def target_in_same_organization
|
|
17
|
+
return if target_document.nil? || owner_organization_id.nil?
|
|
18
|
+
return if target_document.organization_id == owner_organization_id
|
|
19
|
+
|
|
20
|
+
errors.add(:target_document, "must belong to the same organization")
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Admin account for built-in authentication mode. Dormant when the host
|
|
3
|
+
# wires its own auth: nothing reads this table unless RivetCms.builtin_auth?
|
|
4
|
+
#
|
|
5
|
+
# CE has no roles: every authenticated user can do everything, so this model
|
|
6
|
+
# carries only identity and sign-in state. Role-based and per-record
|
|
7
|
+
# permissions are a Pro feature that installs its own policy through the
|
|
8
|
+
# can? seam and its own tables.
|
|
9
|
+
#
|
|
10
|
+
# Accounts are always created by an existing user (or the first-run setup
|
|
11
|
+
# screen); there is no self-registration. A user created without a password
|
|
12
|
+
# is pending: they finish through a signed set-password link and cannot sign
|
|
13
|
+
# in before that. Deactivation, never deletion: audit events and content
|
|
14
|
+
# attribution reference these rows.
|
|
15
|
+
class User < ApplicationRecord
|
|
16
|
+
include OrganizationScoped
|
|
17
|
+
|
|
18
|
+
has_prefix_id :usr
|
|
19
|
+
|
|
20
|
+
has_secure_password validations: false
|
|
21
|
+
|
|
22
|
+
before_validation { self.email = email.to_s.strip.downcase }
|
|
23
|
+
|
|
24
|
+
validates :name, presence: true
|
|
25
|
+
validates :email, presence: true,
|
|
26
|
+
format: { with: URI::MailTo::EMAIL_REGEXP },
|
|
27
|
+
uniqueness: { scope: :organization_id }
|
|
28
|
+
validates :password, length: { minimum: 8 }, allow_nil: true
|
|
29
|
+
validate :password_within_bcrypt_limit
|
|
30
|
+
|
|
31
|
+
scope :active, -> { where(active: true) }
|
|
32
|
+
|
|
33
|
+
# Invalidated automatically when the password changes (salt rotates)
|
|
34
|
+
generates_token_for :password_setup, expires_in: 3.days do
|
|
35
|
+
password_salt
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pending?
|
|
39
|
+
password_digest.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def status
|
|
43
|
+
return "inactive" unless active?
|
|
44
|
+
return "pending" if pending?
|
|
45
|
+
|
|
46
|
+
"active"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def can_sign_in?
|
|
50
|
+
active? && !pending?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# BCrypt silently truncates at 72 bytes, which would make two long
|
|
56
|
+
# passwords sharing a prefix interchangeable; refuse instead
|
|
57
|
+
def password_within_bcrypt_limit
|
|
58
|
+
errors.add(:password, "is too long (maximum is 72 bytes)") if password && password.bytesize > 72
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Bulk-loads everything RevisionSerializer needs for a set of revisions in a
|
|
3
|
+
# constant number of queries, regardless of page size. Components cannot nest,
|
|
4
|
+
# so the owner tree is bounded: revision -> component instances -> values/relations.
|
|
5
|
+
# When populate_fields are given, target documents' revisions are loaded and
|
|
6
|
+
# preloaded the same way (one level deep).
|
|
7
|
+
class RevisionPreloader
|
|
8
|
+
def initialize(revisions, populate_fields: [], preview: false)
|
|
9
|
+
@revisions = Array(revisions).compact
|
|
10
|
+
@populate_field_ids = populate_fields.map(&:id).to_set
|
|
11
|
+
@preview = preview
|
|
12
|
+
@values = {}
|
|
13
|
+
@relations = {}
|
|
14
|
+
@instances = {}
|
|
15
|
+
@fields_by_content_type = {}
|
|
16
|
+
@fields_by_component = {}
|
|
17
|
+
@target_revision_by_document_id = {}
|
|
18
|
+
load_all
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def document_for(revision)
|
|
22
|
+
revision.document
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fields_for_content_type(content_type_id)
|
|
26
|
+
@fields_by_content_type[content_type_id] || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fields_for_component(component_id)
|
|
30
|
+
@fields_by_component[component_id] || []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def value(owner, field)
|
|
34
|
+
@values.dig(owner_key(owner), field.id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def relations(owner, field)
|
|
38
|
+
@relations.dig(owner_key(owner), field.id) || []
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def component_instances(owner, field)
|
|
42
|
+
@instances.dig(owner_key(owner), field.id) || []
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def target_revision(document_id)
|
|
46
|
+
@target_revision_by_document_id[document_id]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def owner_key(owner)
|
|
52
|
+
[ owner.class.name, owner.id ]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def load_all
|
|
56
|
+
return if @revisions.empty?
|
|
57
|
+
|
|
58
|
+
ActiveRecord::Associations::Preloader.new(records: @revisions, associations: { document: :content_type }).call
|
|
59
|
+
load_fields_for_content_types(@revisions.map { |revision| revision.document.content_type_id })
|
|
60
|
+
load_owned(@revisions)
|
|
61
|
+
load_populate_targets
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def load_fields_for_content_types(content_type_ids)
|
|
65
|
+
missing = content_type_ids.uniq - @fields_by_content_type.keys
|
|
66
|
+
return if missing.empty?
|
|
67
|
+
|
|
68
|
+
Field.where(content_type_id: missing).ordered
|
|
69
|
+
.group_by(&:content_type_id)
|
|
70
|
+
.each { |ct_id, fields| @fields_by_content_type[ct_id] = fields }
|
|
71
|
+
missing.each { |ct_id| @fields_by_content_type[ct_id] ||= [] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def load_owned(owners)
|
|
75
|
+
load_values_and_relations(owners)
|
|
76
|
+
|
|
77
|
+
instances = ComponentInstance
|
|
78
|
+
.where(owner_type: owners.first.class.name, owner_id: owners.map(&:id))
|
|
79
|
+
.order(:position).includes(:component).to_a
|
|
80
|
+
group_owned(instances, @instances)
|
|
81
|
+
|
|
82
|
+
component_ids = instances.map(&:component_id).uniq
|
|
83
|
+
if component_ids.any?
|
|
84
|
+
Field.where(component_id: component_ids).ordered
|
|
85
|
+
.group_by(&:component_id)
|
|
86
|
+
.each { |comp_id, fields| @fields_by_component[comp_id] = fields }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
load_values_and_relations(instances) if instances.any?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def load_values_and_relations(owners)
|
|
93
|
+
owner_type = owners.first.class.name
|
|
94
|
+
owner_ids = owners.map(&:id)
|
|
95
|
+
|
|
96
|
+
values = ContentValue
|
|
97
|
+
.where(owner_type: owner_type, owner_id: owner_ids)
|
|
98
|
+
.includes(:field, media_asset: { file_attachment: :blob }).to_a
|
|
99
|
+
values.group_by { |value| [ value.owner_type, value.owner_id ] }.each do |key, group|
|
|
100
|
+
@values[key] = group.index_by(&:field_id)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
relations = Relation
|
|
104
|
+
.where(owner_type: owner_type, owner_id: owner_ids)
|
|
105
|
+
.order(:position).includes(target_document: :content_type).to_a
|
|
106
|
+
group_owned(relations, @relations)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def group_owned(records, store)
|
|
110
|
+
records.group_by { |record| [ record.owner_type, record.owner_id ] }.each do |key, group|
|
|
111
|
+
store[key] = group.group_by(&:field_id)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def load_populate_targets
|
|
116
|
+
return if @populate_field_ids.empty?
|
|
117
|
+
|
|
118
|
+
targets = @revisions.flat_map { |revision|
|
|
119
|
+
(@relations[owner_key(revision)] || {})
|
|
120
|
+
.select { |field_id, _| @populate_field_ids.include?(field_id) }
|
|
121
|
+
.values.flatten.map(&:target_document)
|
|
122
|
+
}.compact.uniq # a target can be trashed, leaving the association nil
|
|
123
|
+
|
|
124
|
+
revision_id_by_document = targets.each_with_object({}) do |document, map|
|
|
125
|
+
revision_id = @preview ? (document.draft_revision_id || document.published_revision_id) : document.published_revision_id
|
|
126
|
+
map[document.id] = revision_id if revision_id
|
|
127
|
+
end
|
|
128
|
+
return if revision_id_by_document.empty?
|
|
129
|
+
|
|
130
|
+
target_revisions = DocumentRevision.where(id: revision_id_by_document.values).index_by(&:id)
|
|
131
|
+
documents_by_id = targets.index_by(&:id)
|
|
132
|
+
|
|
133
|
+
revision_id_by_document.each do |document_id, revision_id|
|
|
134
|
+
revision = target_revisions[revision_id]
|
|
135
|
+
next unless revision
|
|
136
|
+
|
|
137
|
+
revision.association(:document).target = documents_by_id[document_id]
|
|
138
|
+
@target_revision_by_document_id[document_id] = revision
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
loaded = @target_revision_by_document_id.values
|
|
142
|
+
load_fields_for_content_types(loaded.map { |revision| revision.document.content_type_id })
|
|
143
|
+
load_owned(loaded)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|