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,245 +1,197 @@
|
|
|
1
1
|
module RivetCms
|
|
2
|
+
# Builds an OpenAPI 3 document describing the delivery API for an organization's
|
|
3
|
+
# content types. Field types map to JSON Schema; references/components/media
|
|
4
|
+
# expand into nested schemas.
|
|
2
5
|
class OpenApiGenerator
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
# content_types/components narrow the document to a permitted subset;
|
|
7
|
+
# nil means all. Excluded types must not be referenced either, or the
|
|
8
|
+
# document would carry $refs to schemas it does not contain.
|
|
9
|
+
def initialize(organization, base_url: "/api", content_types: nil, components: nil)
|
|
10
|
+
@organization = organization
|
|
11
|
+
@base_url = base_url
|
|
12
|
+
@content_types = content_types
|
|
13
|
+
@components = components
|
|
14
|
+
end
|
|
9
15
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def generate_schema
|
|
13
|
-
schema = {
|
|
14
|
-
"openapi" => "3.0.1",
|
|
15
|
-
"info" => {
|
|
16
|
-
"title" => "RivetCMS API",
|
|
17
|
-
"version" => "1.0.0",
|
|
18
|
-
"description" => "API documentation for content types"
|
|
19
|
-
},
|
|
20
|
-
"paths" => generate_paths,
|
|
21
|
-
"components" => {
|
|
22
|
-
"schemas" => generate_schemas,
|
|
23
|
-
"securitySchemes" => {
|
|
24
|
-
"bearerAuth" => {
|
|
25
|
-
"type" => "http",
|
|
26
|
-
"scheme" => "bearer",
|
|
27
|
-
"bearerFormat" => "JWT"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
"security" => [
|
|
32
|
-
{ "bearerAuth" => [] }
|
|
33
|
-
]
|
|
34
|
-
}
|
|
16
|
+
def as_json(*)
|
|
17
|
+
content_types = (@content_types || @organization.content_types.order(:name)).to_a
|
|
35
18
|
|
|
36
|
-
|
|
37
|
-
|
|
19
|
+
{
|
|
20
|
+
openapi: "3.0.3",
|
|
21
|
+
info: { title: "RivetCms Delivery API", version: "1.0.0" },
|
|
22
|
+
servers: [ { url: @base_url } ],
|
|
23
|
+
components: {
|
|
24
|
+
securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
|
|
25
|
+
schemas: content_types.each_with_object({}) { |ct, acc| acc[schema_name(ct)] = document_schema(ct) }
|
|
26
|
+
},
|
|
27
|
+
security: [ { bearerAuth: [] } ],
|
|
28
|
+
paths: content_types.each_with_object({}) { |ct, acc| add_paths(acc, ct) }
|
|
29
|
+
}
|
|
30
|
+
end
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
case obj
|
|
41
|
-
when Hash
|
|
42
|
-
obj.each_with_object({}) do |(key, value), result|
|
|
43
|
-
result[key.to_s] = deep_stringify_keys(value)
|
|
44
|
-
end
|
|
45
|
-
when Array
|
|
46
|
-
obj.map { |item| deep_stringify_keys(item) }
|
|
47
|
-
else
|
|
48
|
-
obj
|
|
49
|
-
end
|
|
50
|
-
end
|
|
32
|
+
private
|
|
51
33
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
ContentType.all.each do |content_type|
|
|
56
|
-
if content_type.is_single
|
|
57
|
-
paths.merge!(generate_single_type_paths(content_type))
|
|
58
|
-
else
|
|
59
|
-
paths.merge!(generate_collection_type_paths(content_type))
|
|
60
|
-
end
|
|
61
|
-
end
|
|
34
|
+
def add_paths(paths, content_type)
|
|
35
|
+
item = "#{@base_url}/#{content_type.slug}"
|
|
36
|
+
ref = { "$ref" => "#/components/schemas/#{schema_name(content_type)}" }
|
|
62
37
|
|
|
63
|
-
|
|
64
|
-
|
|
38
|
+
paths["#{item}"] = {
|
|
39
|
+
get: {
|
|
40
|
+
summary: "List #{content_type.name}",
|
|
41
|
+
parameters: list_params,
|
|
42
|
+
responses: { "200" => { description: "A page of documents", content: json_content(list_schema(ref)) } }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
paths["#{item}/{slug}"] = {
|
|
46
|
+
get: {
|
|
47
|
+
summary: "Fetch one #{content_type.name} by slug",
|
|
48
|
+
parameters: [
|
|
49
|
+
{ name: "slug", in: "path", required: true, schema: { type: "string" } },
|
|
50
|
+
{ name: "preview", in: "query", required: false, schema: { type: "boolean" },
|
|
51
|
+
description: "Requires a preview-scoped token; returns the draft revision." },
|
|
52
|
+
populate_param,
|
|
53
|
+
fields_param
|
|
54
|
+
],
|
|
55
|
+
responses: { "200" => { description: "A document", content: json_content(ref) }, "404" => { description: "Not found" } }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def list_params
|
|
61
|
+
[
|
|
62
|
+
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
|
63
|
+
{ name: "per_page", in: "query", schema: { type: "integer", default: 25, maximum: 100 } },
|
|
64
|
+
{ name: "sort", in: "query", schema: { type: "string" },
|
|
65
|
+
description: "Field/column, prefix - for descending (e.g. -published_at)." },
|
|
66
|
+
populate_param,
|
|
67
|
+
fields_param
|
|
68
|
+
]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def populate_param
|
|
72
|
+
{ name: "populate", in: "query", schema: { type: "string" },
|
|
73
|
+
description: "Comma-separated reference field keys to expand one level into full documents, or * for all." }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fields_param
|
|
77
|
+
{ name: "fields", in: "query", schema: { type: "string" },
|
|
78
|
+
description: "Comma-separated field keys to include in data; also limits which populated fields appear." }
|
|
79
|
+
end
|
|
65
80
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"content" => {
|
|
76
|
-
"application/json" => {
|
|
77
|
-
"schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
81
|
+
def list_schema(item_ref)
|
|
82
|
+
{
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
data: { type: "array", items: item_ref },
|
|
86
|
+
meta: { type: "object", properties: {
|
|
87
|
+
page: { type: "integer" }, per_page: { type: "integer" },
|
|
88
|
+
total: { type: "integer" }, total_pages: { type: "integer" }
|
|
89
|
+
} }
|
|
84
90
|
}
|
|
85
|
-
|
|
91
|
+
}
|
|
92
|
+
end
|
|
86
93
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"in" => "query",
|
|
97
|
-
"description" => "Page number for pagination",
|
|
98
|
-
"schema" => { "type" => "integer", "default" => 1 },
|
|
99
|
-
"required" => false
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
"name" => "per_page",
|
|
103
|
-
"in" => "query",
|
|
104
|
-
"description" => "Number of items per page",
|
|
105
|
-
"schema" => { "type" => "integer", "default" => 25 },
|
|
106
|
-
"required" => false
|
|
107
|
-
}
|
|
108
|
-
],
|
|
109
|
-
"responses" => {
|
|
110
|
-
"200" => {
|
|
111
|
-
"description" => "Returns list of #{content_type.name.pluralize}",
|
|
112
|
-
"content" => {
|
|
113
|
-
"application/json" => {
|
|
114
|
-
"schema" => {
|
|
115
|
-
"type" => "object",
|
|
116
|
-
"properties" => {
|
|
117
|
-
"data" => {
|
|
118
|
-
"type" => "array",
|
|
119
|
-
"items" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
|
120
|
-
},
|
|
121
|
-
"meta" => {
|
|
122
|
-
"type" => "object",
|
|
123
|
-
"properties" => {
|
|
124
|
-
"current_page" => { "type" => "integer" },
|
|
125
|
-
"total_pages" => { "type" => "integer" },
|
|
126
|
-
"total_count" => { "type" => "integer" }
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
"/api/v1/#{content_type.slug}/{id}" => {
|
|
138
|
-
"get" => {
|
|
139
|
-
"tags" => [content_type.name],
|
|
140
|
-
"summary" => "Get a specific #{content_type.name}",
|
|
141
|
-
"parameters" => [
|
|
142
|
-
{
|
|
143
|
-
"name" => "id",
|
|
144
|
-
"in" => "path",
|
|
145
|
-
"required" => true,
|
|
146
|
-
"schema" => { "type" => "string" },
|
|
147
|
-
"description" => "The ID of the #{content_type.name} to retrieve"
|
|
148
|
-
}
|
|
149
|
-
],
|
|
150
|
-
"responses" => {
|
|
151
|
-
"200" => {
|
|
152
|
-
"description" => "Returns the #{content_type.name}",
|
|
153
|
-
"content" => {
|
|
154
|
-
"application/json" => {
|
|
155
|
-
"schema" => { "$ref" => "#/components/schemas/#{content_type.slug}" }
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
},
|
|
159
|
-
"404" => {
|
|
160
|
-
"description" => "#{content_type.name} not found"
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
}
|
|
94
|
+
def document_schema(content_type)
|
|
95
|
+
{
|
|
96
|
+
type: "object",
|
|
97
|
+
properties: {
|
|
98
|
+
id: { type: "string" },
|
|
99
|
+
slug: { type: "string" },
|
|
100
|
+
content_type: { type: "string" },
|
|
101
|
+
state: { type: "string", enum: %w[draft published archived] },
|
|
102
|
+
data: { type: "object", properties: content_type.fields.kept.ordered.each_with_object({}) { |f, acc| acc[f.key] = field_schema(f) } }
|
|
165
103
|
}
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def field_schema(field)
|
|
108
|
+
case field.field_type
|
|
109
|
+
when "integer" then { type: "integer" }
|
|
110
|
+
when "decimal" then { type: "number" }
|
|
111
|
+
when "boolean" then { type: "boolean" }
|
|
112
|
+
when "date" then { type: "string", format: "date" }
|
|
113
|
+
when "datetime" then { type: "string", format: "date-time" }
|
|
114
|
+
when "enumeration" then enumeration_schema(field)
|
|
115
|
+
when "reference" then reference_schema(field)
|
|
116
|
+
when "component" then component_schema(field)
|
|
117
|
+
when "image", "video", "file"
|
|
118
|
+
{ type: "object", nullable: true, properties: {
|
|
119
|
+
id: { type: "integer" }, filename: { type: "string" }, content_type: { type: "string" },
|
|
120
|
+
byte_size: { type: "integer" }, title: { type: "string", nullable: true },
|
|
121
|
+
alt: { type: "string", nullable: true }, description: { type: "string", nullable: true },
|
|
122
|
+
url: { type: "string" }
|
|
123
|
+
} }
|
|
124
|
+
when "string", "text" then string_schema(field)
|
|
125
|
+
else { type: "string" }
|
|
166
126
|
end
|
|
127
|
+
end
|
|
167
128
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
schemas[content_type.slug] = generate_content_type_schema(content_type)
|
|
173
|
-
end
|
|
129
|
+
def enumeration_schema(field)
|
|
130
|
+
choices = Array(field.config&.dig("choices"))
|
|
131
|
+
choices.any? ? { type: "string", enum: choices } : { type: "string" }
|
|
132
|
+
end
|
|
174
133
|
|
|
175
|
-
|
|
176
|
-
|
|
134
|
+
def string_schema(field)
|
|
135
|
+
pattern = field.config&.dig("pattern")
|
|
136
|
+
pattern.present? ? { type: "string", pattern: pattern } : { type: "string" }
|
|
137
|
+
end
|
|
177
138
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
139
|
+
# Shallow {id, slug} by default; when the field's configured target resolves,
|
|
140
|
+
# a oneOf with the target schema documents the populated shape.
|
|
141
|
+
# A target outside the generated subset stays shallow: emitting its $ref
|
|
142
|
+
# would point at a schema this document does not contain.
|
|
143
|
+
def reference_schema(field)
|
|
144
|
+
shallow = { type: "object", properties: { id: { type: "string" }, slug: { type: "string" } } }
|
|
145
|
+
target = @organization.content_types.find_by(id: field.config&.dig("content_type_id"))
|
|
146
|
+
inner = target && included_type_ids.include?(target.id) ? { oneOf: [ shallow, { "$ref" => "#/components/schemas/#{schema_name(target)}" } ] } : shallow
|
|
147
|
+
collapse(field, inner)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Same rule: a component outside the subset renders as an opaque object
|
|
151
|
+
# rather than leaking its field structure.
|
|
152
|
+
def component_schema(field)
|
|
153
|
+
component = Component.find_by(id: field.config&.dig("component_id"), organization_id: field.organization_id)
|
|
154
|
+
inner = if component && included_component_ids.include?(component.id)
|
|
155
|
+
{ type: "object", properties: component.fields.kept.ordered.each_with_object({}) { |f, acc| acc[f.key] = field_schema(f) } }
|
|
156
|
+
else
|
|
157
|
+
{ type: "object" }
|
|
183
158
|
end
|
|
159
|
+
collapse(field, inner)
|
|
160
|
+
end
|
|
184
161
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
"title" => { "type" => "string" },
|
|
189
|
-
"slug" => { "type" => "string" },
|
|
190
|
-
"status" => {
|
|
191
|
-
"type" => "string",
|
|
192
|
-
"enum" => ["draft", "published", "archived"]
|
|
193
|
-
},
|
|
194
|
-
"created_at" => { "type" => "string", "format" => "date-time" },
|
|
195
|
-
"updated_at" => { "type" => "string", "format" => "date-time" }
|
|
196
|
-
}
|
|
162
|
+
def included_type_ids
|
|
163
|
+
@included_type_ids ||= (@content_types || @organization.content_types).map(&:id).to_set
|
|
164
|
+
end
|
|
197
165
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
166
|
+
def included_component_ids
|
|
167
|
+
@included_component_ids ||= (@components || @organization.components).map(&:id).to_set
|
|
168
|
+
end
|
|
201
169
|
|
|
202
|
-
|
|
203
|
-
|
|
170
|
+
def collapse(field, schema)
|
|
171
|
+
field.max_items == 1 ? schema : { type: "array", items: schema }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def json_content(schema)
|
|
175
|
+
{ "application/json" => { schema: schema } }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Distinct slugs can produce the same camelized name (top-10 and top10
|
|
179
|
+
# both yield Top10), so names are deduped once per generation.
|
|
180
|
+
def schema_name(content_type)
|
|
181
|
+
schema_names.fetch(content_type.id)
|
|
182
|
+
end
|
|
204
183
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
when 'media'
|
|
214
|
-
{
|
|
215
|
-
"type" => "object",
|
|
216
|
-
"properties" => {
|
|
217
|
-
"url" => { "type" => "string" },
|
|
218
|
-
"filename" => { "type" => "string" },
|
|
219
|
-
"content_type" => { "type" => "string" }
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
when 'relation'
|
|
223
|
-
{
|
|
224
|
-
"type" => "object",
|
|
225
|
-
"properties" => {
|
|
226
|
-
"id" => { "type" => "string" },
|
|
227
|
-
"title" => { "type" => "string" }
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
when 'component'
|
|
231
|
-
{
|
|
232
|
-
"type" => "object",
|
|
233
|
-
"properties" => {
|
|
234
|
-
"id" => { "type" => "string" },
|
|
235
|
-
"type" => { "type" => "string" },
|
|
236
|
-
"fields" => { "type" => "object" }
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
else
|
|
240
|
-
{ "type" => "string" }
|
|
184
|
+
def schema_names
|
|
185
|
+
@schema_names ||= @organization.content_types.order(:id).each_with_object({}) do |ct, map|
|
|
186
|
+
base = ct.slug.split("-").map(&:capitalize).join
|
|
187
|
+
name = base
|
|
188
|
+
suffix = 2
|
|
189
|
+
while map.value?(name)
|
|
190
|
+
name = "#{base}_#{suffix}"
|
|
191
|
+
suffix += 1
|
|
241
192
|
end
|
|
193
|
+
map[ct.id] = name
|
|
242
194
|
end
|
|
243
195
|
end
|
|
244
196
|
end
|
|
245
|
-
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Enforces retention for one document: keeps the newest N superseded
|
|
3
|
+
# published snapshots per locale and destroys the rest. Candidates exclude
|
|
4
|
+
# the document's live pointers, re-read from the database so a stale
|
|
5
|
+
# in-memory copy cannot target a revision another request just published,
|
|
6
|
+
# and pruning is skipped entirely unless a live published revision exists.
|
|
7
|
+
class RevisionPruner
|
|
8
|
+
def initialize(document, keep_ids: [])
|
|
9
|
+
@document = document
|
|
10
|
+
@keep_ids = Array(keep_ids).compact
|
|
11
|
+
@destroyed = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def prune!
|
|
15
|
+
@destroyed = 0 # defensive: report this run, not a previous one
|
|
16
|
+
retention = RivetCms.normalized_retention_for(@document)
|
|
17
|
+
return 0 if retention == :all
|
|
18
|
+
|
|
19
|
+
published_id, draft_id = live_pointers
|
|
20
|
+
return 0 if published_id.nil? # nothing is live: never guess what to keep
|
|
21
|
+
|
|
22
|
+
protected_ids = ([ published_id, draft_id ] + @keep_ids).compact
|
|
23
|
+
live_locale = DocumentRevision.where(id: published_id).pick(:locale)
|
|
24
|
+
|
|
25
|
+
# Retention applies per locale so one locale's publishes cannot destroy
|
|
26
|
+
# another's, and no locale's snapshots become unreachable. A locale with
|
|
27
|
+
# no live pointer keeps its newest snapshot as that locale's live one.
|
|
28
|
+
locales.each do |locale|
|
|
29
|
+
allowance = locale == live_locale ? retention : retention + 1
|
|
30
|
+
doomed = candidates(protected_ids, locale).order(created_at: :desc, id: :desc).offset(allowance)
|
|
31
|
+
doomed.each do |revision|
|
|
32
|
+
Hooks.run(:prune, revision)
|
|
33
|
+
# destroy returns false when a callback halts it; count what actually
|
|
34
|
+
# went, so the count and log cannot overstate the work.
|
|
35
|
+
if revision.destroy
|
|
36
|
+
@destroyed += 1
|
|
37
|
+
else
|
|
38
|
+
Rails.logger&.warn("[RivetCms] could not prune revision #{revision.id}: destroy was halted")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Rails.logger&.info("[RivetCms] pruned #{@destroyed} superseded revision(s) for document #{@document.id}") if @destroyed.positive?
|
|
44
|
+
@destroyed
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Read live pointers from the database, not the attribute cache
|
|
50
|
+
def live_pointers
|
|
51
|
+
Document.where(id: @document.id).pick(:published_revision_id, :draft_revision_id) || [ nil, nil ]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def locales
|
|
55
|
+
@document.revisions.where(state: DocumentRevision.states[:published]).distinct.pluck(:locale)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def candidates(protected_ids, locale)
|
|
59
|
+
@document.revisions
|
|
60
|
+
.where(state: DocumentRevision.states[:published], locale: locale)
|
|
61
|
+
.where.not(id: protected_ids)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Fans a lifecycle event out to the host-configured webhook endpoints.
|
|
3
|
+
# RivetCms.webhooks is an array of { url:, events: } hashes; events is
|
|
4
|
+
# optional and defaults to everything.
|
|
5
|
+
module Webhooks
|
|
6
|
+
EVENT_NAMES = { publish: "entry.published" }.freeze
|
|
7
|
+
|
|
8
|
+
def self.deliver(event, revision)
|
|
9
|
+
endpoints_for(event).each do |endpoint|
|
|
10
|
+
url = endpoint[:url] || endpoint["url"]
|
|
11
|
+
next if url.blank?
|
|
12
|
+
|
|
13
|
+
WebhookDeliveryJob.perform_later(url, payload(event, revision))
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.endpoints_for(event)
|
|
18
|
+
name = EVENT_NAMES.fetch(event, event.to_s)
|
|
19
|
+
Array(RivetCms.webhooks).select do |endpoint|
|
|
20
|
+
events = endpoint[:events] || endpoint["events"]
|
|
21
|
+
events.nil? || events.map(&:to_s).include?(name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.payload(event, revision)
|
|
26
|
+
document = revision.document
|
|
27
|
+
{
|
|
28
|
+
event: EVENT_NAMES.fetch(event),
|
|
29
|
+
document_id: document.prefix_id,
|
|
30
|
+
slug: document.slug,
|
|
31
|
+
# with_discarded: publishing into a removed type is reachable from host
|
|
32
|
+
# code, and the payload should still say which type it was.
|
|
33
|
+
content_type: ContentType.with_discarded.where(id: document.content_type_id).pick(:slug),
|
|
34
|
+
organization: document.organization.subdomain,
|
|
35
|
+
locale: revision.locale,
|
|
36
|
+
published_at: revision.published_at&.iso8601,
|
|
37
|
+
author: revision.author_name
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Fails fast at boot so a malformed entry cannot silently disable
|
|
42
|
+
# delivery (publish-path errors are swallowed by the hook seam).
|
|
43
|
+
def self.validate_config!
|
|
44
|
+
Array(RivetCms.webhooks).each do |endpoint|
|
|
45
|
+
raise ArgumentError, "RivetCms.webhooks entries must be hashes, got #{endpoint.class}" unless endpoint.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
url = endpoint[:url] || endpoint["url"]
|
|
48
|
+
uri = URI.parse(url.to_s) rescue nil
|
|
49
|
+
unless uri.is_a?(URI::HTTP) # covers HTTPS (subclass)
|
|
50
|
+
raise ArgumentError, "RivetCms.webhooks url must be http(s), got #{url.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
events = endpoint[:events] || endpoint["events"]
|
|
54
|
+
next if events.nil?
|
|
55
|
+
unless events.is_a?(Array) && (events.map(&:to_s) - EVENT_NAMES.values).empty?
|
|
56
|
+
raise ArgumentError, "RivetCms.webhooks events must be an array from #{EVENT_NAMES.values.inspect}, got #{events.inspect}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -1,49 +1,42 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html
|
|
3
|
-
<head>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
<html data-theme="lofi">
|
|
3
|
+
<head>
|
|
4
|
+
<title><%= content_for(:title) || "Rivet CMS" %></title>
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
7
|
+
<meta name="mobile-web-app-capable" content="yes">
|
|
8
|
+
<%= csrf_meta_tags %>
|
|
9
|
+
<%= csp_meta_tag %>
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
<link rel="icon" href="/icon.png" type="image/png">
|
|
12
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
|
13
|
+
<link rel="apple-touch-icon" href="/icon.png">
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
16
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
17
|
+
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400..800;1,400..800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
<script>
|
|
20
|
+
// Apply persisted theme before first paint to avoid a flash
|
|
21
|
+
try {
|
|
22
|
+
var stored = localStorage.getItem("rivetTheme");
|
|
23
|
+
if (stored ? stored === "dark" : matchMedia("(prefers-color-scheme: dark)").matches) {
|
|
24
|
+
document.documentElement.setAttribute("data-theme", "dark");
|
|
25
|
+
}
|
|
26
|
+
} catch (_) {}
|
|
27
|
+
</script>
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
<%
|
|
26
|
-
|
|
27
|
-
<% flash.each do |type, message| %>
|
|
28
|
-
<div class="<%= flash_class_for(type) %> p-4 rounded-md mb-4">
|
|
29
|
-
<div class="flex">
|
|
30
|
-
<div class="flex-shrink-0">
|
|
31
|
-
<%= flash_icon_for(type) %>
|
|
32
|
-
</div>
|
|
33
|
-
<div class="ml-3">
|
|
34
|
-
<p class="text-sm font-medium"><%= message %></p>
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
</div>
|
|
38
|
-
<% end %>
|
|
39
|
-
</div>
|
|
29
|
+
<%= stylesheet_link_tag "rivet_cms", media: "all" %>
|
|
30
|
+
<% RivetCms.admin_stylesheets.each do |name| %>
|
|
31
|
+
<%= admin_extension_tag :stylesheet, name %>
|
|
40
32
|
<% end %>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
33
|
+
<%# Extension bundles come after core so window.RivetCMS exists when they run %>
|
|
34
|
+
<%= javascript_include_tag "rivet_cms", type: "module", defer: true %>
|
|
35
|
+
<% RivetCms.admin_scripts.each do |name| %>
|
|
36
|
+
<%= admin_extension_tag :script, name %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</head>
|
|
39
|
+
<body>
|
|
40
|
+
<%= yield %>
|
|
41
|
+
</body>
|
|
49
42
|
</html>
|