lato 3.24.0 → 3.26.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +49 -5
  3. data/app/assets/javascripts/lato/controllers/lato_account_security_controller.js +51 -0
  4. data/app/assets/javascripts/lato/controllers/lato_account_webauthn_controller.js +8 -5
  5. data/app/assets/javascripts/lato/controllers/lato_action_controller.js +26 -15
  6. data/app/assets/javascripts/lato/controllers/lato_confirm_controller.js +25 -15
  7. data/app/assets/javascripts/lato/controllers/lato_copyclip_controller.js +28 -10
  8. data/app/assets/javascripts/lato/controllers/lato_form_controller.js +1 -0
  9. data/app/assets/javascripts/lato/controllers/lato_index_controller.js +4 -1
  10. data/app/controllers/concerns/lato/componentable.rb +20 -8
  11. data/app/controllers/concerns/lato/sessionable.rb +58 -16
  12. data/app/controllers/lato/account_controller.rb +98 -24
  13. data/app/controllers/lato/application_controller.rb +25 -1
  14. data/app/controllers/lato/authentication_controller.rb +74 -39
  15. data/app/helpers/lato/components_helper.rb +42 -16
  16. data/app/jobs/lato/active_storage_cleaner_job.rb +8 -7
  17. data/app/models/lato/invitation.rb +1 -1
  18. data/app/models/lato/operation.rb +6 -2
  19. data/app/models/lato/session.rb +73 -19
  20. data/app/models/lato/session_record.rb +5 -0
  21. data/app/models/lato/user.rb +66 -26
  22. data/app/views/lato/account/_alert-accepted-privacy-policy-version.html.erb +1 -1
  23. data/app/views/lato/account/_alert-accepted-terms-and-conditions-version.html.erb +1 -1
  24. data/app/views/lato/account/_form-authenticator.html.erb +19 -33
  25. data/app/views/lato/account/_form-destroy.html.erb +1 -0
  26. data/app/views/lato/account/_form-password.html.erb +4 -2
  27. data/app/views/lato/account/_form-user.html.erb +1 -0
  28. data/app/views/lato/account/_form-web3.html.erb +5 -3
  29. data/app/views/lato/account/_form-webauthn.html.erb +1 -0
  30. data/app/views/lato/account/_security-confirmation.html.erb +12 -0
  31. data/app/views/lato/authentication/_fields-registration.html.erb +8 -7
  32. data/app/views/lato/authentication/_form-authenticator.html.erb +2 -2
  33. data/app/views/lato/authentication/_form-signin.html.erb +2 -2
  34. data/app/views/lato/authentication/_form-verify-email.html.erb +3 -2
  35. data/app/views/lato/authentication/_form-web3-signin.html.erb +4 -3
  36. data/app/views/lato/components/_index.html.erb +25 -20
  37. data/app/views/lato/components/_navbar_nav_item.html.erb +1 -1
  38. data/app/views/lato/components/_operation.html.erb +4 -1
  39. data/app/views/lato/components/_page_head.html.erb +1 -1
  40. data/app/views/lato/components/_sidebar_nav_item.html.erb +2 -1
  41. data/app/views/lato/mailer/invitation/invite_mail.html.erb +1 -1
  42. data/app/views/lato/mailer/user/email_verification_mail.html.erb +1 -1
  43. data/app/views/lato/mailer/user/password_update_mail.html.erb +1 -1
  44. data/app/views/lato/mailer/user/signin_success_mail.html.erb +1 -1
  45. data/app/views/layouts/lato/_action.html.erb +2 -2
  46. data/app/views/layouts/lato/_aside-opener.html.erb +2 -2
  47. data/app/views/layouts/lato/_confirm.html.erb +6 -8
  48. data/app/views/layouts/lato/_feedbacks.html.erb +9 -8
  49. data/app/views/layouts/lato/_mailer-head_content.html.erb +1 -1
  50. data/app/views/layouts/lato/_network.html.erb +3 -3
  51. data/app/views/layouts/lato/application.html.erb +3 -2
  52. data/config/locales/en.yml +32 -0
  53. data/config/locales/fr.yml +29 -0
  54. data/config/locales/it.yml +29 -0
  55. data/config/locales/ro.yml +29 -0
  56. data/config/routes.rb +1 -0
  57. data/db/migrate/20261024000000_create_lato_session_records.rb +16 -0
  58. data/db/migrate/20261110000000_add_second_factor_counters_to_lato_users.rb +6 -0
  59. data/lib/lato/config.rb +4 -0
  60. data/lib/lato/engine.rb +9 -0
  61. data/lib/lato/version.rb +1 -1
  62. metadata +34 -17
