rivet_cms 0.1.1.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 +175 -6
- 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 +65 -42
- 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 -39
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class SessionsController < AuthController
|
|
3
|
+
LOGIN_ATTEMPT_LIMIT = 10
|
|
4
|
+
LOGIN_ATTEMPT_WINDOW = 5.minutes
|
|
5
|
+
|
|
6
|
+
# Memoized on the class, computed on first use so BCrypt is referenced at
|
|
7
|
+
# request time (after bcrypt is required), never at load time
|
|
8
|
+
def self.dummy_digest
|
|
9
|
+
@dummy_digest ||= BCrypt::Password.create("rivet-cms-timing-equalizer").to_s
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
return redirect_to root_path if builtin_session_user
|
|
14
|
+
return redirect_to setup_path if users.none?
|
|
15
|
+
|
|
16
|
+
render inertia: "Auth/Login", props: { submit_path: login_path }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create
|
|
20
|
+
# Reserve an attempt atomically BEFORE authenticating, so a parallel
|
|
21
|
+
# burst cannot slip every request through BCrypt before any counter
|
|
22
|
+
# reaches the limit. The increment is the reservation.
|
|
23
|
+
unless reserve_attempt
|
|
24
|
+
return redirect_to login_path,
|
|
25
|
+
inertia: { errors: { base: [ "Too many attempts. Wait a few minutes and try again" ] } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
user = users.active.find_by(email: submitted_email)
|
|
29
|
+
if user&.can_sign_in? && user.authenticate(params[:password].to_s)
|
|
30
|
+
release_reservation_on_success
|
|
31
|
+
sign_in(user)
|
|
32
|
+
redirect_to root_path
|
|
33
|
+
else
|
|
34
|
+
equalize_timing unless user&.can_sign_in?
|
|
35
|
+
# Failure keeps its reservation; that is the point of the counters
|
|
36
|
+
redirect_to login_path, inertia: { errors: { base: [ "That email and password combination does not work" ] } }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy
|
|
41
|
+
reset_session
|
|
42
|
+
redirect_to login_path, status: :see_other
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A BCrypt comparison runs on every login attempt, so an attacker cannot
|
|
48
|
+
# tell known emails (expensive compare) from unknown ones (fast return)
|
|
49
|
+
# by timing; only its cost matters, never its content.
|
|
50
|
+
def equalize_timing
|
|
51
|
+
BCrypt::Password.new(self.class.dummy_digest).is_password?(params[:password].to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Two independent counters guard login: one per source IP, one per target
|
|
55
|
+
# account (scoped by organization, so the same email in another tenant is
|
|
56
|
+
# a separate counter). See reserve_attempt for the ordering that keeps the
|
|
57
|
+
# two from corrupting each other, and release_reservation_on_success for
|
|
58
|
+
# what a successful login does and does not clear.
|
|
59
|
+
#
|
|
60
|
+
# This needs a shared, incrementing cache (Redis, Memcached) to bind
|
|
61
|
+
# multiple workers; on a per-process MemoryStore it is per-worker, and on
|
|
62
|
+
# the NullStore it does nothing. It is a floor, not a replacement for
|
|
63
|
+
# rack-attack or a WAF at the edge.
|
|
64
|
+
def submitted_email
|
|
65
|
+
params[:email].to_s.strip.downcase
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ip_throttle_key
|
|
69
|
+
"rivet_cms:login_attempts:ip:#{request.remote_ip}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def account_throttle_key(email = submitted_email)
|
|
73
|
+
"rivet_cms:login_attempts:account:#{Current.organization&.id}:#{email}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reserve one IP attempt, then one account attempt, checking each before
|
|
77
|
+
# reserving the next. Returns false (throttled) without ever touching the
|
|
78
|
+
# account counter once the IP is over limit, so a blocked IP cannot lock
|
|
79
|
+
# arbitrary accounts. If the account is over limit, the IP reservation
|
|
80
|
+
# this request just made is released, so an already-locked account does
|
|
81
|
+
# not drain the shared IP budget.
|
|
82
|
+
def reserve_attempt
|
|
83
|
+
return false if over_limit?(bump(ip_throttle_key))
|
|
84
|
+
|
|
85
|
+
if over_limit?(bump(account_throttle_key))
|
|
86
|
+
unbump(ip_throttle_key)
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def over_limit?(count)
|
|
93
|
+
# nil (NullStore) means throttling is disabled, never over limit
|
|
94
|
+
count && count > LOGIN_ATTEMPT_LIMIT
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A success proves ownership of this account, so clear its counter; but it
|
|
98
|
+
# only releases this request's own IP reservation, never the whole IP
|
|
99
|
+
# history, so one account's success cannot erase IP-wide spray protection.
|
|
100
|
+
def release_reservation_on_success
|
|
101
|
+
unbump(ip_throttle_key)
|
|
102
|
+
Rails.cache.delete(account_throttle_key)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def bump(key)
|
|
106
|
+
# increment is atomic on stores that support it; it seeds the key on a
|
|
107
|
+
# real store, and returns nil on the NullStore (throttling disabled)
|
|
108
|
+
Rails.cache.increment(key, 1, expires_in: LOGIN_ATTEMPT_WINDOW)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Release a reservation only if it is still live. A bare decrement on an
|
|
112
|
+
# expired key recreates it as a non-expiring -1, a permanent counter that
|
|
113
|
+
# later increments accumulate on and can eventually lock the IP forever.
|
|
114
|
+
# The exist? check skips that; expires_in bounds the entry anyway if the
|
|
115
|
+
# key expires in the race between the check and the decrement.
|
|
116
|
+
def unbump(key)
|
|
117
|
+
return unless Rails.cache.exist?(key)
|
|
118
|
+
|
|
119
|
+
Rails.cache.decrement(key, 1, expires_in: LOGIN_ATTEMPT_WINDOW)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# First-run screen: with no users at all, the first visit creates the owner
|
|
3
|
+
# account. Gone the moment one user exists; never a registration form.
|
|
4
|
+
#
|
|
5
|
+
# Outside development/test a fresh deployment cannot be claimed by whoever
|
|
6
|
+
# finds it first: setup demands a code that is written to the server log
|
|
7
|
+
# (or configured via RivetCms.setup_code), so claiming requires server
|
|
8
|
+
# access. The Jenkins initial-password pattern.
|
|
9
|
+
class SetupController < AuthController
|
|
10
|
+
before_action :ensure_first_run
|
|
11
|
+
|
|
12
|
+
def new
|
|
13
|
+
log_setup_code
|
|
14
|
+
render inertia: "Auth/Setup", props: { submit_path: setup_path, requires_code: RivetCms.setup_code_required? }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
unless setup_code_valid?
|
|
19
|
+
return redirect_to setup_path,
|
|
20
|
+
inertia: { errors: { base: [ "That setup code is not right. It is printed in the server log." ] } }
|
|
21
|
+
end
|
|
22
|
+
if params[:password].blank?
|
|
23
|
+
# has_secure_password ignores an empty string entirely, which would
|
|
24
|
+
# otherwise create a passwordless owner and sign them in
|
|
25
|
+
return redirect_to setup_path, inertia: { errors: { password: [ "can't be blank" ] } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
user = users.new(user_params)
|
|
29
|
+
|
|
30
|
+
if user.save
|
|
31
|
+
sign_in(user)
|
|
32
|
+
redirect_to root_path, notice: "Welcome to RivetCMS"
|
|
33
|
+
else
|
|
34
|
+
redirect_to setup_path, inertia: { errors: user.errors }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def ensure_first_run
|
|
41
|
+
redirect_to login_path if users.any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def setup_code_valid?
|
|
45
|
+
return true unless RivetCms.setup_code_required?
|
|
46
|
+
|
|
47
|
+
ActiveSupport::SecurityUtils.secure_compare(params[:setup_code].to_s, RivetCms.setup_code)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def log_setup_code
|
|
51
|
+
return unless RivetCms.setup_code_required?
|
|
52
|
+
|
|
53
|
+
Rails.logger&.info("[RivetCms] First-run setup code: #{RivetCms.setup_code}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def user_params
|
|
57
|
+
params.permit(:name, :email, :password)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# One place to find everything trashed, so recovering does not depend on
|
|
3
|
+
# remembering which content type something belonged to. Entries of removed
|
|
4
|
+
# types are represented by their type below: restoring the type brings its
|
|
5
|
+
# entries back with it.
|
|
6
|
+
class TrashController < ApplicationController
|
|
7
|
+
include InertiaProps
|
|
8
|
+
|
|
9
|
+
before_action -> { authorize! :read, :content }
|
|
10
|
+
|
|
11
|
+
def show
|
|
12
|
+
scope = trashed_entries
|
|
13
|
+
if params[:type].present?
|
|
14
|
+
scope = scope.joins(:content_type).where(rivet_cms_content_types: { slug: params[:type] })
|
|
15
|
+
end
|
|
16
|
+
scope = scope.search(params[:q]) if params[:q].present?
|
|
17
|
+
page = scope.includes(:content_type, :draft_revision).order(deleted_at: :desc).page(params[:page]).per(25)
|
|
18
|
+
entries = permitted_documents(page)
|
|
19
|
+
titles = document_titles(entries)
|
|
20
|
+
|
|
21
|
+
render inertia: "Trash/Show", props: {
|
|
22
|
+
documents: entries.map { |document|
|
|
23
|
+
document_list_props(document, titles).merge(
|
|
24
|
+
content_type_name: document.content_type.name,
|
|
25
|
+
trashed_at: document.deleted_at.iso8601,
|
|
26
|
+
paths: {
|
|
27
|
+
restore: restore_content_type_document_path(document.content_type, document),
|
|
28
|
+
purge: purge_content_type_document_path(document.content_type, document)
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
},
|
|
32
|
+
q: params[:q].presence,
|
|
33
|
+
type: params[:type].presence,
|
|
34
|
+
# Filter options come from what is actually trashed, like media kinds
|
|
35
|
+
types: filter_types,
|
|
36
|
+
pagination: { page: page.current_page, total_pages: page.total_pages },
|
|
37
|
+
content_types: removed_content_types
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def trashed_entries
|
|
44
|
+
Document.with_discarded.discarded.in_visible_types.where(organization: Current.organization)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def filter_types
|
|
48
|
+
permitted(ContentType.where(id: trashed_entries.distinct.select(:content_type_id)).order(:name), :read, :content)
|
|
49
|
+
.map { |content_type| { slug: content_type.slug, name: content_type.name } }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Removed types are schema data; without schema read the section is empty
|
|
53
|
+
def removed_content_types
|
|
54
|
+
return [] unless can?(:read, :schema)
|
|
55
|
+
|
|
56
|
+
removed = permitted(ContentType.with_discarded.discarded.where(organization: Current.organization).order(:deleted_at), :read, :schema)
|
|
57
|
+
entry_counts = Document.with_discarded.where(content_type_id: removed.map(&:id)).group(:content_type_id).count
|
|
58
|
+
|
|
59
|
+
removed.map { |content_type|
|
|
60
|
+
content_type_props(content_type).merge(
|
|
61
|
+
removed_at: content_type.deleted_at.iso8601,
|
|
62
|
+
documents_count: entry_counts.fetch(content_type.id, 0),
|
|
63
|
+
paths: { restore: restore_content_type_path(content_type), purge: purge_content_type_path(content_type) }
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# User management for built-in authentication mode. Hidden and denied under
|
|
3
|
+
# host auth. CE has no roles, so this is add / edit / deactivate only; every
|
|
4
|
+
# user can do everything. Users are deactivated, never deleted, since audit
|
|
5
|
+
# events and content attribution reference them.
|
|
6
|
+
#
|
|
7
|
+
# The authorize! :users gates below pass for everyone under CE's allow-all
|
|
8
|
+
# policy; they are the seam through which Pro restricts user management to
|
|
9
|
+
# its own roles.
|
|
10
|
+
class UsersController < ApplicationController
|
|
11
|
+
before_action :ensure_builtin_mode
|
|
12
|
+
before_action -> { authorize! :read, :users }, only: [ :index ]
|
|
13
|
+
before_action -> { authorize! :write, :users }, except: [ :index ]
|
|
14
|
+
before_action :set_user, except: [ :index, :create ]
|
|
15
|
+
before_action -> { authorize! :write, :users, record: @user }, except: [ :index, :create ]
|
|
16
|
+
|
|
17
|
+
def index
|
|
18
|
+
render inertia: "Users/Index", props: {
|
|
19
|
+
users: users.order(:created_at).map { |user| user_props(user) },
|
|
20
|
+
invite_link: flash[:invite_link]
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
user = users.new(user_params)
|
|
26
|
+
|
|
27
|
+
if user.save
|
|
28
|
+
audit "user.created", user
|
|
29
|
+
flash[:invite_link] = invitation_url(user.generate_token_for(:password_setup))
|
|
30
|
+
redirect_to users_path, notice: "#{user.name} was added. Share the sign-in link below; it is shown only once."
|
|
31
|
+
else
|
|
32
|
+
redirect_to users_path, inertia: { errors: user.errors }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def update
|
|
37
|
+
if @user.update(user_params)
|
|
38
|
+
audit "user.updated", @user
|
|
39
|
+
redirect_to users_path, notice: "#{@user.name} was updated"
|
|
40
|
+
else
|
|
41
|
+
redirect_to users_path, inertia: { errors: @user.errors }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def deactivate
|
|
46
|
+
if @user == Current.user
|
|
47
|
+
return redirect_to users_path, alert: "You cannot deactivate your own account"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
locked_out = User.transaction do
|
|
51
|
+
# Lock every user who can actually sign in (active with a password) so
|
|
52
|
+
# two concurrent deactivations serialize instead of both seeing "one
|
|
53
|
+
# other can still sign in" and leaving nobody who can. Pending
|
|
54
|
+
# invitees have no password and do not count. (No-op on SQLite,
|
|
55
|
+
# honored on Postgres/MySQL.)
|
|
56
|
+
signin_ids = users.active.where.not(password_digest: nil).order(:id).lock.pluck(:id)
|
|
57
|
+
next true if signin_ids.include?(@user.id) && (signin_ids - [ @user.id ]).empty?
|
|
58
|
+
|
|
59
|
+
@user.update!(active: false)
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if locked_out
|
|
64
|
+
return redirect_to users_path, alert: "At least one active user must remain, or no one could sign in"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
audit "user.deactivated", @user
|
|
68
|
+
redirect_to users_path, notice: "#{@user.name} was deactivated and can no longer sign in"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def reactivate
|
|
72
|
+
@user.update!(active: true)
|
|
73
|
+
audit "user.reactivated", @user
|
|
74
|
+
redirect_to users_path, notice: "#{@user.name} was reactivated"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reset_link
|
|
78
|
+
flash[:invite_link] = invitation_url(@user.generate_token_for(:password_setup))
|
|
79
|
+
redirect_to users_path, notice: "New sign-in link for #{@user.name}; it is shown only once."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def ensure_builtin_mode
|
|
85
|
+
head :not_found unless RivetCms.builtin_auth?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def users
|
|
89
|
+
User.where(organization: Current.organization)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def set_user
|
|
93
|
+
@user = users.find(params[:id])
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def user_params
|
|
97
|
+
params.permit(:name, :email)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def user_props(user)
|
|
101
|
+
{
|
|
102
|
+
id: user.prefix_id,
|
|
103
|
+
name: user.name,
|
|
104
|
+
email: user.email,
|
|
105
|
+
status: user.status,
|
|
106
|
+
yourself: user == Current.user,
|
|
107
|
+
created_at: user.created_at.iso8601,
|
|
108
|
+
paths: {
|
|
109
|
+
update: user_path(user),
|
|
110
|
+
deactivate: deactivate_user_path(user),
|
|
111
|
+
reactivate: reactivate_user_path(user),
|
|
112
|
+
reset_link: reset_link_user_path(user)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
module RivetCms
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
# Fails open on the page, closed on the asset: a registered extension
|
|
4
|
+
# bundle that cannot be resolved is logged and skipped instead of 500ing
|
|
5
|
+
# every admin page. Matches the can? posture of degrading rather than
|
|
6
|
+
# taking the whole admin down over an initializer typo.
|
|
7
|
+
def admin_extension_tag(kind, name)
|
|
8
|
+
case kind
|
|
9
|
+
when :script then javascript_include_tag(name, type: "module", defer: true)
|
|
10
|
+
when :stylesheet then stylesheet_link_tag(name, media: "all")
|
|
11
|
+
end
|
|
12
|
+
rescue StandardError => error
|
|
13
|
+
Rails.logger&.error("[RivetCms] admin #{kind} #{name.inspect} could not be rendered (skipping): #{error.class}: #{error.message}")
|
|
14
|
+
nil
|
|
15
|
+
end
|
|
6
16
|
end
|
|
7
17
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
|
|
3
|
+
module RivetCms
|
|
4
|
+
# Basic CE webhook delivery: one POST, JSON body, short timeouts, no
|
|
5
|
+
# signing. Any failure (connection error or non-2xx response) raises, so
|
|
6
|
+
# retry behavior uniformly follows the host's queue adapter defaults.
|
|
7
|
+
class WebhookDeliveryJob < ApplicationJob
|
|
8
|
+
# Webhook URLs are often capability URLs (tokens in path or query):
|
|
9
|
+
# keep them out of job argument logs, and log only the host on failure.
|
|
10
|
+
self.log_arguments = false
|
|
11
|
+
|
|
12
|
+
OPEN_TIMEOUT = 5
|
|
13
|
+
READ_TIMEOUT = 10
|
|
14
|
+
WRITE_TIMEOUT = 10
|
|
15
|
+
|
|
16
|
+
class DeliveryError < StandardError; end
|
|
17
|
+
|
|
18
|
+
def perform(url, payload)
|
|
19
|
+
uri = URI.parse(url)
|
|
20
|
+
http = Net::HTTP.new(uri.hostname, uri.port)
|
|
21
|
+
http.use_ssl = uri.scheme == "https"
|
|
22
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
23
|
+
http.read_timeout = READ_TIMEOUT
|
|
24
|
+
http.write_timeout = WRITE_TIMEOUT
|
|
25
|
+
|
|
26
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json", "User-Agent" => "RivetCMS/#{RivetCms::VERSION}")
|
|
27
|
+
request.body = payload.to_json
|
|
28
|
+
response = http.request(request)
|
|
29
|
+
|
|
30
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
31
|
+
raise DeliveryError, "webhook to #{uri.host} responded #{response.code}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
# Shared by content types and components. The fields association is
|
|
3
|
+
# soft-delete scoped (kept only), so dependent: :destroy would leave
|
|
4
|
+
# discarded fields holding the FK — destroy those explicitly on delete.
|
|
5
|
+
module HasFields
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
has_many :fields, dependent: :destroy
|
|
10
|
+
before_destroy :destroy_discarded_fields, prepend: true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def all_fields
|
|
14
|
+
fields.with_discarded
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def active_fields
|
|
18
|
+
fields
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def discarded_fields
|
|
22
|
+
fields.with_discarded.discarded
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def destroy_discarded_fields
|
|
28
|
+
discarded_fields.destroy_all
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
module OrganizationScoped
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
belongs_to :organization
|
|
7
|
+
before_validation :assign_default_organization
|
|
8
|
+
validates :organization, presence: true
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def assign_default_organization
|
|
14
|
+
self.organization ||= RivetCms::Current.organization
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
class RevisionImmutableError < StandardError; end
|
|
3
|
+
|
|
4
|
+
module RevisionOwned
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
belongs_to :owner, polymorphic: true
|
|
9
|
+
before_update :guard_published_owner!
|
|
10
|
+
validate :field_matches_owner_organization
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def owning_revision
|
|
14
|
+
node = owner
|
|
15
|
+
node = node.owner while node.is_a?(ComponentInstance)
|
|
16
|
+
node if node.is_a?(DocumentRevision)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def owner_organization_id
|
|
20
|
+
owning_revision&.document&.organization_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def guard_published_owner!
|
|
26
|
+
raise RevisionImmutableError, "cannot modify a published revision" if owning_revision&.published?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def field_matches_owner_organization
|
|
30
|
+
return if field.nil? || owner_organization_id.nil?
|
|
31
|
+
return if field.organization_id == owner_organization_id
|
|
32
|
+
|
|
33
|
+
errors.add(:field, "must belong to the same organization")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
module Sluggable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
SLUG_FORMAT = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
before_validation :generate_slug, if: -> { slug.blank? && respond_to?(:name) && name.present? }
|
|
9
|
+
|
|
10
|
+
validates :slug, presence: true,
|
|
11
|
+
format: { with: SLUG_FORMAT, message: "must be lowercase alphanumeric with hyphens" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def generate_slug
|
|
17
|
+
base_slug = name.parameterize
|
|
18
|
+
self.slug = unique_slug(base_slug)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def unique_slug(base_slug)
|
|
22
|
+
slug = base_slug
|
|
23
|
+
counter = 1
|
|
24
|
+
scope = self.class.unscoped.where.not(id: id)
|
|
25
|
+
|
|
26
|
+
if respond_to?(:organization_id) && organization_id.present?
|
|
27
|
+
scope = scope.where(organization_id: organization_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
while scope.exists?(slug: slug)
|
|
31
|
+
slug = "#{base_slug}-#{counter}"
|
|
32
|
+
counter += 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
slug
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
module SoftDeletable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
scope :kept, -> { where(deleted_at: nil) }
|
|
7
|
+
scope :discarded, -> { where.not(deleted_at: nil) }
|
|
8
|
+
|
|
9
|
+
default_scope { kept }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class_methods do
|
|
13
|
+
def with_discarded
|
|
14
|
+
unscope(where: :deleted_at)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def discard
|
|
19
|
+
update(deleted_at: Time.current)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def discard!
|
|
23
|
+
update!(deleted_at: Time.current)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def undiscard
|
|
27
|
+
update(deleted_at: nil)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def undiscard!
|
|
31
|
+
update!(deleted_at: nil)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def discarded?
|
|
35
|
+
deleted_at.present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def kept?
|
|
39
|
+
deleted_at.nil?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module RivetCms
|
|
2
|
+
module TypedValue
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
VALUE_COLUMNS = %i[
|
|
6
|
+
string_value text_value integer_value boolean_value
|
|
7
|
+
decimal_value datetime_value date_value json_value
|
|
8
|
+
].freeze
|
|
9
|
+
|
|
10
|
+
SCALAR_COLUMN = {
|
|
11
|
+
"string" => :string_value,
|
|
12
|
+
"text" => :text_value,
|
|
13
|
+
"rich_text" => :text_value,
|
|
14
|
+
"markdown" => :text_value,
|
|
15
|
+
"integer" => :integer_value,
|
|
16
|
+
"decimal" => :decimal_value,
|
|
17
|
+
"enumeration" => :string_value,
|
|
18
|
+
"boolean" => :boolean_value,
|
|
19
|
+
"date" => :date_value,
|
|
20
|
+
"datetime" => :datetime_value
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def value
|
|
24
|
+
return media_asset if field&.attachment?
|
|
25
|
+
|
|
26
|
+
column = value_column
|
|
27
|
+
column ? self[column] : nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def value=(raw)
|
|
31
|
+
if field&.attachment?
|
|
32
|
+
self.media_asset = MediaAsset.find_by(id: extract_media_asset_id(raw))
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
column = value_column
|
|
37
|
+
self[column] = raw if column
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def extract_media_asset_id(raw)
|
|
43
|
+
raw = raw["id"] || raw[:id] if raw.is_a?(Hash)
|
|
44
|
+
raw.presence
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def value_column
|
|
48
|
+
SCALAR_COLUMN[field&.field_type]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|