lesli_shield 1.1.5 → 1.1.7
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/Rakefile +3 -0
- data/app/assets/stylesheets/lesli_shield/application.css +140 -1
- data/app/assets/stylesheets/lesli_shield/confirmations.css +917 -18
- data/app/assets/stylesheets/lesli_shield/confirmations.tailwind.css +918 -0
- data/app/assets/stylesheets/lesli_shield/passwords.css +872 -18
- data/app/assets/stylesheets/lesli_shield/passwords.tailwind.css +873 -0
- data/app/assets/stylesheets/lesli_shield/registrations.css +904 -770
- data/app/assets/stylesheets/lesli_shield/registrations.tailwind.css +908 -0
- data/app/assets/stylesheets/lesli_shield/sessions.css +984 -32
- data/app/assets/stylesheets/lesli_shield/sessions.tailwind.css +983 -1
- data/app/controllers/lesli_shield/profile/passwords_controller.rb +119 -0
- data/app/controllers/lesli_shield/profiles_controller.rb +63 -0
- data/app/controllers/lesli_shield/sessions_controller.rb +1 -2
- data/app/controllers/lesli_shield/users_controller.rb +3 -7
- data/app/controllers/users/confirmations_controller.rb +162 -49
- data/app/controllers/users/passwords_controller.rb +211 -28
- data/app/controllers/users/registrations_controller.rb +108 -83
- data/app/controllers/users/sessions_controller.rb +122 -84
- data/app/interfaces/lesli_shield/authentication_interface.rb +3 -3
- data/app/interfaces/lesli_shield/authorization_interface.rb +2 -2
- data/app/mailers/lesli_shield/devise_mailer.rb +73 -17
- data/app/models/concerns/lesli_shield/user_authentication.rb +54 -0
- data/app/models/lesli_shield/account.rb +4 -1
- data/app/models/lesli_shield/invite.rb +1 -1
- data/app/models/lesli_shield/user/role.rb +1 -1
- data/app/models/lesli_shield/user.rb +5 -2
- data/app/services/lesli_shield/profile/password_service.rb +47 -0
- data/app/services/lesli_shield/profile_service.rb +24 -0
- data/app/services/lesli_shield/user_registration_service.rb +41 -74
- data/app/services/lesli_shield/user_session_service.rb +1 -1
- data/app/services/lesli_shield/user_validator_service.rb +35 -39
- data/app/views/devise/confirmations/new.html.erb +58 -12
- data/app/views/devise/confirmations/show.html.erb +27 -60
- data/app/views/devise/passwords/edit.html.erb +59 -25
- data/app/views/devise/passwords/new.html.erb +61 -26
- data/app/views/devise/registrations/new.html.erb +80 -53
- data/app/views/devise/sessions/new.html.erb +53 -36
- data/app/views/devise/shared/_application-devise-simple.erb +18 -19
- data/app/views/devise/shared/_demo.html.erb +8 -6
- data/app/views/devise/shared/_error_messages.html.erb +45 -10
- data/app/views/devise/shared/_links.html.erb +6 -13
- data/app/views/lesli_shield/invites/_form.html.erb +1 -1
- data/app/views/lesli_shield/invites/index.html.erb +3 -2
- data/app/views/lesli_shield/profiles/_password-form.html.erb +68 -0
- data/app/views/lesli_shield/profiles/show.html.erb +29 -0
- data/app/views/lesli_shield/users/_information-card.html.erb +35 -45
- data/app/views/lesli_shield/users/_information-form.html.erb +9 -1
- data/config/initializers/devise.rb +52 -328
- data/config/locales/translations.en.yml +65 -2
- data/config/locales/translations.es.yml +65 -2
- data/config/routes.rb +4 -0
- data/lib/lesli_shield/engine.rb +44 -4
- data/lib/lesli_shield/router.rb +52 -18
- data/lib/lesli_shield/version.rb +2 -2
- data/readme.md +6 -4
- metadata +30 -5
- data/app/assets/config/lesli_shield_manifest.js +0 -48
- data/app/assets/stylesheets/lesli_shield/application.tailwind.css +0 -0
|
@@ -34,49 +34,232 @@ Building a better future, one line of code at a time.
|
|
|
34
34
|
|
|
35
35
|
class Users::PasswordsController < Devise::PasswordsController
|
|
36
36
|
|
|
37
|
+
PASSWORD_RESET_RATE_LIMIT_WINDOW = 15.minutes
|
|
38
|
+
|
|
39
|
+
rate_limit(
|
|
40
|
+
to: 20,
|
|
41
|
+
within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
|
|
42
|
+
by: -> { request.remote_ip },
|
|
43
|
+
with: :respond_to_password_reset_rate_limit,
|
|
44
|
+
only: :create,
|
|
45
|
+
name: "password-reset-request-ip"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
rate_limit(
|
|
49
|
+
to: 5,
|
|
50
|
+
within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
|
|
51
|
+
by: :password_reset_email_rate_limit_key,
|
|
52
|
+
with: :respond_to_password_reset_rate_limit,
|
|
53
|
+
only: :create,
|
|
54
|
+
name: "password-reset-request-email"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
rate_limit(
|
|
58
|
+
to: 20,
|
|
59
|
+
within: PASSWORD_RESET_RATE_LIMIT_WINDOW,
|
|
60
|
+
by: -> { request.remote_ip },
|
|
61
|
+
with: :respond_to_password_reset_rate_limit,
|
|
62
|
+
only: :update,
|
|
63
|
+
name: "password-reset-update-ip"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
before_action :protect_password_reset_token, only: %i[edit update]
|
|
67
|
+
|
|
37
68
|
# Sends an email with a token, so the user can reset their password
|
|
38
69
|
def create
|
|
39
|
-
|
|
70
|
+
super do |user|
|
|
71
|
+
record_activity_safely(
|
|
72
|
+
user,
|
|
73
|
+
operation: "password_reset_requested",
|
|
74
|
+
description: "Password reset requested"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
40
78
|
|
|
41
|
-
|
|
79
|
+
def update
|
|
80
|
+
password_reset_succeeded = false
|
|
42
81
|
|
|
43
|
-
|
|
82
|
+
super do |user|
|
|
83
|
+
password_reset_succeeded = user.errors.empty?
|
|
44
84
|
|
|
45
|
-
user.
|
|
46
|
-
:
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
:operation => 'password_reset',
|
|
50
|
-
:description => 'Reset password instructions sent'
|
|
51
|
-
)
|
|
85
|
+
if password_reset_succeeded && user.has_expired_password?
|
|
86
|
+
user.update!(password_expiration_at: nil)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
52
89
|
|
|
53
|
-
|
|
54
|
-
|
|
90
|
+
if password_reset_succeeded
|
|
91
|
+
complete_successful_password_reset!(resource)
|
|
55
92
|
else
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
record_activity_safely(
|
|
94
|
+
resource,
|
|
95
|
+
operation: "password_reset_failed",
|
|
96
|
+
description: "Password reset failed validation"
|
|
97
|
+
)
|
|
59
98
|
end
|
|
60
99
|
end
|
|
61
100
|
|
|
62
|
-
|
|
63
|
-
|
|
101
|
+
protected
|
|
102
|
+
|
|
103
|
+
# Preserve the current UX: after requesting a reset, show the same page with
|
|
104
|
+
# Devise's generic paranoid-mode confirmation instead of revealing whether
|
|
105
|
+
# the submitted email belongs to an account.
|
|
106
|
+
def after_sending_reset_password_instructions_path_for(_resource_name)
|
|
107
|
+
new_user_password_path
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Devise resets the password even when the account cannot authenticate. Only
|
|
111
|
+
# create a web session when the user also passes LesliShield's account, role,
|
|
112
|
+
# confirmation, and lock checks.
|
|
113
|
+
def sign_in_after_reset_password?
|
|
114
|
+
super && resource.active_for_authentication?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
64
118
|
|
|
65
|
-
|
|
119
|
+
def complete_successful_password_reset!(user)
|
|
120
|
+
unless user_signed_in?
|
|
121
|
+
record_activity_safely(
|
|
122
|
+
user,
|
|
123
|
+
operation: "password_reset_succeeded",
|
|
124
|
+
description: "Password reset successful"
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
end
|
|
66
128
|
|
|
67
|
-
|
|
68
|
-
|
|
129
|
+
session[:user_session_id] = create_application_session_and_audit!(user)
|
|
130
|
+
record_successful_password_reset_analytics
|
|
131
|
+
rescue StandardError
|
|
132
|
+
rollback_web_session
|
|
133
|
+
raise
|
|
134
|
+
end
|
|
69
135
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
136
|
+
# The password has already been committed by Devise at this point. This
|
|
137
|
+
# transaction makes the application session and its single audit event
|
|
138
|
+
# atomic without wrapping Devise's sign-in or response handling.
|
|
139
|
+
def create_application_session_and_audit!(user)
|
|
140
|
+
resource_class.transaction do
|
|
141
|
+
application_session = LesliShield::UserSessionService
|
|
142
|
+
.new(user)
|
|
143
|
+
.create(
|
|
144
|
+
request.remote_ip,
|
|
145
|
+
detected_user_agent
|
|
146
|
+
)
|
|
147
|
+
.result
|
|
74
148
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
149
|
+
record_activity(
|
|
150
|
+
user,
|
|
151
|
+
operation: "password_reset_succeeded",
|
|
152
|
+
description: "Password reset successful",
|
|
153
|
+
session_id: application_session.id
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
application_session.id
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def rollback_web_session
|
|
161
|
+
session.delete(:user_session_id)
|
|
162
|
+
sign_out(resource_name)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def record_successful_password_reset_analytics
|
|
166
|
+
log_requests if respond_to?(:log_requests)
|
|
167
|
+
log_devices if respond_to?(:log_devices)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def detected_user_agent
|
|
171
|
+
get_user_agent if respond_to?(:get_user_agent)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def password_reset_email_rate_limit_key
|
|
175
|
+
normalized_email = password_reset_request_params[:email].to_s.strip.downcase
|
|
176
|
+
identity = normalized_email.presence || "blank:#{request.remote_ip}"
|
|
177
|
+
|
|
178
|
+
OpenSSL::HMAC.hexdigest(
|
|
179
|
+
"SHA256",
|
|
180
|
+
Rails.application.secret_key_base,
|
|
181
|
+
identity
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def password_reset_request_params
|
|
186
|
+
params.fetch(resource_name, {}).permit(:email)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def respond_to_password_reset_rate_limit
|
|
190
|
+
message = I18n.t(
|
|
191
|
+
"lesli_shield.devise/passwords.message_too_many_attempts"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
response.set_header(
|
|
195
|
+
"Retry-After",
|
|
196
|
+
PASSWORD_RESET_RATE_LIMIT_WINDOW.to_i.to_s
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
respond_to do |format|
|
|
200
|
+
format.html { render_password_reset_rate_limit(message) }
|
|
201
|
+
format.json do
|
|
202
|
+
render json: { error: message }, status: :too_many_requests
|
|
79
203
|
end
|
|
204
|
+
format.any { head :too_many_requests }
|
|
80
205
|
end
|
|
81
206
|
end
|
|
207
|
+
|
|
208
|
+
def render_password_reset_rate_limit(message)
|
|
209
|
+
flash.now[:alert] = message
|
|
210
|
+
|
|
211
|
+
if action_name == "update"
|
|
212
|
+
self.resource = resource_class.new(
|
|
213
|
+
reset_password_token: resource_params[:reset_password_token]
|
|
214
|
+
)
|
|
215
|
+
set_minimum_password_length
|
|
216
|
+
render :edit, status: :too_many_requests
|
|
217
|
+
else
|
|
218
|
+
self.resource = resource_class.new(
|
|
219
|
+
email: password_reset_request_params[:email]
|
|
220
|
+
)
|
|
221
|
+
render :new, status: :too_many_requests
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def protect_password_reset_token
|
|
226
|
+
# `no-referrer` makes some browsers submit the form with `Origin: null`,
|
|
227
|
+
# which Rails correctly rejects when origin-based CSRF protection is
|
|
228
|
+
# enabled. `same-origin` keeps the reset token away from third-party
|
|
229
|
+
# origins while preserving a verifiable origin for the form submission.
|
|
230
|
+
response.set_header("Referrer-Policy", "same-origin")
|
|
231
|
+
response.set_header("Cache-Control", "no-store")
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def record_activity_safely(user, operation:, description:)
|
|
235
|
+
record_activity(
|
|
236
|
+
user,
|
|
237
|
+
operation: operation,
|
|
238
|
+
description: description
|
|
239
|
+
)
|
|
240
|
+
rescue StandardError => exception
|
|
241
|
+
Rails.error.report(
|
|
242
|
+
exception,
|
|
243
|
+
handled: true,
|
|
244
|
+
context: {
|
|
245
|
+
controller: self.class.name,
|
|
246
|
+
operation: operation
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Invalid or unknown reset tokens can produce a resource without a tenant.
|
|
252
|
+
# Those attempts cannot be written to tenant-scoped Audit tables.
|
|
253
|
+
def record_activity(user, operation:, description:, session_id: nil)
|
|
254
|
+
return unless user&.account
|
|
255
|
+
|
|
256
|
+
user.log(
|
|
257
|
+
engine: LesliShield,
|
|
258
|
+
source: self.class.name,
|
|
259
|
+
action: action_name,
|
|
260
|
+
operation: operation,
|
|
261
|
+
description: description,
|
|
262
|
+
session_id: session_id
|
|
263
|
+
)
|
|
264
|
+
end
|
|
82
265
|
end
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Lesli
|
|
6
6
|
|
|
7
|
-
Copyright (c)
|
|
7
|
+
Copyright (c) 2026, Lesli Technologies, S. A.
|
|
8
8
|
|
|
9
9
|
This program is free software: you can redistribute it and/or modify
|
|
10
10
|
it under the terms of the GNU General Public License as published by
|
|
@@ -33,98 +33,123 @@ Building a better future, one line of code at a time.
|
|
|
33
33
|
=end
|
|
34
34
|
|
|
35
35
|
class Users::RegistrationsController < Devise::RegistrationsController
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
REGISTRATION_RATE_LIMIT_WINDOW = 1.hour
|
|
37
|
+
|
|
38
|
+
before_action :ensure_registration_allowed!, only: %i[new create]
|
|
39
|
+
|
|
40
|
+
rate_limit(
|
|
41
|
+
to: 10,
|
|
42
|
+
within: REGISTRATION_RATE_LIMIT_WINDOW,
|
|
43
|
+
by: -> { request.remote_ip },
|
|
44
|
+
with: :respond_to_registration_rate_limit,
|
|
45
|
+
only: :create,
|
|
46
|
+
name: "registration-ip"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
rate_limit(
|
|
50
|
+
to: 3,
|
|
51
|
+
within: REGISTRATION_RATE_LIMIT_WINDOW,
|
|
52
|
+
by: :registration_email_rate_limit_key,
|
|
53
|
+
with: :respond_to_registration_rate_limit,
|
|
54
|
+
only: :create,
|
|
55
|
+
name: "registration-email"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Devise owns user construction, validation, persistence, confirmation
|
|
59
|
+
# delivery, password cleanup, and the active/inactive response flow.
|
|
60
|
+
def create
|
|
61
|
+
super do |user|
|
|
62
|
+
record_user_creation_safely(user)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
38
65
|
|
|
39
|
-
|
|
40
|
-
# def new
|
|
41
|
-
# super
|
|
42
|
-
# end
|
|
66
|
+
protected
|
|
43
67
|
|
|
44
|
-
|
|
45
|
-
|
|
68
|
+
# Standalone registrations remain inactive until confirmation creates the
|
|
69
|
+
# account and role. Preserve the existing UX by returning to the form with
|
|
70
|
+
# Devise's success notice instructing the user to check their email.
|
|
71
|
+
def after_inactive_sign_up_path_for(_user)
|
|
72
|
+
new_user_registration_path
|
|
73
|
+
end
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
if !Lesli.config.security.dig(:allow_registration)
|
|
49
|
-
raise(I18n.t("core.users/registrations.messages_error_registration_not_allowed"))
|
|
50
|
-
end
|
|
75
|
+
private
|
|
51
76
|
|
|
52
|
-
|
|
53
|
-
|
|
77
|
+
def ensure_registration_allowed!
|
|
78
|
+
return if Lesli.config.security.dig(:allow_registration)
|
|
54
79
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
rescue => exception
|
|
63
|
-
#Honeybadger.notify(exception)
|
|
64
|
-
danger(exception.message)
|
|
65
|
-
end
|
|
66
|
-
redirect_to(new_user_registration_path)
|
|
80
|
+
redirect_to(
|
|
81
|
+
new_user_session_path,
|
|
82
|
+
alert: I18n.t(
|
|
83
|
+
"lesli_shield.devise/registrations.message_registration_not_allowed"
|
|
84
|
+
),
|
|
85
|
+
status: request.post? ? :see_other : :found
|
|
86
|
+
)
|
|
67
87
|
end
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
# end
|
|
73
|
-
|
|
74
|
-
# PUT /resource
|
|
75
|
-
# def update
|
|
76
|
-
# super
|
|
77
|
-
# end
|
|
78
|
-
|
|
79
|
-
# DELETE /resource
|
|
80
|
-
# def destroy
|
|
81
|
-
# super
|
|
82
|
-
# end
|
|
83
|
-
|
|
84
|
-
# GET /resource/cancel
|
|
85
|
-
# Forces the session data which is usually expired after sign
|
|
86
|
-
# in to be expired now. This is useful if the user wants to
|
|
87
|
-
# cancel oauth signing in/up in the middle of the process,
|
|
88
|
-
# removing all OAuth session data.
|
|
89
|
-
# def cancel
|
|
90
|
-
# super
|
|
91
|
-
# end
|
|
89
|
+
def registration_email_rate_limit_key
|
|
90
|
+
normalized_email = registration_params[:email].to_s.strip.downcase
|
|
91
|
+
identity = normalized_email.presence || "blank:#{request.remote_ip}"
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
OpenSSL::HMAC.hexdigest(
|
|
94
|
+
"SHA256",
|
|
95
|
+
Rails.application.secret_key_base,
|
|
96
|
+
identity
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def registration_params
|
|
101
|
+
params
|
|
102
|
+
.fetch(resource_name, ActionController::Parameters.new)
|
|
103
|
+
.permit(:email)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def respond_to_registration_rate_limit
|
|
107
|
+
message = I18n.t(
|
|
108
|
+
"lesli_shield.devise/registrations.message_too_many_attempts"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
response.set_header(
|
|
112
|
+
"Retry-After",
|
|
113
|
+
REGISTRATION_RATE_LIMIT_WINDOW.to_i.to_s
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
respond_to do |format|
|
|
117
|
+
format.html { render_registration_rate_limit(message) }
|
|
118
|
+
format.json do
|
|
119
|
+
render json: { error: message }, status: :too_many_requests
|
|
120
|
+
end
|
|
121
|
+
format.any { head :too_many_requests }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
94
124
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
# If you have extra params to permit, append them to the sanitizer.
|
|
101
|
-
def configure_sign_up_params
|
|
102
|
-
devise_parameter_sanitizer.permit(:sign_up, keys: [
|
|
103
|
-
:email,
|
|
104
|
-
:password,
|
|
105
|
-
:password_confirmation
|
|
106
|
-
])
|
|
125
|
+
def render_registration_rate_limit(message)
|
|
126
|
+
flash.now[:alert] = message
|
|
127
|
+
self.resource = resource_class.new(email: registration_params[:email])
|
|
128
|
+
set_minimum_password_length
|
|
129
|
+
render :new, status: :too_many_requests
|
|
107
130
|
end
|
|
108
131
|
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
# A standalone signup has no tenant yet, so its durable audit event belongs
|
|
133
|
+
# to the later confirmation transaction. Keep support for pre-associated
|
|
134
|
+
# resources without allowing an optional audit failure to undo registration.
|
|
135
|
+
def record_user_creation_safely(user)
|
|
136
|
+
return unless user.persisted? && user.account.present?
|
|
137
|
+
|
|
138
|
+
user.log(
|
|
139
|
+
engine: LesliShield,
|
|
140
|
+
source: self.class.name,
|
|
141
|
+
action: action_name,
|
|
142
|
+
operation: "user_creation_succeeded",
|
|
143
|
+
description: "User created successfully"
|
|
144
|
+
)
|
|
145
|
+
rescue StandardError => exception
|
|
146
|
+
Rails.error.report(
|
|
147
|
+
exception,
|
|
148
|
+
handled: true,
|
|
149
|
+
context: {
|
|
150
|
+
controller: self.class.name,
|
|
151
|
+
operation: "user_creation_succeeded"
|
|
152
|
+
}
|
|
153
|
+
)
|
|
129
154
|
end
|
|
130
155
|
end
|