@@ -3,8 +3,20 @@ module Lato
3
3
  before_action :authenticate_session
4
4
  before_action { active_navbar(:account) }
5
5
 
6
+ before_action :discard_expired_security_enrollment
7
+ before_action :limit_requests, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action security_challenge]
8
+ before_action :reauthenticate_sensitive_action, only: %i[update_user_action update_password_action update_authenticator_action update_webauthn_action update_web3_action destroy_action]
9
+
6
10
  def index; end
7
11
 
12
+ def security_challenge
13
+ return head :not_found unless @session.user.webauthn_enabled?
14
+
15
+ options = @session.user.webauthn_authentication_options
16
+ @session.set_security_challenge(options.challenge)
17
+ render json: options.as_json
18
+ end
19
+
8
20
  def update_user_action
9
21
  respond_to do |format|
10
22
  if @session.user.update(params.require(:user).permit(:first_name, :last_name, :email))
@@ -20,25 +32,32 @@ module Lato
20
32
  def update_authenticator_action
21
33
  return respond_to_with_not_found unless Lato.config.authenticator_connection
22
34
 
23
- if @session.user.authenticator_secret
24
- respond_to do |format|
25
- if @session.user.remove_authenticator_secret
26
- format.html { redirect_to lato.account_path }
27
- format.json { render json: @session.user }
28
- else
29
- format.html { render :index, status: :unprocessable_entity }
30
- format.json { render json: @session.user.errors, status: :unprocessable_entity }
31
- end
32
- end
35
+ user = @session.user
36
+ if user.authenticator_enabled?
37
+ success = user.remove_authenticator_secret
38
+ elsif params.dig(:user, :authenticator_command) == "confirm"
39
+ pending = session.delete(:pending_authenticator_secret)
40
+ used_at = pending.present? && ROTP::TOTP.new(pending).verify(params.dig(:user, :authenticator_code).to_s, drift_behind: 15)
41
+ # Store the enrollment code timestamp so the same code can not be replayed for the first login.
42
+ success = used_at ? user.update(authenticator_secret: pending, authenticator_last_used_at: Time.at(used_at)) : false
43
+ user.errors.add(:base, :authenticator_code_invalid) unless success
44
+ @session.clear_security_continuation
33
45
  else
34
- respond_to do |format|
35
- if @session.user.generate_authenticator_secret
36
- format.html { redirect_to lato.account_path }
37
- format.json { render json: @session.user }
38
- else
39
- format.html { render :index, status: :unprocessable_entity }
40
- format.json { render json: @session.user.errors, status: :unprocessable_entity }
41
- end
46
+ session[:pending_authenticator_secret] = ROTP::Base32.random
47
+ grant_security_continuation
48
+ return respond_to do |format|
49
+ format.html { redirect_to lato.account_path }
50
+ format.json { render json: { secret: session[:pending_authenticator_secret] } }
51
+ end
52
+ end
53
+
54
+ respond_to do |format|
55
+ if success
56
+ format.html { redirect_to lato.account_path }
57
+ format.json { render json: user }
58
+ else
59
+ format.html { render :index, status: :unprocessable_entity }
60
+ format.json { render json: user.errors, status: :unprocessable_entity }
42
61
  end
43
62
  end
44
63
  end
@@ -76,6 +95,7 @@ module Lato
76
95
  format.json { render json: @session.user.errors, status: :unprocessable_entity }
77
96
  end
78
97
  else
98
+ grant_security_continuation
79
99
  options = @session.user.webauthn_registration_options
80
100
  session[:webauthn_registration_challenge] = Base64.strict_encode64(options.challenge)
81
101
  session[:webauthn_registration_options] = options.as_json
@@ -107,19 +127,18 @@ module Lato
107
127
  end
108
128
  elsif session[:web3_nonce]
109
129
  respond_to do |format|
110
- if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce: session[:web3_nonce]))
111
- session[:web3_nonce] = nil
130
+ if @session.user.add_web3_connection(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(web3_nonce: web3_message_consume))
112
131
  format.html { redirect_to lato.account_path }
113
132
  format.json { render json: @session.user }
114
133
  else
115
- session[:web3_nonce] = nil
116
134
  format.html { render :index, status: :unprocessable_entity }
117
135
  format.json { render json: @session.user.errors, status: :unprocessable_entity }
118
136
  end
119
137
  end
120
138
  else
121
139
  respond_to do |format|
