camaleon_cms 2.9.2 → 2.9.4
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/README.md +17 -4
- data/Rakefile +3 -16
- data/app/apps/plugins/attack/admin_controller.rb +9 -6
- data/app/apps/plugins/attack/attack_helper.rb +35 -25
- data/app/apps/plugins/authoring_post/authoring_post_helper.rb +23 -16
- data/app/apps/plugins/front_cache/admin_controller.rb +14 -10
- data/app/apps/plugins/front_cache/config/initializer.rb +1 -1
- data/app/apps/plugins/front_cache/config/locales/translation.yml +0 -7
- data/app/apps/plugins/front_cache/front_cache_helper.rb +109 -37
- data/app/apps/plugins/front_cache/views/admin/settings.html.erb +0 -5
- data/app/apps/plugins/visibility_post/config/config.json +1 -0
- data/app/apps/plugins/visibility_post/config/routes_front.txt +1 -0
- data/app/apps/plugins/visibility_post/front_controller.rb +23 -0
- data/app/apps/plugins/visibility_post/visibility_post_helper.rb +141 -70
- data/app/apps/themes/camaleon_first/main_helper.rb +18 -12
- data/app/apps/themes/default/assets/css/style.css.scss +24 -0
- data/app/apps/themes/default/assets/genericons/genericons.css +3 -8
- data/app/apps/themes/default/default_helper.rb +2 -1
- data/app/apps/themes/default/views/layouts/index.html.erb +3 -1
- data/app/apps/themes/new/assets/css/styles.css.scss +24 -0
- data/app/apps/themes/new/custom_helper.rb +5 -9
- data/app/apps/themes/new/views/layouts/_header.html.erb +3 -1
- data/app/assets/javascripts/camaleon_cms/admin/_data.js +16 -0
- data/app/assets/javascripts/camaleon_cms/admin/_post.js +5 -2
- data/app/assets/javascripts/camaleon_cms/admin/nav_menu.js +14 -3
- data/app/assets/javascripts/camaleon_cms/admin/uploader/_media_manager.js +5 -0
- data/app/controllers/camaleon_cms/admin/appearances/nav_menus_controller.rb +8 -6
- data/app/controllers/camaleon_cms/admin/appearances/themes_controller.rb +8 -5
- data/app/controllers/camaleon_cms/admin/appearances/widgets/assign_controller.rb +7 -2
- data/app/controllers/camaleon_cms/admin/appearances/widgets/main_controller.rb +2 -1
- data/app/controllers/camaleon_cms/admin/categories_controller.rb +19 -4
- data/app/controllers/camaleon_cms/admin/comments_controller.rb +0 -1
- data/app/controllers/camaleon_cms/admin/installers_controller.rb +32 -1
- data/app/controllers/camaleon_cms/admin/media_controller.rb +66 -14
- data/app/controllers/camaleon_cms/admin/plugins_controller.rb +1 -3
- data/app/controllers/camaleon_cms/admin/post_tags_controller.rb +10 -4
- data/app/controllers/camaleon_cms/admin/posts/drafts_controller.rb +40 -11
- data/app/controllers/camaleon_cms/admin/posts_controller.rb +55 -22
- data/app/controllers/camaleon_cms/admin/sessions_controller.rb +237 -44
- data/app/controllers/camaleon_cms/admin/settings/custom_fields_controller.rb +80 -13
- data/app/controllers/camaleon_cms/admin/settings/post_types_controller.rb +5 -2
- data/app/controllers/camaleon_cms/admin/settings/sites_controller.rb +26 -3
- data/app/controllers/camaleon_cms/admin/settings_controller.rb +5 -3
- data/app/controllers/camaleon_cms/admin/user_roles_controller.rb +7 -5
- data/app/controllers/camaleon_cms/admin/users_controller.rb +78 -11
- data/app/controllers/camaleon_cms/admin_controller.rb +136 -26
- data/app/controllers/camaleon_cms/camaleon_controller.rb +37 -92
- data/app/controllers/camaleon_cms/frontend_controller.rb +146 -74
- data/app/controllers/concerns/camaleon_cms/admin/custom_fields_concern.rb +20 -5
- data/app/controllers/concerns/camaleon_cms/frontend_concern.rb +46 -17
- data/app/controllers/concerns/camaleon_cms/frontend_visited_state_concern.rb +72 -0
- data/app/controllers/concerns/camaleon_cms/hook_lifecycle_concern.rb +104 -0
- data/app/controllers/concerns/camaleon_cms/request_context_concern.rb +113 -0
- data/app/controllers/concerns/camaleon_cms/runtime_admin_menu_concern.rb +237 -0
- data/app/controllers/concerns/camaleon_cms/runtime_captcha_image_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_html_content_concern.rb +143 -0
- data/app/controllers/concerns/camaleon_cms/runtime_shortcode_theme_concern.rb +246 -0
- data/app/controllers/concerns/camaleon_cms/runtime_state_concern.rb +13 -0
- data/app/controllers/concerns/camaleon_cms/runtime_uploader_concern.rb +54 -0
- data/app/controllers/concerns/camaleon_cms/session_captcha_runtime_concern.rb +19 -0
- data/app/controllers/concerns/camaleon_cms/session_runtime_concern.rb +40 -0
- data/app/decorators/camaleon_cms/application_decorator.rb +11 -8
- data/app/decorators/camaleon_cms/category_decorator.rb +3 -2
- data/app/decorators/camaleon_cms/post_comment_decorator.rb +13 -5
- data/app/decorators/camaleon_cms/post_decorator.rb +68 -50
- data/app/decorators/camaleon_cms/post_type_decorator.rb +1 -1
- data/app/decorators/camaleon_cms/site_decorator.rb +33 -25
- data/app/decorators/camaleon_cms/term_taxonomy_decorator.rb +21 -10
- data/app/decorators/camaleon_cms/theme_decorator.rb +3 -3
- data/app/decorators/camaleon_cms/user_decorator.rb +26 -5
- data/app/helpers/camaleon_cms/admin/application_helper.rb +5 -8
- data/app/helpers/camaleon_cms/admin/breadcrumb_helper.rb +1 -1
- data/app/helpers/camaleon_cms/admin/custom_fields_helper.rb +24 -23
- data/app/helpers/camaleon_cms/admin/menus_helper.rb +249 -139
- data/app/helpers/camaleon_cms/admin/post_type_helper.rb +34 -17
- data/app/helpers/camaleon_cms/camaleon_helper.rb +31 -39
- data/app/helpers/camaleon_cms/captcha_helper.rb +73 -53
- data/app/helpers/camaleon_cms/comment_helper.rb +19 -7
- data/app/helpers/camaleon_cms/content_helper.rb +13 -6
- data/app/helpers/camaleon_cms/email_helper.rb +7 -3
- data/app/helpers/camaleon_cms/frontend/application_helper.rb +6 -1
- data/app/helpers/camaleon_cms/frontend/content_select_helper.rb +34 -20
- data/app/helpers/camaleon_cms/frontend/nav_menu_helper.rb +143 -66
- data/app/helpers/camaleon_cms/frontend/seo_helper.rb +39 -28
- data/app/helpers/camaleon_cms/frontend/site_helper.rb +32 -12
- data/app/helpers/camaleon_cms/hooks_helper.rb +14 -4
- data/app/helpers/camaleon_cms/html_helper.rb +58 -37
- data/app/helpers/camaleon_cms/plugins_helper.rb +23 -24
- data/app/helpers/camaleon_cms/session_helper.rb +188 -44
- data/app/helpers/camaleon_cms/short_code_helper.rb +181 -85
- data/app/helpers/camaleon_cms/site_helper.rb +34 -13
- data/app/helpers/camaleon_cms/theme_helper.rb +35 -19
- data/app/helpers/camaleon_cms/uploader_helper.rb +19 -473
- data/app/helpers/camaleon_cms/user_roles_helper.rb +5 -4
- data/app/mailers/camaleon_cms/html_mailer.rb +4 -4
- data/app/models/camaleon_cms/ability.rb +15 -7
- data/app/models/camaleon_cms/category.rb +6 -6
- data/app/models/camaleon_cms/custom_field.rb +15 -6
- data/app/models/camaleon_cms/custom_field_group.rb +39 -22
- data/app/models/camaleon_cms/custom_fields_relationship.rb +140 -2
- data/app/models/camaleon_cms/media.rb +8 -2
- data/app/models/camaleon_cms/meta.rb +2 -2
- data/app/models/camaleon_cms/nav_menu.rb +5 -3
- data/app/models/camaleon_cms/nav_menu_item.rb +16 -8
- data/app/models/camaleon_cms/plugin.rb +3 -4
- data/app/models/camaleon_cms/post.rb +137 -27
- data/app/models/camaleon_cms/post_comment.rb +10 -6
- data/app/models/camaleon_cms/post_default.rb +42 -12
- data/app/models/camaleon_cms/post_relationship.rb +6 -4
- data/app/models/camaleon_cms/post_tag.rb +4 -5
- data/app/models/camaleon_cms/post_type.rb +21 -13
- data/app/models/camaleon_cms/site.rb +39 -29
- data/app/models/camaleon_cms/term_relationship.rb +10 -7
- data/app/models/camaleon_cms/term_taxonomy.rb +58 -4
- data/app/models/camaleon_cms/theme.rb +2 -4
- data/app/models/camaleon_cms/user.rb +17 -5
- data/app/models/camaleon_cms/user_role.rb +81 -6
- data/app/models/camaleon_cms/widget/assigned.rb +11 -6
- data/app/models/camaleon_cms/widget/main.rb +12 -8
- data/app/models/camaleon_cms/widget/sidebar.rb +3 -8
- data/app/models/camaleon_record.rb +89 -5
- data/app/models/concerns/camaleon_cms/categories_tags_for_posts.rb +36 -42
- data/app/models/concerns/camaleon_cms/common_relationships.rb +18 -5
- data/app/models/concerns/camaleon_cms/content_shortcode_gate.rb +73 -0
- data/app/models/concerns/camaleon_cms/custom_fields_read.rb +111 -54
- data/app/models/concerns/camaleon_cms/metas.rb +31 -6
- data/app/models/concerns/camaleon_cms/normalize_attrs.rb +2 -8
- data/app/models/concerns/camaleon_cms/site_default_settings.rb +120 -16
- data/app/models/concerns/camaleon_cms/user_methods.rb +44 -19
- data/app/models/current_request.rb +8 -1
- data/app/uploaders/camaleon_cms_aws_uploader.rb +31 -10
- data/app/uploaders/camaleon_cms_local_uploader.rb +76 -19
- data/app/uploaders/camaleon_cms_uploader.rb +23 -10
- data/app/validators/camaleon_cms/post_uniq_validator.rb +5 -4
- data/app/validators/camaleon_cms/uniq_validator.rb +1 -1
- data/app/validators/camaleon_cms/user_url_validator.rb +215 -36
- data/app/views/camaleon_cms/admin/categories/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/installers/form.html.erb +5 -0
- data/app/views/camaleon_cms/admin/installers/welcome.html.erb +30 -6
- data/app/views/camaleon_cms/admin/plugins/_plugins_list.html.erb +2 -2
- data/app/views/camaleon_cms/admin/post_tags/index.html.erb +2 -2
- data/app/views/camaleon_cms/admin/posts/_sidebar.html.erb +1 -1
- data/app/views/camaleon_cms/admin/posts/form.html.erb +3 -2
- data/app/views/camaleon_cms/admin/posts/index.html.erb +5 -5
- data/app/views/camaleon_cms/admin/sessions/back_to_parent.html.erb +39 -0
- data/app/views/camaleon_cms/admin/sessions/logout_confirm.html.erb +27 -0
- data/app/views/camaleon_cms/admin/settings/_email_settings.html.erb +4 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_get_items.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render.html.erb +19 -4
- data/app/views/camaleon_cms/admin/settings/custom_fields/_render_category_simple.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/custom_fields/form.html.erb +4 -3
- data/app/views/camaleon_cms/admin/settings/shortcodes.html.erb +4 -4
- data/app/views/camaleon_cms/admin/settings/site.html.erb +1 -1
- data/app/views/camaleon_cms/admin/settings/sites/form.html.erb +1 -1
- data/app/views/camaleon_cms/admin/user_roles/form.html.erb +24 -3
- data/app/views/camaleon_cms/admin/users/form.html.erb +8 -4
- data/app/views/camaleon_cms/admin/users/index.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/partials/_render_custom_field.html.erb +8 -2
- data/app/views/camaleon_cms/default_theme/sitemap.html.erb +1 -1
- data/app/views/camaleon_cms/default_theme/sitemap.xml.builder +1 -1
- data/app/views/layouts/camaleon_cms/admin/_header.html.erb +1 -1
- data/config/initializers/action_view.rb +42 -5
- data/config/initializers/active_record_extension.rb +13 -27
- data/config/initializers/assets.rb +52 -23
- data/config/initializers/custom_initializers.rb +1 -1
- data/config/locales/camaleon_cms/admin/ar.yml +1 -0
- data/config/locales/camaleon_cms/admin/de.yml +1 -0
- data/config/locales/camaleon_cms/admin/en.yml +36 -0
- data/config/locales/camaleon_cms/admin/es.yml +1 -0
- data/config/locales/camaleon_cms/admin/fr.yml +1 -0
- data/config/locales/camaleon_cms/admin/it.yml +1 -0
- data/config/locales/camaleon_cms/admin/nl.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt-BR.yml +1 -0
- data/config/locales/camaleon_cms/admin/pt.yml +1 -0
- data/config/locales/camaleon_cms/admin/ru.yml +1 -0
- data/config/locales/camaleon_cms/admin/uk.yml +1 -0
- data/config/locales/camaleon_cms/admin/zh-CH.yml +1 -0
- data/config/routes/admin.rb +33 -15
- data/config/routes/frontend.rb +21 -15
- data/lib/camaleon_cms/assets_precompile.rb +86 -0
- data/lib/camaleon_cms/captcha_image_generation.rb +65 -0
- data/lib/camaleon_cms/content_security.rb +158 -0
- data/lib/camaleon_cms/engine.rb +45 -21
- data/lib/camaleon_cms/media_security_headers.rb +27 -0
- data/lib/camaleon_cms/setup_token.rb +69 -0
- data/lib/camaleon_cms/shortcode_registry.rb +102 -0
- data/lib/camaleon_cms/svg_content_checker.rb +123 -0
- data/lib/camaleon_cms/task_reporter.rb +14 -0
- data/lib/camaleon_cms/unsafe_markup.rb +207 -0
- data/lib/camaleon_cms/uploader_content_security.rb +248 -0
- data/lib/camaleon_cms/uploader_image_processing.rb +166 -0
- data/lib/camaleon_cms/uploader_path_security.rb +146 -0
- data/lib/camaleon_cms/uploader_pipeline.rb +350 -0
- data/lib/camaleon_cms/uploader_support.rb +86 -0
- data/lib/camaleon_cms/version.rb +1 -1
- data/lib/camaleon_cms.rb +23 -0
- data/lib/ext/hash.rb +28 -2
- data/lib/ext/string.rb +10 -5
- data/lib/ext/translator.rb +10 -2
- data/lib/generators/camaleon_cms/gem_plugin_template/app/controllers/plugins/my_plugin/admin_controller.rb +5 -1
- data/lib/generators/camaleon_cms/install_generator.rb +7 -4
- data/lib/generators/camaleon_cms/install_template/plugin_routes.rb +5 -12
- data/lib/generators/camaleon_cms/theme_generator.rb +5 -10
- data/lib/generators/camaleon_cms/theme_template/views/layouts/index.html.erb +3 -1
- data/lib/plugin_routes.rb +412 -368
- data/lib/tasks/camaleon_cms/camaleon_tasks.rake +2 -1
- data/lib/tasks/cross_site_field_groups.rake +74 -0
- data/lib/tasks/custom_fields_roles.rake +15 -13
- data/lib/tasks/orphaned_comments.rake +42 -0
- data/lib/tasks/private_upload_acls.rake +31 -0
- data/lib/tasks/site_custom_field_groups.rake +36 -0
- data/lib/tasks/unsafe_stored_content.rake +42 -0
- data/lib/tasks/unsafe_stored_uploads.rake +70 -0
- data/lib/tasks/user_field_groups.rake +27 -0
- metadata +125 -4
|
@@ -100,8 +100,12 @@ en:
|
|
|
100
100
|
custom_group_support_multiple: Support multiple groups
|
|
101
101
|
message:
|
|
102
102
|
custom_group_error: 'Error custom field group'
|
|
103
|
+
value_rejected_html: "The '%{slug}' field contains HTML that is not allowed for your role (for example scripts, event handlers or embeds). Remove it, or ask an administrator for the unfiltered-content permission."
|
|
104
|
+
value_rejected_uri: "The '%{slug}' field contains a script-capable URL (javascript:/data:), which is not allowed."
|
|
105
|
+
value_too_large: "The '%{slug}' field is too large to store; please shorten it."
|
|
103
106
|
custom_created: 'The custom field group has been created.'
|
|
104
107
|
custom_updated: 'The custom field group has been updated.'
|
|
108
|
+
invalid_placement: 'Select where to display this group. The selected target does not belong to this site.'
|
|
105
109
|
fields:
|
|
106
110
|
text_box: 'Text Box'
|
|
107
111
|
private_file: Private File
|
|
@@ -170,11 +174,19 @@ en:
|
|
|
170
174
|
subject_email: 'Reset your access'
|
|
171
175
|
send_mail_succes: 'Send email reset success.'
|
|
172
176
|
send_mail_error: 'Not found email address.'
|
|
177
|
+
password_reset_requested: 'If an account matches that email, a password reset link has been sent.'
|
|
173
178
|
invalid_caption: 'Invalid captcha'
|
|
174
179
|
email_not_validated: 'Email has not been validated.'
|
|
175
180
|
confirm_email_token_incorrect: 'URL incorrect'
|
|
176
181
|
confirm_email_token_expired: 'Confirm email URL expired'
|
|
177
182
|
confirm_email_success: 'Confirm email success!'
|
|
183
|
+
reauth_failed: 'Incorrect password. Please try again.'
|
|
184
|
+
too_many_attempts: 'Too many failed attempts. Please try again later.'
|
|
185
|
+
back_to_parent:
|
|
186
|
+
title: 'Return to your account'
|
|
187
|
+
instructions: 'You are currently working as %{impersonated}. Enter the password for your own account to return to it, or log out completely.'
|
|
188
|
+
submit: 'Return to my account'
|
|
189
|
+
logout: 'Log out completely'
|
|
178
190
|
personal_info: 'Personal info'
|
|
179
191
|
please_login: 'Please login'
|
|
180
192
|
reset_password: 'reset password'
|
|
@@ -200,6 +212,8 @@ en:
|
|
|
200
212
|
out: 'Out'
|
|
201
213
|
logout: 'Logout'
|
|
202
214
|
logout_html: 'Log <strong>Out</strong>?'
|
|
215
|
+
confirm_title: 'Log out?'
|
|
216
|
+
confirm_instructions: 'You are about to end your session. Continue?'
|
|
203
217
|
message:
|
|
204
218
|
closed: 'Session Closed'
|
|
205
219
|
media:
|
|
@@ -273,6 +287,9 @@ en:
|
|
|
273
287
|
new_site: 'New Site'
|
|
274
288
|
edit_site: 'Edit Site'
|
|
275
289
|
update: 'Update'
|
|
290
|
+
shortcodes:
|
|
291
|
+
message:
|
|
292
|
+
rejected: "This content contains a shortcode that your role is not allowed to publish. Remove the shortcode, or ask an administrator for the shortcode-content permission."
|
|
276
293
|
post:
|
|
277
294
|
add_tag: 'Add a Tag'
|
|
278
295
|
clone: 'Clone'
|
|
@@ -281,6 +298,8 @@ en:
|
|
|
281
298
|
label_required: 'Required Label'
|
|
282
299
|
placeholder_required: 'Required Placeholder'
|
|
283
300
|
message:
|
|
301
|
+
content_rejected: 'contains HTML that is not allowed for your role (for example scripts, event handlers or embeds). Remove it, or ask an administrator for the unfiltered-content permission.'
|
|
302
|
+
content_too_large: 'is too large to store; please shorten it.'
|
|
284
303
|
created: 'The %{post_type} has been created.'
|
|
285
304
|
updated: 'The %{post_type} has been updated.'
|
|
286
305
|
deleted: 'The %{post_type} has been removed.'
|
|
@@ -487,10 +506,24 @@ en:
|
|
|
487
506
|
message:
|
|
488
507
|
error: 'Not found site'
|
|
489
508
|
created: 'The site has been created.'
|
|
509
|
+
created_with_admin: 'Site created. New administrator password: %{password}. Change it after signing in.'
|
|
490
510
|
updated: 'The site has been updated.'
|
|
491
511
|
deleted: 'Correct Deleted'
|
|
492
512
|
enter_key_domain: 'Enter a key or a domain'
|
|
493
513
|
enter_key_domain_empty: 'Enter a key (site2) or a domain (my_domain.com). Key will be the name of your subdomain.'
|
|
514
|
+
installer:
|
|
515
|
+
setup_token: 'Setup token'
|
|
516
|
+
setup_token_hint: 'Required for remote installations; leave blank when installing locally. Printed in the server log and saved to tmp/camaleon_setup_token on first run.'
|
|
517
|
+
invalid_setup_token: 'A valid setup token is required to install. See the server log or tmp/camaleon_setup_token.'
|
|
518
|
+
welcome_title: 'Your site was created successfully.'
|
|
519
|
+
visit_frontend: 'Visit Frontend:'
|
|
520
|
+
visit_admin: 'Visit Admin panel'
|
|
521
|
+
here: 'Here.'
|
|
522
|
+
user_label: 'User:'
|
|
523
|
+
password_label: 'Password:'
|
|
524
|
+
copy: 'Copy'
|
|
525
|
+
copied: 'Copied!'
|
|
526
|
+
must_change_notice: 'Save this password now — it is shown only once. You will be asked to change it when you first sign in.'
|
|
494
527
|
signin:
|
|
495
528
|
message:
|
|
496
529
|
enter_username_password: 'You must enter a username and/or password'
|
|
@@ -628,6 +661,7 @@ en:
|
|
|
628
661
|
created: 'The user has been created.'
|
|
629
662
|
updated: 'The user has been updated.'
|
|
630
663
|
deleted: 'The user has been deleted.'
|
|
664
|
+
must_change_password: 'Please choose a new password before continuing.'
|
|
631
665
|
incorrect_old_password: 'Incorrect old password'
|
|
632
666
|
rol_error: 'Not found group user.'
|
|
633
667
|
rol_created: 'The user role has been created.'
|
|
@@ -710,6 +744,8 @@ en:
|
|
|
710
744
|
max: 'Please enter a value less than or equal to {0}.'
|
|
711
745
|
min: 'Please enter a value greater than or equal to {0}.'
|
|
712
746
|
username_alphanumeric: 'Username needs to start with an alphanumeric character'
|
|
747
|
+
https_only_url: 'Only HTTPS URLs are allowed'
|
|
748
|
+
path_traversal: 'URL path contains path traversal sequences'
|
|
713
749
|
widgets:
|
|
714
750
|
create_widget: 'Create Widget'
|
|
715
751
|
create_sidebar: 'Create Sidebar'
|
|
@@ -125,6 +125,7 @@ es:
|
|
|
125
125
|
message:
|
|
126
126
|
success: 'Bienvenido!!!'
|
|
127
127
|
fail: 'Usuario o contraseña incorrectos'
|
|
128
|
+
too_many_attempts: 'Demasiados intentos fallidos. Vuelva a intentarlo más tarde.'
|
|
128
129
|
forgot_url_incorrect: 'URL incorrecta'
|
|
129
130
|
forgot_expired: 'Restablecer caducado.'
|
|
130
131
|
hello: 'Hola'
|
|
@@ -128,6 +128,7 @@ fr:
|
|
|
128
128
|
message:
|
|
129
129
|
success: 'Bienvenue !!!'
|
|
130
130
|
fail: 'Nom ou mot de passe incorrect'
|
|
131
|
+
too_many_attempts: 'Trop de tentatives échouées. Veuillez réessayer plus tard.'
|
|
131
132
|
forgot_url_incorrect: 'URL incorrecte'
|
|
132
133
|
forgot_expired: 'Réinitialisation expirée.'
|
|
133
134
|
hello: 'Bonjour'
|
|
@@ -126,6 +126,7 @@ nl:
|
|
|
126
126
|
message:
|
|
127
127
|
success: 'Welkom!!!'
|
|
128
128
|
fail: 'Gebruikersnaam of wachtwoord incorrect'
|
|
129
|
+
too_many_attempts: 'Te veel mislukte pogingen. Probeer het later opnieuw.'
|
|
129
130
|
forgot_url_incorrect: 'URL incorrect'
|
|
130
131
|
forgot_expired: 'Reset verlopen.'
|
|
131
132
|
hello: 'Hallo'
|
|
@@ -126,6 +126,7 @@ pt-BR:
|
|
|
126
126
|
message:
|
|
127
127
|
success: 'Bem-vindo!!!'
|
|
128
128
|
fail: 'Usuário ou senha incorreta'
|
|
129
|
+
too_many_attempts: 'Muitas tentativas malsucedidas. Tente novamente mais tarde.'
|
|
129
130
|
forgot_url_incorrect: 'URL incorreta'
|
|
130
131
|
forgot_expired: 'Expirou o prazo para redefinir a senha.'
|
|
131
132
|
hello: 'Olá'
|
|
@@ -124,6 +124,7 @@ pt:
|
|
|
124
124
|
message:
|
|
125
125
|
success: 'Benvindo!!!'
|
|
126
126
|
fail: 'Utilizador ou password incorreta'
|
|
127
|
+
too_many_attempts: 'Demasiadas tentativas falhadas. Tente novamente mais tarde.'
|
|
127
128
|
forgot_url_incorrect: 'Endereço incorreto'
|
|
128
129
|
forgot_expired: 'Expirou o prazo para redefinir a password.'
|
|
129
130
|
hello: 'Olá'
|
|
@@ -137,6 +137,7 @@ ru:
|
|
|
137
137
|
message:
|
|
138
138
|
success: 'Добро пожаловать!!!'
|
|
139
139
|
fail: 'Неверное имя пользователя или пароль'
|
|
140
|
+
too_many_attempts: 'Слишком много неудачных попыток. Повторите попытку позже.'
|
|
140
141
|
forgot_url_incorrect: 'Неверный URL'
|
|
141
142
|
forgot_expired: 'Сброс истек.'
|
|
142
143
|
hello: 'Привет'
|
|
@@ -134,6 +134,7 @@ uk:
|
|
|
134
134
|
message:
|
|
135
135
|
success: 'Ласкаво просимо !!!'
|
|
136
136
|
fail: "Неправильне ім'я користувача або пароль"
|
|
137
|
+
too_many_attempts: 'Забагато невдалих спроб. Спробуйте пізніше.'
|
|
137
138
|
forgot_url_incorrect: 'Неправильний URL'
|
|
138
139
|
forgot_expired: 'Скидання закінчилось.'
|
|
139
140
|
hello: 'Привіт'
|
data/config/routes/admin.rb
CHANGED
|
@@ -8,17 +8,22 @@ Rails.application.routes.draw do
|
|
|
8
8
|
get 'search'
|
|
9
9
|
get 'login' => 'sessions#login'
|
|
10
10
|
post 'login' => 'sessions#login_post'
|
|
11
|
-
|
|
11
|
+
# Security (audit M6): logout changes state, so it acts only over POST. The GET renders a
|
|
12
|
+
# confirmation page instead of 404ing -- frontend themes across the ecosystem link this
|
|
13
|
+
# path -- and the impersonation flow keeps its redirect (see SessionsController#logout).
|
|
14
|
+
match 'logout' => 'sessions#logout', via: %i[get post]
|
|
15
|
+
match 'back_to_parent' => 'sessions#back_to_parent', via: %i[get post]
|
|
12
16
|
match 'forgot' => 'sessions#forgot', via: %i[get post patch]
|
|
13
|
-
match 'confirm_email' => 'sessions#confirm_email', via: %i[get post
|
|
17
|
+
match 'confirm_email' => 'sessions#confirm_email', via: %i[get post patch]
|
|
14
18
|
match 'register' => 'sessions#register', via: %i[get post patch]
|
|
15
19
|
match 'api/:method', action: :api, via: %i[get post], as: :api
|
|
16
20
|
|
|
17
21
|
resources :post_type, as: :post_type do
|
|
18
22
|
resources :posts, controller: 'posts' do
|
|
19
23
|
# resources :comments
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
# Security (audit M6): state-changing member actions must not ride GET links.
|
|
25
|
+
patch :trash
|
|
26
|
+
patch :restore
|
|
22
27
|
collection do
|
|
23
28
|
match 'ajax', via: %i[get post patch]
|
|
24
29
|
end
|
|
@@ -41,7 +46,8 @@ Rails.application.routes.draw do
|
|
|
41
46
|
match 'profile/edit' => 'users#profile_edit', via: %i[get post patch]
|
|
42
47
|
resources :users, controller: 'users' do
|
|
43
48
|
patch 'updated_ajax'
|
|
44
|
-
|
|
49
|
+
# Security (audit M6): forces a session switch -- the highest-value CSRF target here.
|
|
50
|
+
post :impersonate, on: :member
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
resources :user_roles, controller: 'user_roles' do
|
|
@@ -53,11 +59,11 @@ Rails.application.routes.draw do
|
|
|
53
59
|
collection do
|
|
54
60
|
post 'get_items/:key', action: :get_items, as: :get_items
|
|
55
61
|
post 'reorder'
|
|
56
|
-
|
|
62
|
+
match 'list', via: %i[get post]
|
|
57
63
|
end
|
|
58
64
|
end
|
|
59
65
|
get 'site'
|
|
60
|
-
|
|
66
|
+
post 'test_email' # Security (audit M6): sends mail; must not be a CSRF-able GET
|
|
61
67
|
get 'theme'
|
|
62
68
|
post 'save_theme'
|
|
63
69
|
get 'languages'
|
|
@@ -73,25 +79,33 @@ Rails.application.routes.draw do
|
|
|
73
79
|
resources :comments, controller: 'comments' do
|
|
74
80
|
get 'answer'
|
|
75
81
|
post 'save_answer'
|
|
76
|
-
|
|
82
|
+
patch 'toggle_status' # Security (audit M6): flips moderation state
|
|
77
83
|
end
|
|
78
84
|
end
|
|
79
85
|
|
|
80
86
|
namespace :appearances do
|
|
81
|
-
|
|
87
|
+
# Security (audit M6): this legacy widgets surface predates the widgets/{main,sidebar,
|
|
88
|
+
# assign} controllers (its target controller is long gone) but still declared delete
|
|
89
|
+
# endpoints reachable over CSRF-exempt GET. The non-GET verbs keep the paths and helpers
|
|
90
|
+
# routable for any external binding.
|
|
91
|
+
delete 'widgets'
|
|
82
92
|
match 'widgets_save', via: %i[post patch]
|
|
83
|
-
|
|
93
|
+
patch 'widget_delete'
|
|
84
94
|
get 'render_form'
|
|
85
95
|
|
|
86
96
|
resources :themes, only: [:index] do
|
|
87
97
|
collection do
|
|
88
98
|
get 'preview'
|
|
89
99
|
# match "settings", via: [:get, :post, :patch]
|
|
90
|
-
|
|
100
|
+
# Security (audit M6): load_data clears and re-imports post types, nav menus and sliders
|
|
101
|
+
# -- a state change that must not ride a CSRF-exempt GET link.
|
|
102
|
+
match 'load_data', via: %i[post patch]
|
|
91
103
|
end
|
|
92
104
|
end
|
|
93
105
|
resources :nav_menus, except: :show do
|
|
94
|
-
|
|
106
|
+
# Security (audit M6): destroys a menu item -- must not ride a CSRF-exempt GET. The
|
|
107
|
+
# admin JS sends a token-bearing DELETE (nav_menu.js).
|
|
108
|
+
delete 'item_delete/:id' => :delete_menu_item, as: :delete_menu_item
|
|
95
109
|
get 'custom_settings/:id' => :custom_settings, as: :custom_settings
|
|
96
110
|
post 'save_custom_settings/:id' => :save_custom_settings, as: :save_custom_settings
|
|
97
111
|
get 'edit_menu_item/:id' => :edit_menu_item, as: :edit_menu_item
|
|
@@ -114,8 +128,10 @@ Rails.application.routes.draw do
|
|
|
114
128
|
end
|
|
115
129
|
|
|
116
130
|
resources :plugins, only: %i[index destroy] do
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
# Security (audit M6): activating/deactivating and upgrading a plugin run install and
|
|
132
|
+
# upgrade hooks -- state changes, not reads.
|
|
133
|
+
patch 'toggle', on: :collection
|
|
134
|
+
post 'upgrade'
|
|
119
135
|
end
|
|
120
136
|
|
|
121
137
|
# installer
|
|
@@ -125,7 +141,9 @@ Rails.application.routes.draw do
|
|
|
125
141
|
end
|
|
126
142
|
|
|
127
143
|
resources :media, only: [:index] do
|
|
128
|
-
|
|
144
|
+
# Security (audit M6): crop writes a cropped upload and can rewrite a user avatar
|
|
145
|
+
# (saved_avatar); via: :all admitted every verb, the CSRF-exempt GET/HEAD included.
|
|
146
|
+
post 'crop', on: :collection
|
|
129
147
|
get 'ajax', on: :collection
|
|
130
148
|
get 'download_private_file', on: :collection
|
|
131
149
|
post 'upload', on: :collection
|
data/config/routes/frontend.rb
CHANGED
|
@@ -29,11 +29,12 @@ Rails.application.routes.draw do
|
|
|
29
29
|
multilingual_segment = PluginRoutes.all_translations('routes.category', default: 'category')
|
|
30
30
|
request.params[:category_id].match(/[0-9]+/) && request.params[:label_cat].in?(multilingual_segment)
|
|
31
31
|
}
|
|
32
|
-
get ':post_type_title/:label_cat/:category_id-:title/:slug' => :post, as: 'post_of_category_post_type',
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
get ':post_type_title/:label_cat/:category_id-:title/:slug' => :post, as: 'post_of_category_post_type',
|
|
33
|
+
constraints: lambda { |request|
|
|
34
|
+
multilingual_segment = PluginRoutes.all_translations('routes.category', default: 'category')
|
|
35
|
+
request.params[:post_type_title].match(/^(?!(#{PluginRoutes.all_locales})$)[\w.-]+$/) &&
|
|
36
|
+
request.params[:category_id].match(/[0-9]+/) && request.params[:label_cat].in?(multilingual_segment)
|
|
37
|
+
}
|
|
37
38
|
get ':label/:post_tag_id-:title' => :post_tag, as: 'post_tag', constraints: lambda { |request|
|
|
38
39
|
multilingual_segment = PluginRoutes.all_translations('routes.tag', default: 'tag')
|
|
39
40
|
request.params[:post_tag_id].match(/[0-9]+/) && request.params[:label].in?(multilingual_segment)
|
|
@@ -44,12 +45,14 @@ Rails.application.routes.draw do
|
|
|
44
45
|
}
|
|
45
46
|
get ':label/:post_tag_slug' => :post_tag, as: 'post_tag_simple', constraints: lambda { |request|
|
|
46
47
|
multilingual_segment = PluginRoutes.all_translations('routes.tag', default: 'tag')
|
|
47
|
-
request.params[:post_tag_slug].match(%r{[a-zA-Z0-9_=\s\-/]+}) &&
|
|
48
|
-
|
|
49
|
-
get ':label/:user_id-:user_name' => :profile, as: :profile, defaults: { label: 'profile' }, constraints: lambda { |request|
|
|
50
|
-
multilingual_segment = PluginRoutes.all_translations('routes.profile', default: 'profile') | %w[profile]
|
|
51
|
-
request.params[:user_id].match(/[0-9]+/) && request.params[:label].in?(multilingual_segment)
|
|
48
|
+
request.params[:post_tag_slug].match(%r{[a-zA-Z0-9_=\s\-/]+}) &&
|
|
49
|
+
request.params[:label].in?(multilingual_segment)
|
|
52
50
|
}
|
|
51
|
+
get ':label/:user_id-:user_name' => :profile, as: :profile, defaults: { label: 'profile' },
|
|
52
|
+
constraints: lambda { |request|
|
|
53
|
+
multilingual_segment = PluginRoutes.all_translations('routes.profile', default: 'profile') | %w[profile]
|
|
54
|
+
request.params[:user_id].match(/[0-9]+/) && request.params[:label].in?(multilingual_segment)
|
|
55
|
+
}
|
|
53
56
|
get ':label' => :search, as: :search, defaults: { label: 'search' }, constraints: lambda { |request|
|
|
54
57
|
multilingual_segment = PluginRoutes.all_translations('routes.search', default: 'search') | %w[search]
|
|
55
58
|
request.params[:label].in?(multilingual_segment)
|
|
@@ -71,11 +74,14 @@ Rails.application.routes.draw do
|
|
|
71
74
|
# post types
|
|
72
75
|
controller 'camaleon_cms/frontend' do
|
|
73
76
|
PluginRoutes.get_sites.each do |s|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
# get_sites eager-loads :post_types, so map in memory instead of a per-site pluck query.
|
|
78
|
+
s.post_types.map { |pt| [pt.slug, pt.id] }.each do |pt_slug, pt_id|
|
|
79
|
+
get ':post_type_slug' => :post_type, as: "post_type_#{pt_id}", post_type_id: pt_id,
|
|
80
|
+
constraints: lambda { |request|
|
|
81
|
+
multilingual_segment =
|
|
82
|
+
PluginRoutes.all_translations("routes.post_types.#{pt_slug}", default: pt_slug)
|
|
83
|
+
request.params[:post_type_slug].in?(multilingual_segment)
|
|
84
|
+
}
|
|
79
85
|
end
|
|
80
86
|
end
|
|
81
87
|
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module CamaleonCms
|
|
2
|
+
# Helper used by config/initializers/assets.rb to declare plugin/theme assets as
|
|
3
|
+
# precompiled.
|
|
4
|
+
#
|
|
5
|
+
# Plugin and theme assets can live in three different layouts:
|
|
6
|
+
# * host app: <app>/app/apps/{plugins,themes}/<key>/assets/...
|
|
7
|
+
# * the camaleon_cms gem: <gem>/app/apps/{plugins,themes}/<key>/assets/...
|
|
8
|
+
# * separate "gem_mode" gems: <gem>/app/assets/<type>/{plugins,themes}/<key>/...
|
|
9
|
+
# (e.g. cama_contact_form ships app/assets/javascripts/plugins/cama_contact_form/...)
|
|
10
|
+
#
|
|
11
|
+
# Rails' default Sprockets precompile rules only cover the app/assets top level, so none
|
|
12
|
+
# of these are declared as precompiled. On modern Sprockets (>= 4) with
|
|
13
|
+
# `config.assets.unknown_asset_fallback = false`, requesting such an asset (e.g. via
|
|
14
|
+
# `javascript_include_tag "plugins/visibility_post/assets/js/form.js"`) raises
|
|
15
|
+
# `Sprockets::Rails::Helper::AssetNotPrecompiledError` unless it is declared.
|
|
16
|
+
#
|
|
17
|
+
# We enumerate the concrete asset files from the *real* Sprockets load paths and declare
|
|
18
|
+
# their exact logical paths as strings. Strings are the canonical, Sprockets-version
|
|
19
|
+
# agnostic form (work on both 3.x and 4.x). Scanning `config.assets.paths` (instead of a
|
|
20
|
+
# couple of hardcoded directories) ensures host, gem-bundled and separately gem-packaged
|
|
21
|
+
# (gem_mode) plugins/themes are all covered, matching the reach of the old precompile
|
|
22
|
+
# `proc` this replaced. Because the declaration is computed from the on-disk files, every
|
|
23
|
+
# installed plugin/theme is declared regardless of whether it is currently active, so
|
|
24
|
+
# switching themes / enabling-disabling plugins (DB-only operations) never requires a
|
|
25
|
+
# recompile.
|
|
26
|
+
module AssetsPrecompile
|
|
27
|
+
PRECOMPILE_EXTENSIONS = /\.(css|js|svg|ttf|woff|woff2|eot|otf|swf|pdf|png|jpe?g|gif|ico|mp3|mp4|webm|ogg|webp)\z/i
|
|
28
|
+
PRECOMPILE_CONTENT_TYPES = %r{(javascript|image/|audio|video|font)}
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# path: logical asset path relative to an asset load path, e.g.
|
|
33
|
+
# "plugins/visibility_post/assets/js/form.js", "themes/my_theme/assets/css/app.css"
|
|
34
|
+
# or "plugins/cama_contact_form/admin_editor.js" (gem_mode layout).
|
|
35
|
+
# Returns true when the asset must be precompiled.
|
|
36
|
+
def match?(path)
|
|
37
|
+
dirname = File.dirname(path)
|
|
38
|
+
return false unless dirname.start_with?('plugins/', 'themes/')
|
|
39
|
+
|
|
40
|
+
name = File.basename(path)
|
|
41
|
+
return false if name.start_with?('_') || path.include?('/views/')
|
|
42
|
+
|
|
43
|
+
PRECOMPILE_EXTENSIONS.match?(path) || content_type_for(name).scan(PRECOMPILE_CONTENT_TYPES).any?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def content_type_for(name)
|
|
47
|
+
MIME::Types.type_for(name).first.content_type
|
|
48
|
+
rescue StandardError
|
|
49
|
+
''
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Enumerate the logical paths of every plugin/theme asset found under the given asset
|
|
53
|
+
# roots. For each root, only the `plugins/` and `themes/` subtrees are scanned, and each
|
|
54
|
+
# file is converted to its logical path (relative to the root) and filtered by `match?`.
|
|
55
|
+
def logical_paths(asset_roots = default_asset_roots)
|
|
56
|
+
asset_roots.flat_map do |root|
|
|
57
|
+
root = root.to_s
|
|
58
|
+
next [] if root.empty? || !Dir.exist?(root)
|
|
59
|
+
|
|
60
|
+
prefix = "#{root.chomp('/')}/"
|
|
61
|
+
Dir.glob(File.join(root, '{plugins,themes}', '**', '*')).filter_map do |file|
|
|
62
|
+
next unless File.file?(file)
|
|
63
|
+
|
|
64
|
+
logical = file.delete_prefix(prefix)
|
|
65
|
+
logical if match?(logical)
|
|
66
|
+
end
|
|
67
|
+
end.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Asset roots to scan: the configured Sprockets load paths (which cover host,
|
|
71
|
+
# gem-bundled and gem_mode plugin/theme assets), plus the host and camaleon_cms gem
|
|
72
|
+
# `app/apps` folders as a fallback in case the asset paths are not yet populated when
|
|
73
|
+
# this is called during initialization.
|
|
74
|
+
def default_asset_roots
|
|
75
|
+
roots = []
|
|
76
|
+
if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
77
|
+
roots.concat(Array(Rails.application.config.assets.paths).map(&:to_s))
|
|
78
|
+
end
|
|
79
|
+
roots << Rails.root.join('app/apps').to_s if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
80
|
+
# rubocop:disable Style/GlobalVars
|
|
81
|
+
roots << File.join($camaleon_engine_dir, 'app', 'apps') if defined?($camaleon_engine_dir) && $camaleon_engine_dir
|
|
82
|
+
# rubocop:enable Style/GlobalVars
|
|
83
|
+
roots.uniq
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CamaleonCms
|
|
4
|
+
# Captcha challenge/image generation shared by the two entry points that expose it:
|
|
5
|
+
# CamaleonCms::RuntimeCaptchaImageConcern (the controller stack serving GET /captcha) and
|
|
6
|
+
# CamaleonCms::CaptchaHelper (views and the runtime helper surface). Both include this
|
|
7
|
+
# module, so a captcha hardening fix cannot land in one entry point and silently miss the
|
|
8
|
+
# copy that actually runs. Parity between the entry points is guarded by
|
|
9
|
+
# spec/lib/camaleon_cms/captcha_implementation_parity_spec.rb.
|
|
10
|
+
module CaptchaImageGeneration
|
|
11
|
+
# The requested length is clamped so an attacker-supplied ?len= can neither make the
|
|
12
|
+
# worker build an arbitrarily large challenge string for ImageMagick to draw (H4
|
|
13
|
+
# resource exhaustion) nor shrink the answer space to a brute-forceable size (H3).
|
|
14
|
+
CAPTCHA_MIN_LENGTH = 4
|
|
15
|
+
CAPTCHA_MAX_LENGTH = 8
|
|
16
|
+
CAPTCHA_DEFAULT_LENGTH = 5
|
|
17
|
+
|
|
18
|
+
# build a captcha image
|
|
19
|
+
# @param [Integer, nil] len Number of characters to include in captcha (default: 5)
|
|
20
|
+
# @return [MiniMagick::Image]
|
|
21
|
+
def cama_captcha_build(len = CAPTCHA_DEFAULT_LENGTH)
|
|
22
|
+
img = MiniMagick::Image.open(resolve_captcha_file("captcha_#{rand(12)}.jpg"))
|
|
23
|
+
text = cama_rand_str(cama_captcha_length(len))
|
|
24
|
+
# Single active challenge: replace, never accumulate. An append-only list let any previously
|
|
25
|
+
# issued answer keep verifying, which (with a shrinkable length) made the captcha bypassable (H3).
|
|
26
|
+
session[:cama_captcha] = [text]
|
|
27
|
+
img.combine_options do |c|
|
|
28
|
+
c.gravity('Center')
|
|
29
|
+
c.fill('#FFFFFF')
|
|
30
|
+
c.draw("text 0,5 #{text}")
|
|
31
|
+
c.font(resolve_captcha_file('bumpyroad.ttf'))
|
|
32
|
+
c.pointsize('30')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# Clamp a requested captcha length into the safe range; a non-numeric or absent value
|
|
39
|
+
# falls back to the default.
|
|
40
|
+
def cama_captcha_length(len)
|
|
41
|
+
return CAPTCHA_DEFAULT_LENGTH unless len.to_s.match?(/\A\d+\z/)
|
|
42
|
+
|
|
43
|
+
len.to_i.clamp(CAPTCHA_MIN_LENGTH, CAPTCHA_MAX_LENGTH)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resolve_captcha_file(filename)
|
|
47
|
+
base_dir = $camaleon_engine_dir.presence || Rails.root.to_s # rubocop:disable Style/GlobalVars
|
|
48
|
+
File.join(base_dir, 'lib', 'captcha', filename)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# generate random string for captcha
|
|
52
|
+
# len: length of characters, default 6
|
|
53
|
+
def cama_rand_str(len = 6)
|
|
54
|
+
alphabets = [('A'..'Z').to_a].flatten!
|
|
55
|
+
alphanumerics = [('A'..'Z').to_a, ('1'..'9').to_a].flatten!
|
|
56
|
+
# Security (audit Low): draw from a CSPRNG. Kernel#rand is a Mersenne-Twister PRNG whose future
|
|
57
|
+
# output is predictable from observed samples, so a seen challenge weakened the next one.
|
|
58
|
+
str = alphabets[SecureRandom.random_number(alphabets.size)]
|
|
59
|
+
(len.to_i - 1).times do
|
|
60
|
+
str << alphanumerics[SecureRandom.random_number(alphanumerics.size)]
|
|
61
|
+
end
|
|
62
|
+
str
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|