122
- if session[:web3_nonce] = SecureRandom.hex(32)
140
+ grant_security_continuation
141
+ if web3_message_generate('connect your wallet')
123
142
  format.html { redirect_to lato.account_path }
124
143
  format.json { render json: @session.user }
125
144
  else
@@ -145,8 +164,9 @@ module Lato
145
164
  def update_password_action
146
165
  respond_to do |format|
147
166
  if @session.user.update(params.require(:user).permit(:password, :password_confirmation))
148
- format.html { redirect_to lato.account_path, notice: I18n.t('lato.account_controller.update_password_action_notice') }
149
- format.json { render json: @session.user }
167
+ session_destroy
168
+ format.html { redirect_to lato.authentication_signin_path, notice: I18n.t('lato.account_controller.update_password_action_notice') }
169
+ format.json { render json: {} }
150
170
  else
151
171
  format.html { render :index, status: :unprocessable_entity }
152
172
  format.json { render json: @session.user.errors, status: :unprocessable_entity }
@@ -194,7 +214,61 @@ module Lato
194
214
 
195
215
  private
196
216
 
217
+ def discard_expired_security_enrollment
218
+ action = @session.security_continuation_action
219
+ session.delete(:pending_authenticator_secret) unless action == "update_authenticator_action"
220
+ unless action == "update_webauthn_action"
221
+ session.delete(:webauthn_registration_challenge)
222
+ session.delete(:webauthn_registration_options)
223
+ end
224
+ return if action == "update_web3_action"
225
+
226
+ session.delete(:web3_nonce)
227
+ session.delete(:web3_nonce_expires_at)
228
+ end
229
+
230
+ def reauthenticate_sensitive_action
231
+ if action_name == "update_user_action"
232
+ email = params.dig(:user, :email)
233
+ return if email.nil? || email.to_s.downcase.strip == @session.user.email
234
+ end
235
+ return if action_name == "update_webauthn_action" && params.dig(:user, :webauthn_command) == "cancel"
236
+
237
+ continuation = case action_name
238
+ when "update_authenticator_action"
239
+ params.dig(:user, :authenticator_command) == "confirm"
240
+ when "update_webauthn_action"
241
+ params.dig(:user, :webauthn_command) == "complete"
242
+ when "update_web3_action"
243
+ session[:web3_nonce].present?
244
+ end
245
+ return if continuation && @session.consume_security_continuation(action_name)
246
+
247
+ user = @session.user
248
+ password_valid = user.authenticate(params.dig(:user, :current_password).to_s)
249
+ factor_valid = if user.authenticator_enabled?
250
+ user.authenticator(authenticator_code: params.dig(:user, :current_authenticator_code).to_s)
251
+ elsif user.webauthn_enabled?
252
+ challenge = @session.take_security_challenge
253
+ user.webauthn_authentication({ webauthn_credential: params.dig(:user, :current_webauthn_credential) }, challenge)
254
+ else
255
+ true
256
+ end
257
+ return if password_valid && factor_valid
258
+
259
+ user.errors.add(:base, I18n.t('lato.security_confirmation_required'))
260
+ respond_to do |format|
261
+ format.html { render :index, status: :unprocessable_entity }
262
+ format.json { render json: user.errors, status: :unprocessable_entity }
263
+ end
264
+ end
265
+
266
+ def grant_security_continuation
267
+ @session.grant_security_continuation(action_name)
268
+ end
269
+
197
270
  def reset_webauthn_registration_state
271
+ @session.clear_security_continuation
198
272
  session[:webauthn_registration_challenge] = nil
199
273
  session[:webauthn_registration_options] = nil
200
274
  end
@@ -46,11 +46,35 @@ module Lato
46
46
 
47
47
  def respond_to_redirect_same_page(notice = nil)
48
48
  respond_to do |format|
49
- format.html { redirect_to request.referer, notice: notice }
49
+ # Never follow a Referer pointing to another host (open redirect).
50
+ format.html { redirect_back_or_to lato.root_path, allow_other_host: false, notice: notice }
50
51
  format.json { render json: {} }
51
52
  end
52
53
  end
53
54
 
55
+ # Web3 challenge: the signed text binds the signature to this host, a purpose and a short lifetime,
56
+ # so a signature collected by another site can not be replayed here.
57
+ WEB3_MESSAGE_LIFETIME = 5.minutes
58
+
59
+ def web3_message_generate(purpose)
60
+ session[:web3_nonce] = [
61
+ "#{request.host} wants you to #{purpose} with your Ethereum account.",
62
+ "",
63
+ "URI: #{request.base_url}",
64
+ "Nonce: #{SecureRandom.hex(16)}",
65
+ "Issued At: #{Time.current.utc.iso8601}"
66
+ ].join("\n")
67
+ session[:web3_nonce_expires_at] = WEB3_MESSAGE_LIFETIME.from_now.to_i
68
+ session[:web3_nonce]
69
+ end
70
+
71
+ # Single use: the message is removed from the session whatever the outcome.
72
+ def web3_message_consume
73
+ message = session.delete(:web3_nonce)
74
+ expires_at = session.delete(:web3_nonce_expires_at).to_i
75
+ message if message.present? && Time.current.to_i <= expires_at
76
+ end
77
+
54
78
  def respond_to_with_offline
55
79
  hide_sidebar
56
80
  respond_to do |format|
@@ -1,13 +1,13 @@
1
1
  module Lato
2
2
  class AuthenticationController < ApplicationController
3
- before_action :limit_requests, only: %i[signin_action signup_action accept_invitation_action recover_password_action update_password_action verify_email_action authenticator_action webauthn_action]
4
- before_action :not_authenticate_session, only: %i[signin signin_action signup signup_action accept_invitation accept_invitation_action]
3
+ before_action :limit_requests, only: %i[signin_action signup_action accept_invitation_action recover_password_action update_password_action verify_email_action authenticator_action webauthn_action web3_signin_action]
4
+ before_action :not_authenticate_session, only: %i[signin signin_action web3_signin web3_signin_action signup signup_action accept_invitation accept_invitation_action]
5
5
  before_action :authenticate_session, only: %i[signout signout_action]
6
6
 
7
7
  before_action :find_user, only: %i[verify_email verify_email_action]
8
8
  before_action :find_recover_password_user, only: %i[update_password update_password_action]
9
9
  before_action :find_invitation, only: %i[accept_invitation accept_invitation_action]
10
- before_action :find_authentication_user, only: %i[authentication_method authentication_method_action webauthn webauthn_action]
10
+ before_action :find_authentication_user, only: %i[authentication_method authentication_method_action authenticator authenticator_action webauthn webauthn_action]
11
11
 
12
12
  before_action :lock_signup_if_disabled, only: %i[signup signup_action]
13
13
  before_action :lock_recover_password_if_disabled, only: %i[recover_password recover_password_action update_password update_password_action]
@@ -26,12 +26,15 @@ module Lato
26
26
 
27
27
  def signin_action
28
28
  @user = Lato::User.new
29
+ signin_params = params.require(:user).permit(:email, :password)
30
+ return if account_limit_reached?(signin_params[:email])
29
31
 
30
32
  respond_to do |format|
31
- if @user.signin(params.require(:user).permit(:email, :password).merge(
33
+ if @user.signin(signin_params.merge(
32
34
  ip_address: request.remote_ip,
33
35
  user_agent: request.user_agent
34
36
  ))
37
+ account_limit_reset(signin_params[:email])
35
38
  redirect_path = determine_authentication_redirect(@user)
36
39
  if redirect_path
37
40
  # Second factor still pending: expose only where to continue, not user data.
@@ -43,6 +46,7 @@ module Lato
43
46
  format.json { render json: @user }
44
47
  end
45
48
  else
49
+ account_limit_track_failure(signin_params[:email])
46
50
  format.html { render :signin, status: :unprocessable_entity }
47
51
  format.json { render json: @user.errors, status: :unprocessable_entity }
48
52
  end
@@ -51,7 +55,7 @@ module Lato
51
55
 
52
56
  def web3_signin
53
57
  @user = Lato::User.new
54
- session[:web3_nonce] = SecureRandom.hex(32)
58
+ web3_message_generate('sign in')
55
59
  end
56
60
 
57
61
  def web3_signin_action
@@ -61,20 +65,18 @@ module Lato
61
65
  if @user.web3_signin(params.require(:user).permit(:web3_address, :web3_signed_nonce).merge(
62
66
  ip_address: request.remote_ip,
63
67
  user_agent: request.user_agent,
64
- web3_nonce: session[:web3_nonce]
68
+ web3_nonce: web3_message_consume
65
69
  ))
66
- session[:web3_nonce] = nil
67
70
  redirect_path = determine_authentication_redirect(@user)
68
71
  if redirect_path
69
72
  format.html { redirect_to redirect_path }
70
- format.json { render json: @user }
73
+ format.json { render json: { redirect: redirect_path } }
71
74
  else
72
75
  session_create(@user.id)
73
76
  format.html { redirect_to lato.root_path }
74
77
  format.json { render json: @user }
75
78
  end
76
79
  else
77
- session[:web3_nonce] = nil
78
80
  format.html { render :web3_signin, status: :unprocessable_entity }
79
81
  format.json { render json: @user.errors, status: :unprocessable_entity }
80
82
  end
@@ -232,22 +234,19 @@ module Lato
232
234
  # Authenticator
233
235
  ##
234
236
 
235
- def authenticator
236
- @user = Lato::User.find_by_id(session[:authentication_user_id])
237
- return respond_to_with_not_found unless @user
238
- end
237
+ def authenticator; end
239
238
 
240
239
  def authenticator_action
241
- @user = Lato::User.find_by_id(session[:authentication_user_id])
242
-
243
240
  respond_to do |format|
244
241
  if @user.authenticator(params.require(:user).permit(:authenticator_code))
242
+ account_limit_reset(authentication_limit_identifier)
245
243
  clear_authentication_session
246
244
  session_create(@user.id)
247
245
 
248
246
  format.html { redirect_to lato.root_path }
249
247
  format.json { render json: @user }
250
248
  else
249
+ track_authentication_failure
251
250
  format.html { render :authenticator, status: :unprocessable_entity }
252
251
  format.json { render json: @user.errors, status: :unprocessable_entity }
253
252
  end
@@ -265,12 +264,14 @@ module Lato
265
264
  def webauthn_action
266
265
  respond_to do |format|
267
266
  if @user.webauthn_authentication(params.require(:user).permit(:webauthn_credential), session[:webauthn_challenge])
267
+ account_limit_reset(authentication_limit_identifier)
268
268
  clear_authentication_session
269
269
  session_create(@user.id)
270
270
 
271
271
  format.html { redirect_to lato.root_path }
272
272
  format.json { render json: @user }
273
273
  else
274
+ track_authentication_failure
274
275
  @options = @user.webauthn_authentication_options
275
276
  session[:webauthn_challenge] = @options.challenge
276
277
  format.html { render :webauthn, status: :unprocessable_entity }
@@ -305,9 +306,30 @@ module Lato
305
306
  respond_to_with_not_found unless @invitation
306
307
  end
307
308
 
309
+ # Pending second factor state lives in the session cookie: it expires and allows a few attempts only.
310
+ AUTHENTICATION_PENDING_LIFETIME = 10.minutes
311
+ AUTHENTICATION_PENDING_MAX_ATTEMPTS = 5
312
+
308
313
  def find_authentication_user
309
- @user = Lato::User.find_by_id(session[:authentication_user_id])
310
- respond_to_with_not_found unless @user
314
+ expired = session[:authentication_expires_at].to_i < Time.current.to_i
315
+ @user = Lato::User.find_by_id(session[:authentication_user_id]) unless expired
316
+ unless @user
317
+ clear_authentication_session
318
+ return respond_to_with_not_found
319
+ end
320
+
321
+ account_limit_reached?(authentication_limit_identifier)
322
+ end
323
+
324
+ def authentication_limit_identifier
325
+ "second_factor/#{@user.id}"
326
+ end
327
+
328
+ # Counted both in the session and per user in cache, so replaying an older cookie does not reset the budget.
329
+ def track_authentication_failure
330
+ account_limit_track_failure(authentication_limit_identifier)
331
+ session[:authentication_attempts] = session[:authentication_attempts].to_i + 1
332
+ clear_authentication_session if session[:authentication_attempts] >= AUTHENTICATION_PENDING_MAX_ATTEMPTS
311
333
  end
312
334
 
313
335
  def determine_authentication_redirect(user)
@@ -317,6 +339,8 @@ module Lato
317
339
  return nil unless authenticator_enabled || webauthn_enabled
318
340
 
319
341
  session[:authentication_user_id] = user.id
342
+ session[:authentication_expires_at] = AUTHENTICATION_PENDING_LIFETIME.from_now.to_i
343
+ session[:authentication_attempts] = 0
320
344
 
321
345
  if authenticator_enabled && webauthn_enabled
322
346
  lato.authentication_authentication_method_path
@@ -329,6 +353,8 @@ module Lato
329
353
 
330
354
  def clear_authentication_session
331
355
  session[:authentication_user_id] = nil
356
+ session[:authentication_expires_at] = nil
357
+ session[:authentication_attempts] = nil
332
358
  session[:authentication_method] = nil
333
359
  session[:webauthn_challenge] = nil
334
360
  end
@@ -372,35 +398,44 @@ module Lato
372
398
 
373
399
  hcaptcha_response = params["h-captcha-response"]
374
400
  if hcaptcha_response.blank?
375
- @user.errors.add(:base, "hCaptcha response is missing")
376
- respond_to do |format|
377
- format.html { render render_key, status: :unprocessable_entity }
378
- format.json { render json: @user.errors, status: :unprocessable_entity }
379
- end
380
- return false
401
+ return respond_to_hcaptcha_error(render_key, I18n.t('lato.hcaptcha_missing'))
381
402
  end
382
403
 
404
+ unless hcaptcha_verified?(hcaptcha_response)
405
+ return respond_to_hcaptcha_error(render_key, I18n.t('lato.hcaptcha_failed'))
406
+ end
407
+
408
+ true
409
+ end
410
+
411
+ # Short timeouts: a slow hCaptcha must not hold server threads. Any network or parsing error counts as failure.
412
+ def hcaptcha_verified?(hcaptcha_response)
383
413
  require 'net/http'
384
- require 'uri'
385
414
  require 'json'
415
+
386
416
  uri = URI.parse("https://hcaptcha.com/siteverify")
387
- response = Net::HTTP.post_form(uri, {
388
- "secret" => Lato.config.hcaptcha_secret,
389
- "response" => hcaptcha_response,
390
- "remoteip" => request.remote_ip
391
- })
392
- result = JSON.parse(response.body)
393
- Rails.logger.info("[hCaptcha] Verification result: #{result}")
394
- unless result["success"]
395
- @user.errors.add(:base, "hCaptcha verification failed")
396
- respond_to do |format|
397
- format.html { render render_key, status: :unprocessable_entity }
398
- format.json { render json: @user.errors, status: :unprocessable_entity }
399
- end
400
- return false
417
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 3, read_timeout: 5) do |http|
418
+ http.post(uri.path, URI.encode_www_form(
419
+ "secret" => Lato.config.hcaptcha_secret,
420
+ "response" => hcaptcha_response,
421
+ "remoteip" => request.remote_ip
422
+ ), "Content-Type" => "application/x-www-form-urlencoded")
401
423
  end
424
+ result = JSON.parse(response.body)
425
+ Rails.logger.info("[hCaptcha] Verification success: #{result['success'].inspect}")
426
+ result["success"] == true
427
+ rescue StandardError => e
428
+ Rails.logger.error("[hCaptcha] Verification error: #{e.class}")
429
+ false
430
+ end
402
431
 
403
- true
432
+ def respond_to_hcaptcha_error(render_key, message)
433
+ @user.errors.add(:base, message)
434
+ respond_to do |format|
435
+ format.html { render render_key, status: :unprocessable_entity }
436
+ format.json { render json: @user.errors, status: :unprocessable_entity }
437
+ end
438
+ false
404
439
  end
405
440
  end
406
441
  end
@@ -106,7 +106,12 @@ module Lato
106
106
  # setup classes
107
107
  options[:class] ||= []
108
108
  options[:class].push(classes)
109
- options[:class].push('is-invalid') if form.object && form.object.errors[key] && form.object.errors[key].any?
109
+ if _lato_form_errors?(form, key)
110
+ options[:class].push('is-invalid')
111
+ options[:aria] ||= {}
112
+ options[:aria][:invalid] = true
113
+ options[:aria][:describedby] = [options[:aria][:describedby], _lato_form_feedback_id(form, key)].compact.join(' ')
114
+ end
110
115
 
111
116
  # setup stimulus
112
117
  options[:data] ||= {}
@@ -115,6 +120,25 @@ module Lato
115
120
  options[:data][:lato_form_target] = 'input'
116
121
  end
117
122
 
123
+ def _lato_form_errors?(form, key)
124
+ form.object.respond_to?(:errors) && form.object.errors[key].any?
125
+ end
126
+
127
+ def _lato_form_feedback_id(form, key)
128
+ form.field_id(key, :feedback)
129
+ end
130
+
131
+ # Inline error message linked to the input through aria-describedby.
132
+ def _lato_form_input_feedback(form, key)
133
+ return ''.html_safe unless _lato_form_errors?(form, key)
134
+
135
+ content_tag :div, form.object.errors[key].to_sentence, id: _lato_form_feedback_id(form, key), class: 'invalid-feedback'
136
+ end
137
+
138
+ def _lato_form_input_with_feedback(form, key)
139
+ yield + _lato_form_input_feedback(form, key)
140
+ end
141
+
118
142
  def lato_form_notices(options = {})
119
143
  return unless notice
120
144
 
@@ -134,6 +158,7 @@ module Lato
134
158
  options[:class] ||= []
135
159
  options[:class] += %w[alert alert-danger]
136
160
  options[:class] += %w[alert-dismissible fade show] unless options[:fixed]
161
+ options[:role] ||= 'alert'
137
162
 
138
163
  errors_list = content_tag(:ul, class: %w[mb-0 ps-3]) do
139
164
  instance.errors.collect do |error|
@@ -158,48 +183,48 @@ module Lato
158
183
  def lato_form_item_input_text(form, key, options = {})
159
184
  _lato_form_input_options(form, key, options, :keyup, 'form-control')
160
185
 
161
- form.text_field key, options
186
+ _lato_form_input_with_feedback(form, key) { form.text_field key, options }
162
187
  end
163
188
 
164
189
  def lato_form_item_input_number(form, key, options = {})
165
190
  _lato_form_input_options(form, key, options, :keyup, 'form-control')
166
191
 
167
- form.number_field key, options
192
+ _lato_form_input_with_feedback(form, key) { form.number_field key, options }
168
193
  end
169
194
 
170
195
  def lato_form_item_input_email(form, key, options = {})
171
196
  _lato_form_input_options(form, key, options, :keyup, 'form-control')
172
197
 
173
- form.email_field key, options
198
+ _lato_form_input_with_feedback(form, key) { form.email_field key, options }
174
199
  end
175
200
 
176
201
  def lato_form_item_input_password(form, key, options = {})
177
202
  _lato_form_input_options(form, key, options, :keyup, 'form-control')
178
203
 
179
- form.password_field key, options
204
+ _lato_form_input_with_feedback(form, key) { form.password_field key, options }
180
205
  end
181
206
 
182
207
  def lato_form_item_input_check(form, key, label, options = {})
183
208
  _lato_form_input_options(form, key, options, :change, 'form-check-input')
184
209
 
185
- # TO-DO: Trovare il modo di calcolare l'id dato da rails a check_input_tag e metterlo nell'attributo :for di check_label_tag
186
-
210
+ # The label is escaped unless the caller passes an html_safe string (e.g. a translation with a trusted link).
187
211
  content_tag :div, class: 'form-check' do
188
212
  concat form.check_box(key, options)
189
- concat label_tag(key, raw(label), class: 'form-check-label')
213
+ concat form.label(key, label, class: 'form-check-label', for: options[:id] || form.field_id(key))
214
+ concat _lato_form_input_feedback(form, key)
190
215
  end
191
216
  end
192
217
 
193
218
  def lato_form_item_input_select(form, key, select_options = [], options = {})
194
219
  _lato_form_input_options(form, key, options, :change, 'form-control')
195
220
 
196
- form.select key, select_options, {}, options
221
+ _lato_form_input_with_feedback(form, key) { form.select key, select_options, {}, options }
197
222
  end
198
223
 
199
224
  def lato_form_item_input_file(form, key, options = {})
200
225
  _lato_form_input_options(form, key, options, :change, 'form-control')
201
226
 
202
- form.file_field key, options
227
+ _lato_form_input_with_feedback(form, key) { form.file_field key, options }
203
228
  end
204
229
 
205
230
  def lato_form_item_input_file_dropzone(form, key, options = {})
@@ -211,25 +236,25 @@ module Lato
211
236
  def lato_form_item_input_textarea(form, key, options = {})
212
237
  _lato_form_input_options(form, key, options, :keyup, 'form-control')
213
238
 
214
- form.text_area key, options
239
+ _lato_form_input_with_feedback(form, key) { form.text_area key, options }
215
240
  end
216
241
 
217
242
  def lato_form_item_input_date(form, key, options = {})
218
243
  _lato_form_input_options(form, key, options, :change, 'form-control')
219
244
 
220
- form.date_field key, options
245
+ _lato_form_input_with_feedback(form, key) { form.date_field key, options }
221
246
  end
222
247
 
223
248
  def lato_form_item_input_datetime(form, key, options = {})
224
249
  _lato_form_input_options(form, key, options, :change, 'form-control')
225
250
 
226
- form.datetime_field key, options
251
+ _lato_form_input_with_feedback(form, key) { form.datetime_field key, options }
227
252
  end
228
253
 
229
254
  def lato_form_item_input_time(form, key, options = {})
230
255
  _lato_form_input_options(form, key, options, :change, 'form-control')
231
256
 
232
- form.time_field key, options
257
+ _lato_form_input_with_feedback(form, key) { form.time_field key, options }
233
258
  end
234
259
 
235
260
  def lato_form_item_input_color(form, key, options = {})
@@ -237,7 +262,7 @@ module Lato
237
262
  options[:class] ||= []
238
263
  options[:class].push('lato-form-item-input-color')
239
264
 
240
- form.color_field key, options
265
+ _lato_form_input_with_feedback(form, key) { form.color_field key, options }
241
266
  end
242
267
 
243
268
  def lato_form_item_input_list(form, key, partial, partial_params = {})
@@ -270,7 +295,8 @@ module Lato
270
295
  image_url ||= image_path('lato/user-150x150.jpg')
271
296
 
272
297
  content_tag :div, class: 'd-flex align-items-center' do
273
- concat content_tag :div, '', class: 'border border-2 rounded-circle me-2', style: "background-position: center; background-size: cover; background-repeat: no-repeat; background-image: url(#{image_url}); width: 30px; height: 30px;"
298
+ # An img attribute is escaped, while a url() inside inline CSS could be broken out of by a crafted image URL.
299
+ concat image_tag(image_url, alt: '', width: 30, height: 30, class: 'border border-2 rounded-circle me-2', style: 'object-fit: cover;')
274
300
  concat content_tag :small, label, class: 'text-black'
275
301
  end
276
302
  end
@@ -3,23 +3,22 @@ module Lato
3
3
  def perform(params = {})
4
4
  update_operation_percentage(0)
5
5
 
6
- # delete all active storage blobs that are not attached to any record with more than 12 hours of life
6
+ # delete all active storage blobs that are not attached to any record older than the minimum age (default 12 hours)
7
+ min_age_hours = (params[:min_age_hours] || params['min_age_hours'] || 12).to_i
7
8
  query = ActiveStorage::Blob.joins("LEFT JOIN active_storage_attachments ON active_storage_attachments.blob_id = active_storage_blobs.id")
8
9
  .where("active_storage_attachments.blob_id IS NULL")
9
- .where("active_storage_blobs.created_at < ?", 12.hours.ago)
10
+ .where("active_storage_blobs.created_at < ?", min_age_hours.hours.ago)
10
11
  totals = query.count
11
12
  runned = 0
12
13
  query.find_in_batches do |blobs|
13
14
  blobs.each(&:purge_later)
14
15
  runned += blobs.size
15
- update_operation_percentage((runned.to_f / totals * 100).round)
16
+ update_operation_percentage((runned.to_f / totals * 100).round) if totals.positive?
16
17
  end
17
18
 
18
19
  # delete all empty folders in active storage local service (if exists)
19
- if ActiveStorage::Blob.service.class.to_s == 'ActiveStorage::Service::DiskService'
20
- storage_folder = ActiveStorage::Blob.service.root
21
- return unless File.directory?(storage_folder)
22
-
20
+ storage_folder = ActiveStorage::Blob.service.root if ActiveStorage::Blob.service.class.to_s == 'ActiveStorage::Service::DiskService'
21
+ if storage_folder && File.directory?(storage_folder)
23
22
  Dir.foreach(storage_folder) do |folder|
24
23
  next if folder == '.' || folder == '..'
25
24
 
@@ -27,6 +26,8 @@ module Lato
27
26
  FileUtils.rm_rf(folder_path) if File.directory?(folder_path) && Dir.empty?(folder_path)
28
27
  end
29
28
  end
29
+
30
+ update_operation_percentage(100)
30
31
  end
31
32
  end
32
33
  end
@@ -23,7 +23,7 @@ module Lato
23
23
  # be sure that email is not already used by another user
24
24
  before_create do
25
25
  if Lato::User.find_by(email: email)
26
- errors.add(:email, 'is already used by another user')
26
+ errors.add(:email, :taken)
27
27
  throw :abort
28
28
  end
29
29
  end
@@ -73,9 +73,13 @@ module Lato
73
73
 
74
74
  def start
75
75
  begin
76
- active_job_name.constantize.perform_later(active_job_input.merge(_operation_id: id))
76
+ # Only operation jobs can be started: active_job_name may come from request params in host apps.
77
+ job_class = active_job_name.to_s.safe_constantize
78
+ raise ArgumentError, "#{active_job_name} is not a Lato::ApplicationJob" unless job_class.is_a?(Class) && job_class < Lato::ApplicationJob
79
+
80
+ job_class.perform_later(active_job_input.merge(_operation_id: id))
77
81
  rescue StandardError
78
- errors.add(:base, 'Impossibile eseguire il job')
82
+ errors.add(:base, :start_error)
79
83
  return false
80
84
  end
81
85