diff --git a/css/cleantalk-admin-settings-page.min.css b/css/cleantalk-admin-settings-page.min.css index ce92c121a..f9483cb7a 100644 --- a/css/cleantalk-admin-settings-page.min.css +++ b/css/cleantalk-admin-settings-page.min.css @@ -1 +1 @@ -.apbct_settings-page{padding-right:10px}.apbct_settings-field_content,.cleantalk_link-auto,.cleantalk_link-manual,.ct-warning-test-failed,.ct_rate_block,.ct_settings_banner,i.animate-spin{display:inline-block}#apbctTopWarning{margin-bottom:5px}#apbctTopWarning h3{margin:10px 0 5px 0}#apbctTopWarning h4{margin:10px}#apbctTopWarning h4 span{margin-top:5px}.apbct_settings-subtitle{position:relative;top:-15px;margin:0}.apbct_settings-field_wrapper{margin:15px 0}.apbct_settings-field_wrapper--sub{margin-left:30px!important}.apbct_settings-field_wrapper--sub-sub{margin-left:60px!important}.apbct_settings__label{margin-right:10px;font-size:17px;vertical-align:text-bottom}.apbct_settings-field_content{display:inline-block}.apbct_settings-field_content--radio,.apbct_settings-field_wrapper>.apbct_settings-field_description{width:70%;padding-bottom:10px}.apbct_settings-field_title--select,.apbct_settings-field_title--textarea{margin:0;width:210px;padding-right:10px;padding-bottom:10px;font-size:14px;vertical-align:top}.apbct_settings-field_title--with-help{width:auto;white-space:nowrap}.apbct_settings-field_title--radio{display:inline-block;margin:0;width:210px;padding-right:10px;font-size:14px;vertical-align:top}.apbct_input_text{min-width:255px;width:400px}.apbct_settings-long_description---show:hover{color:#aaa;cursor:pointer}.apbct_setting_textarea{min-width:300px}.cleantalk_link{text-decoration:none;font-size:13px;line-height:26px;margin:10px 0 0 0;padding:0 10px 1px;cursor:pointer;border-width:1px;border-style:solid;-webkit-appearance:none;white-space:nowrap;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.cleantalk_link-auto{background:#ccc;border-color:#999;-webkit-box-shadow:inset 0 1px 0 rgba(200,200,200,.5),0 1px 0 rgba(0,0,0,.15);box-shadow:inset 0 1px 0 rgba(200,200,200,.5),0 1px 0 rgba(0,0,0,.15);color:#000;display:inline-block;height:28px;-webkit-border-radius:2px;border-radius:2px}.cleantalk_link-auto:hover{color:#fff}.cleantalk_link-auto.cleantalk_link_text_center{text-align:center}.cleantalk_link-manual{background:#2ea2cc;border-color:#0074a2;-webkit-box-shadow:inset 0 1px 0 rgba(120,200,230,.5),0 1px 0 rgba(0,0,0,.15);box-shadow:inset 0 1px 0 rgba(120,200,230,.5),0 1px 0 rgba(0,0,0,.15);color:#fff;display:inline-block;-webkit-border-radius:3px;border-radius:3px;text-align:center}.cleantalk_link-manual:hover{color:#000}.cleantalk_link[disabled=disabled]{background:#5d9db5;color:#000}.apbct_status_icon{vertical-align:text-bottom;margin:0 5px 0 8px}.apbct-icon-upload-cloud{margin-left:3px}a.ct_support_link{color:#666;margin-right:.5em;font-size:10pt;font-weight:400}.ct-warning-test-failed{display:inline-block;position:relative;padding:5px;margin:4px;border:3px solid #f03232;border-radius:5px;background-color:#ffc8c8}.ct_settings_banner{text-align:right;display:inline-block;width:100%;margin:1em 0;vertical-align:top}#ct_translate_plugin{margin-left:0}.ct_rate_block{display:inline-block;width:370px;margin-right:3em;padding:.8em .8em 15px .8em;text-align:center;border:1px dashed #666}#ct_translate_plugin .apbct_button_rate{margin-bottom:10px}.apbct_long_desc{position:absolute;background:#5a5a5a;min-width:80px;min-height:80px;max-width:500px;padding:10px;color:#fff;z-index:10}.apbct_long_desc a{color:#78c8e6}i.animate-spin{-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;-webkit-animation:spin 2s infinite linear;animation:spin 2s infinite linear;display:inline-block;margin:0 auto;font-size:25px;line-height:20px;color:#78c8e6;margin:25px}@keyframes spin{to{transform:rotate(359deg)}}.apbct_long_desc__cancel{position:absolute;top:5px;right:5px;color:rgba(255,255,255,.5)}.apbct_long_desc__cancel:hover{color:#fff}.apbct_long_desc__angle{position:absolute;top:5px;left:-17px;width:10px;height:10px;background:#5a5a5a;-webkit-transform:rotate(135deg);-ms-transform:rotate(135deg);transform:rotate(135deg);-webkit-transform-origin:100% 100%;-ms-transform-origin:100% 100%;transform-origin:100% 100%}.apbct_long_desc__title{color:#78c8e6;margin:0}.--hide{display:none}.apbct_preloader_button{height:15px;margin-left:5px;vertical-align:text-top;display:none}.--invisible{visibility:hidden}.apbct_preloader{height:1em;width:1em;margin-left:.5em;display:inline-block}.key_changed_sync{font-size:1.5em;line-height:2em;vertical-align:middle}.key_changed_success{font-size:1.5em;line-height:2em;vertical-align:middle}.--upper-case{text-transform:uppercase}#cleantalk-modal-content{text-align:center}#cleantalk-modal-content>*{display:block;width:100%}.cleantalk-confirm-modal_header{font-size:15px;font-weight:500}.cleantalk-confirm-modal_text-block{padding:10px 0}.cleantalk-confirm-modal_buttons-block{display:flex!important;gap:10px;justify-content:center}button.ct_support_link{border:none;background:0 0;color:#666;text-decoration:underline;cursor:pointer}.apbct-btn-as-link{padding:0;margin:0;vertical-align:baseline;color:gray;border:0 none;border-bottom:1px solid;cursor:pointer}#apbct-account-email[contenteditable=true]{padding:6px;background-color:#fff;border:1px solid #ccc}button[value=save_changes]:disabled{color:#2271b1;border-color:#2271b1;background:#f6f7f7}#apbct_settings__advanced_settings{position:relative}#apbct_settings__advanced_settings_inner{width:70%}#apbct_hidden_section_nav{position:absolute;top:0;right:0;width:200px}#apbct_hidden_section_nav ul{z-index:9999;width:100%;top:40px}.apbct_settings__position_main_save_button{text-align:right;margin-right:40px}#apbct_settings__after_advanced_settings{margin-bottom:20px;width:70%}#apbct_settings__button_section{position:static;z-index:9999;padding-bottom:40px;bottom:0}.apbct_highlighted{outline-offset:5px;outline-color:red;outline-width:3px!important;outline-style:solid!important}.apbct_notice_inner{display:flex;margin-top:10px!important}.apbct_notification__advanced_settings{margin:20px 0;font-weight:400}.apbct_settings_top_info__div{float:right;padding:0 20px 0 0;font-size:13px;position:relative}.apbct_settings_top_info__p{padding:0;margin:2px}.apbct_settings_top_info__btn{display:inline-flex;gap:10px}.apbct_hidden_section_nav_mob_btn{display:none;background-image:url(images/menu.svg);background-repeat:no-repeat;background-size:contain;background-position-y:center}.apbct_hidden_section_nav_mob_btn-close{display:none}.apbct_settings__footer{background:#c2c2c2;position:absolute;left:0;padding-bottom:30px;padding-left:21px;z-index:-1}.apbct_settings_top_info__sub_btn{display:flex;flex-wrap:wrap;gap:10px;margin:15px 0}.apbct_settings__key_line{display:flex;align-items:center;justify-content:flex-start;flex-direction:row}.apbct_settings__key_line__elements{margin-right:10px}.apbct_setting__key_line__buttons{margin:0}.apbct_setting__key_line__get_key_manual_chunk{margin:10px 0 10px 0;border:1px solid #d3d3d3;border-radius:3px;padding:10px;background:#fff}.apbct_settings__key_line__service_block{margin-left:10px}#apbct_settings__public_offer{margin-top:10px;display:flex;align-items:center}#cleantalk_apikey_wrapper{max-width:65%}.apcbt_contact_data_encoder__line{max-width:70%;display:flex;flex-wrap:wrap;flex-direction:row}.apbct_warning_red_point{display:inline-block;width:4px;height:4px;background:red;border-radius:100%;margin:0 10px 3px 3px;vertical-align:middle;position:relative}.apbct_warning_red_point::after{content:'';position:absolute;left:50%;top:50%;width:12px;height:12px;border:1.5px solid rgba(255,2,2,.65);border-radius:50%;animation:apbct_pulsate 1.2s ease-out infinite;opacity:.7;pointer-events:none;transform:translate(-50%,-50%)}#apbct_summary_and_support{display:flex;flex-wrap:wrap;width:-webkit-fill-available;border:1px solid #ddd;border-radius:3px;padding:10px;background:#ededed;margin-top:4em}#apbct_summary_and_support-left_side{width:69%;padding:0 20px 0 0}#apbct_summary_and_support-right_side{width:31%;padding:0 20px;border-left:1px solid #dcdcde}#apbct_summary_and_support-sides_wrap{display:flex;flex-direction:row;margin:10px 0 10px;width:100%}.apbct_summary_and_support-side_header{margin:.5em 0}.apbct_summary_and_support-inner_header{margin:2em 0 .5em;font-size:medium}.apbct_summary_and_support-inner_header::after{content:'';display:block;width:100%;height:1px;background:linear-gradient(90deg,#d3d3d357 0,rgba(211,211,211,.14) 10%,transparent 100%);margin-top:5px}.apbct_summary_and_support-support_buttons_wrapper{display:flex;flex-direction:column}#apbct_summary_and_support-create_user_button{display:inline-flex;flex-direction:row;align-items:center;justify-content:center}.apbct_summary_and_support-user_creation_result{background:#eee;margin-top:10px;padding:5px;border:1px solid #d3d3d3;display:none}.apbct_summary-list_of_items{display:flex;flex-direction:column}.apbct_summary_list_item{margin-bottom:2px}.apbct-green{color:green}.apbct-red{color:red}#apbct_negative_reports_table{width:100%;border-collapse:collapse;margin:10px 5px}#apbct_sending_report_div{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;flex-wrap:nowrap}#apbct_sending_report_disabled_notice{margin-right:5px}.apbct-real-user-popup-img{align-self:start;margin:0!important;height:1.1em;vertical-align:initial}@keyframes apbct_pulsate{0%{transform:translate(-50%,-50%) scale(1);opacity:.7}100%{transform:translate(-50%,-50%) scale(1.5);opacity:0}}@media (max-width:768px){.apbct_settings_top_info__btn{display:inline-grid;width:100%;gap:5px}#apbct_settings__advanced_settings_inner,#apbct_settings__after_advanced_settings{width:100%}.apbct_settings-field_content--radio,.apbct_settings-field_wrapper>.apbct_settings-field_description{width:100%}.apbct_settings_top_info__sub_btn .apbct_bottom_links--left,.apbct_settings_top_info__sub_btn .apbct_bottom_links--other{margin-left:0;margin-right:0}#apbct_hidden_section_nav ul{display:none;background-color:#dadee3bd;margin:0;padding:10px;border-radius:3px;right:0;width:auto}#apbct_hidden_section_nav{right:0;min-width:167px}.apbct_setting_radio::before{height:6px!important;width:6px!important;margin:4px!important}.apbct_setting_checkbox::before{height:15px!important;width:15px!important;margin:0 -.05rem!important}.apbct_setting_checkbox,.apbct_setting_radio{height:16px!important;width:16px!important}.apbct_hidden_section_nav_mob_btn{display:block;width:32px;height:28px;right:20px;top:20px;position:fixed}.apbct_hidden_section_nav_mob_btn-close{display:block;position:relative;float:right;width:20px;height:22px;top:-5px;right:-5px;background-image:url(images/cancel.svg);background-repeat:no-repeat;background-size:contain;background-position-y:center;text-align:center}.apbct_long_desc{left:-2%!important;max-width:95%;margin-top:10%}.apbct_long_desc__angle{display:none}.ct_rate_block{max-width:240px;margin-right:20px}.apbct_settings__key_line{display:flex;align-items:flex-start;flex-direction:column;width:100%}.apbct_settings__key_line__elements{margin:5px 0 5px 0}#apbct_setting_apikey,.apbct_setting__key_line__buttons,.apbct_settings__key_line__elements{width:100%}#cleantalk_apikey_wrapper{max-width:100%}#apbct_settings__button_section{position:fixed;right:20px}.apcbt_contact_data_encoder__line{max-width:100%;margin-top:5px}#apbct_summary_and_support-right_side{border-top:1px solid #dcdcde;border-left:0;margin:35px 0 0;padding:0;width:-webkit-fill-available}#apbct_summary_and_support-left_side{border-top:1px solid #dcdcde;width:-webkit-fill-available}#apbct_summary_and_support-sides_wrap{flex-direction:column}}.apbct-trial-renew-fullpage{display:flex;position:fixed;width:stretch;top:0;bottom:0;overflow-y:auto;justify-content:center;align-items:flex-start;font-family:Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#444;background-color:#f0f0f0;z-index:99}.apbct-trial-renew-fullpage-notice{padding:32px 64px 64px 64px!important;margin-top:5em!important;margin-bottom:5em!important}.apbct-trial-renew-fullpage-body{margin-top:32px;margin-left:32px}.apbct-banner-header{display:flex;justify-content:space-between}.apbct-trial-renew-fullpage-body .apbct-banner-button-wrapper{display:flex;margin:0}.apbct-trial-renew-fullpage-image{flex:1 1 0;min-width:0;max-width:576px;height:auto;object-fit:contain;margin:auto 0;align-self:flex-start}.apbct-banner-desc-block-row{line-height:22px}.apbct-banner-red-point{display:inline-block;width:8px;height:8px;background-color:#bb3323;border-radius:50%;margin-right:8px;vertical-align:middle}.apbct-banner-error{background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-banner-background-container{display:grid;max-width:644px;gap:16px;padding:32px;background-color:#f3f6f9;border-radius:8px}.apbct-banner-desc-block-title{font-size:18px;font-weight:700;line-height:22px;color:#0a0a0a;margin-bottom:8px}.apbct-banner-stat-value{margin-bottom:0;color:#444;vertical-align:middle}.apbct-banner-desc-block-text{font-size:14px;line-height:22px;color:#717182;margin-left:12px;margin-top:0;vertical-align:middle}.apbct-banner-desc-blocks{display:grid;gap:16px;flex:1 1 0;min-width:0}.apbct-banner-desc-blocks-content{display:flex;gap:72px;flex-wrap:wrap;justify-content:center;margin:32px 0}.apbct-banner-check{vertical-align:middle}.apbct-trial-renew-upgrade{display:none;text-align:center}.apbct-trial-renew-upgrade-link{font-size:16px;line-height:22px;letter-spacing:0;text-decoration:underline solid 0 #026e88!important;text-underline-offset:25%;color:#026e88!important}@media (max-width:1366px){.apbct-banner-header{display:grid;gap:16px}.apbct-banner-desc-blocks-content{display:grid;gap:38px;place-items:center}.apbct-trial-renew-upgrade{display:block}}@media (max-width:1200px){.apbct-banner-desc-blocks-content{gap:32px}}@media (max-width:782px){.apbct-trial-renew-fullpage{padding-top:50px;padding-bottom:50px}.apbct-trial-renew-fullpage-notice{padding:24px 24px 32px 24px!important}.apbct-trial-renew-fullpage-body{margin-top:20px;margin-left:0}.apbct-banner-desc-blocks-content{gap:24px}.apbct-banner-background-container{padding:20px}.apbct-trial-renew-fullpage-image{width:100%}}@media (max-width:480px){.apbct-trial-renew-fullpage-notice{padding:16px 12px 24px 12px!important}.apbct-banner-background-container{padding:16px}.apbct-banner-desc-blocks-content{gap:16px}.apbct-banner-desc-block-title{font-size:16px}} \ No newline at end of file +.apbct_settings-page{padding-right:10px}.apbct_settings-field_content,.cleantalk_link-auto,.cleantalk_link-manual,.ct-warning-test-failed,.ct_rate_block,.ct_settings_banner,i.animate-spin{display:inline-block}#apbctTopWarning{margin-bottom:5px}#apbctTopWarning h3{margin:10px 0 5px 0}#apbctTopWarning h4{margin:10px}#apbctTopWarning h4 span{margin-top:5px}.apbct_settings-subtitle{position:relative;top:-15px;margin:0}.apbct_settings-field_wrapper{margin:15px 0}.apbct_settings-field_wrapper--sub{margin-left:30px!important}.apbct_settings-field_wrapper--sub-sub{margin-left:60px!important}.apbct_settings__label{margin-right:10px;font-size:17px;vertical-align:text-bottom}.apbct_settings-field_content{display:inline-block}.apbct_settings-field_content--radio,.apbct_settings-field_wrapper>.apbct_settings-field_description{width:70%;padding-bottom:10px}.apbct_settings-field_title--select,.apbct_settings-field_title--textarea{margin:0;width:210px;padding-right:10px;padding-bottom:10px;font-size:14px;vertical-align:top}.apbct_settings-field_title--with-help{width:auto;white-space:nowrap}.apbct_settings-field_title--radio{display:inline-block;margin:0;width:210px;padding-right:10px;font-size:14px;vertical-align:top}.apbct_input_text{min-width:255px;width:400px}.apbct_settings-long_description---show:hover{color:#aaa;cursor:pointer}.apbct_setting_textarea{min-width:300px}.cleantalk_link{text-decoration:none;font-size:13px;line-height:26px;margin:10px 0 0 0;padding:0 10px 1px;cursor:pointer;border-width:1px;border-style:solid;-webkit-appearance:none;white-space:nowrap;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}.cleantalk_link-auto{background:#ccc;border-color:#999;-webkit-box-shadow:inset 0 1px 0 rgba(200,200,200,.5),0 1px 0 rgba(0,0,0,.15);box-shadow:inset 0 1px 0 rgba(200,200,200,.5),0 1px 0 rgba(0,0,0,.15);color:#000;display:inline-block;height:28px;-webkit-border-radius:2px;border-radius:2px}.cleantalk_link-auto:hover{color:#fff}.cleantalk_link-auto.cleantalk_link_text_center{text-align:center}.cleantalk_link-manual{background:#2ea2cc;border-color:#0074a2;-webkit-box-shadow:inset 0 1px 0 rgba(120,200,230,.5),0 1px 0 rgba(0,0,0,.15);box-shadow:inset 0 1px 0 rgba(120,200,230,.5),0 1px 0 rgba(0,0,0,.15);color:#fff;display:inline-block;-webkit-border-radius:3px;border-radius:3px;text-align:center}.cleantalk_link-manual:hover{color:#000}.cleantalk_link[disabled=disabled]{background:#5d9db5;color:#000}.apbct_status_icon{vertical-align:text-bottom;margin:0 5px 0 8px}.apbct-icon-upload-cloud{margin-left:3px}a.ct_support_link{color:#666;margin-right:.5em;font-size:10pt;font-weight:400}.ct-warning-test-failed{display:inline-block;position:relative;padding:5px;margin:4px;border:3px solid #f03232;border-radius:5px;background-color:#ffc8c8}.ct_settings_banner{text-align:right;display:inline-block;width:100%;margin:1em 0;vertical-align:top}#ct_translate_plugin{margin-left:0}.ct_rate_block{display:inline-block;width:370px;margin-right:3em;padding:.8em .8em 15px .8em;text-align:center;border:1px dashed #666}#ct_translate_plugin .apbct_button_rate{margin-bottom:10px}.apbct_long_desc{position:absolute;background:#5a5a5a;min-width:80px;min-height:80px;max-width:500px;padding:10px;color:#fff;z-index:10}.apbct_long_desc a{color:#78c8e6}i.animate-spin{-moz-animation:spin 2s infinite linear;-o-animation:spin 2s infinite linear;-webkit-animation:spin 2s infinite linear;animation:spin 2s infinite linear;display:inline-block;margin:0 auto;font-size:25px;line-height:20px;color:#78c8e6;margin:25px}@keyframes spin{to{transform:rotate(359deg)}}.apbct_long_desc__cancel{position:absolute;top:5px;right:5px;color:rgba(255,255,255,.5)}.apbct_long_desc__cancel:hover{color:#fff}.apbct_long_desc__angle{position:absolute;top:5px;left:-17px;width:10px;height:10px;background:#5a5a5a;-webkit-transform:rotate(135deg);-ms-transform:rotate(135deg);transform:rotate(135deg);-webkit-transform-origin:100% 100%;-ms-transform-origin:100% 100%;transform-origin:100% 100%}.apbct_long_desc__title{color:#78c8e6;margin:0}.--hide{display:none}.apbct_preloader_button{height:15px;margin-left:5px;vertical-align:text-top;display:none}.--invisible{visibility:hidden}.apbct_preloader{height:1em;width:1em;margin-left:.5em;display:inline-block}.key_changed_sync{font-size:1.5em;line-height:2em;vertical-align:middle}.key_changed_success{font-size:1.5em;line-height:2em;vertical-align:middle}.--upper-case{text-transform:uppercase}#cleantalk-modal-content{text-align:center}#cleantalk-modal-content>*{display:block;width:100%}.cleantalk-confirm-modal_header{font-size:15px;font-weight:500}.cleantalk-confirm-modal_text-block{padding:10px 0}.cleantalk-confirm-modal_buttons-block{display:flex!important;gap:10px;justify-content:center}button.ct_support_link{border:none;background:0 0;color:#666;text-decoration:underline;cursor:pointer}.apbct-btn-as-link{padding:0;margin:0;vertical-align:baseline;color:gray;border:0 none;border-bottom:1px solid;cursor:pointer}#apbct-account-email[contenteditable=true]{padding:6px;background-color:#fff;border:1px solid #ccc}button[value=save_changes]:disabled{color:#2271b1;border-color:#2271b1;background:#f6f7f7}#apbct_settings__advanced_settings{position:relative}#apbct_settings__advanced_settings_inner{width:70%}#apbct_hidden_section_nav{position:absolute;top:0;right:0;width:200px}#apbct_hidden_section_nav ul{z-index:9999;width:100%;top:40px}.apbct_settings__position_main_save_button{text-align:right;margin-right:40px}#apbct_settings__after_advanced_settings{margin-bottom:20px;width:70%}#apbct_settings__button_section{position:static;z-index:9999;padding-bottom:40px;bottom:0}.apbct_highlighted{outline-offset:5px;outline-color:red;outline-width:3px!important;outline-style:solid!important}.apbct_notice_inner{display:flex;margin-top:10px!important}.apbct_notification__advanced_settings{margin:20px 0;font-weight:400}.apbct_settings_top_info__div{float:right;padding:0 20px 0 0;font-size:13px;position:relative}.apbct_settings_top_info__p{padding:0;margin:2px}.apbct_settings_top_info__btn{display:inline-flex;gap:10px}.apbct_hidden_section_nav_mob_btn{display:none;background-image:url(images/menu.svg);background-repeat:no-repeat;background-size:contain;background-position-y:center}.apbct_hidden_section_nav_mob_btn-close{display:none}.apbct_settings__footer{background:#c2c2c2;position:absolute;left:0;padding-bottom:30px;padding-left:21px;z-index:-1}.apbct_settings_top_info__sub_btn{display:flex;flex-wrap:wrap;gap:10px;margin:15px 0}.apbct_settings__key_line{display:flex;align-items:center;justify-content:flex-start;flex-direction:row}.apbct_settings__key_line__elements{margin-right:10px}.apbct_setting__key_line__buttons{margin:0}.apbct_setting__key_line__get_key_manual_chunk{margin:10px 0 10px 0;border:1px solid #d3d3d3;border-radius:3px;padding:10px;background:#fff}.apbct_settings__key_line__service_block{margin-left:10px}#apbct_settings__public_offer{margin-top:10px;display:flex;align-items:center}#cleantalk_apikey_wrapper{max-width:65%}.apcbt_contact_data_encoder__line{max-width:70%;display:flex;flex-wrap:wrap;flex-direction:row}.apbct_warning_red_point{display:inline-block;width:4px;height:4px;background:red;border-radius:100%;margin:0 10px 3px 3px;vertical-align:middle;position:relative}.apbct_warning_red_point::after{content:'';position:absolute;left:50%;top:50%;width:12px;height:12px;border:1.5px solid rgba(255,2,2,.65);border-radius:50%;animation:apbct_pulsate 1.2s ease-out infinite;opacity:.7;pointer-events:none;transform:translate(-50%,-50%)}#apbct_summary_and_support{display:flex;flex-wrap:wrap;width:-webkit-fill-available;border:1px solid #ddd;border-radius:3px;padding:10px;background:#ededed;margin-top:4em}#apbct_summary_and_support-left_side{width:69%;padding:0 20px 0 0}#apbct_summary_and_support-right_side{width:31%;padding:0 20px;border-left:1px solid #dcdcde}#apbct_summary_and_support-sides_wrap{display:flex;flex-direction:row;margin:10px 0 10px;width:100%}.apbct_summary_and_support-side_header{margin:.5em 0}.apbct_summary_and_support-inner_header{margin:2em 0 .5em;font-size:medium}.apbct_summary_and_support-inner_header::after{content:'';display:block;width:100%;height:1px;background:linear-gradient(90deg,#d3d3d357 0,rgba(211,211,211,.14) 10%,transparent 100%);margin-top:5px}.apbct_summary_and_support-support_buttons_wrapper{display:flex;flex-direction:column}#apbct_summary_and_support-create_user_button{display:inline-flex;flex-direction:row;align-items:center;justify-content:center}.apbct_summary_and_support-user_creation_result{background:#eee;margin-top:10px;padding:5px;border:1px solid #d3d3d3;display:none}.apbct_summary-list_of_items{display:flex;flex-direction:column}.apbct_summary_list_item{margin-bottom:2px}.apbct-green{color:green}.apbct-red{color:red}#apbct_negative_reports_table,#apbct_negative_reports_table_api{width:100%;border-collapse:collapse;margin:10px 5px}#apbct_sending_report_div{display:flex;flex-direction:row;justify-content:flex-end;align-items:center;flex-wrap:nowrap}#apbct_sending_report_disabled_notice{margin-right:5px}.apbct-real-user-popup-img{align-self:start;margin:0!important;height:1.1em;vertical-align:initial}@keyframes apbct_pulsate{0%{transform:translate(-50%,-50%) scale(1);opacity:.7}100%{transform:translate(-50%,-50%) scale(1.5);opacity:0}}@media (max-width:768px){.apbct_settings_top_info__btn{display:inline-grid;width:100%;gap:5px}#apbct_settings__advanced_settings_inner,#apbct_settings__after_advanced_settings{width:100%}.apbct_settings-field_content--radio,.apbct_settings-field_wrapper>.apbct_settings-field_description{width:100%}.apbct_settings_top_info__sub_btn .apbct_bottom_links--left,.apbct_settings_top_info__sub_btn .apbct_bottom_links--other{margin-left:0;margin-right:0}#apbct_hidden_section_nav ul{display:none;background-color:#dadee3bd;margin:0;padding:10px;border-radius:3px;right:0;width:auto}#apbct_hidden_section_nav{right:0;min-width:167px}.apbct_setting_radio::before{height:6px!important;width:6px!important;margin:4px!important}.apbct_setting_checkbox::before{height:15px!important;width:15px!important;margin:0 -.05rem!important}.apbct_setting_checkbox,.apbct_setting_radio{height:16px!important;width:16px!important}.apbct_hidden_section_nav_mob_btn{display:block;width:32px;height:28px;right:20px;top:20px;position:fixed}.apbct_hidden_section_nav_mob_btn-close{display:block;position:relative;float:right;width:20px;height:22px;top:-5px;right:-5px;background-image:url(images/cancel.svg);background-repeat:no-repeat;background-size:contain;background-position-y:center;text-align:center}.apbct_long_desc{left:-2%!important;max-width:95%;margin-top:10%}.apbct_long_desc__angle{display:none}.ct_rate_block{max-width:240px;margin-right:20px}.apbct_settings__key_line{display:flex;align-items:flex-start;flex-direction:column;width:100%}.apbct_settings__key_line__elements{margin:5px 0 5px 0}#apbct_setting_apikey,.apbct_setting__key_line__buttons,.apbct_settings__key_line__elements{width:100%}#cleantalk_apikey_wrapper{max-width:100%}#apbct_settings__button_section{position:fixed;right:20px}.apcbt_contact_data_encoder__line{max-width:100%;margin-top:5px}#apbct_summary_and_support-right_side{border-top:1px solid #dcdcde;border-left:0;margin:35px 0 0;padding:0;width:-webkit-fill-available}#apbct_summary_and_support-left_side{border-top:1px solid #dcdcde;width:-webkit-fill-available}#apbct_summary_and_support-sides_wrap{flex-direction:column}}.apbct-trial-renew-fullpage{display:flex;position:fixed;width:stretch;top:0;bottom:0;overflow-y:auto;justify-content:center;align-items:flex-start;font-family:Inter,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#444;background-color:#f0f0f0;z-index:99}.apbct-trial-renew-fullpage-notice{padding:32px 64px 64px 64px!important;margin-top:5em!important;margin-bottom:5em!important}.apbct-trial-renew-fullpage-body{margin-top:32px;margin-left:32px}.apbct-banner-header{display:flex;justify-content:space-between}.apbct-trial-renew-fullpage-body .apbct-banner-button-wrapper{display:flex;margin:0}.apbct-trial-renew-fullpage-image{flex:1 1 0;min-width:0;max-width:576px;height:auto;object-fit:contain;margin:auto 0;align-self:flex-start}.apbct-banner-desc-block-row{line-height:22px}.apbct-banner-red-point{display:inline-block;width:8px;height:8px;background-color:#bb3323;border-radius:50%;margin-right:8px;vertical-align:middle}.apbct-banner-error{background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-banner-background-container{display:grid;max-width:644px;gap:16px;padding:32px;background-color:#f3f6f9;border-radius:8px}.apbct-banner-desc-block-title{font-size:18px;font-weight:700;line-height:22px;color:#0a0a0a;margin-bottom:8px}.apbct-banner-stat-value{margin-bottom:0;color:#444;vertical-align:middle}.apbct-banner-desc-block-text{font-size:14px;line-height:22px;color:#717182;margin-left:12px;margin-top:0;vertical-align:middle}.apbct-banner-desc-blocks{display:grid;gap:16px;flex:1 1 0;min-width:0}.apbct-banner-desc-blocks-content{display:flex;gap:72px;flex-wrap:wrap;justify-content:center;margin:32px 0}.apbct-banner-check{vertical-align:middle}.apbct-trial-renew-upgrade{display:none;text-align:center}.apbct-trial-renew-upgrade-link{font-size:16px;line-height:22px;letter-spacing:0;text-decoration:underline solid 0 #026e88!important;text-underline-offset:25%;color:#026e88!important}@media (max-width:1366px){.apbct-banner-header{display:grid;gap:16px}.apbct-banner-desc-blocks-content{display:grid;gap:38px;place-items:center}.apbct-trial-renew-upgrade{display:block}}@media (max-width:1200px){.apbct-banner-desc-blocks-content{gap:32px}}@media (max-width:782px){.apbct-trial-renew-fullpage{padding-top:50px;padding-bottom:50px}.apbct-trial-renew-fullpage-notice{padding:24px 24px 32px 24px!important}.apbct-trial-renew-fullpage-body{margin-top:20px;margin-left:0}.apbct-banner-desc-blocks-content{gap:24px}.apbct-banner-background-container{padding:20px}.apbct-trial-renew-fullpage-image{width:100%}}@media (max-width:480px){.apbct-trial-renew-fullpage-notice{padding:16px 12px 24px 12px!important}.apbct-banner-background-container{padding:16px}.apbct-banner-desc-blocks-content{gap:16px}.apbct-banner-desc-block-title{font-size:16px}} \ No newline at end of file diff --git a/css/src/cleantalk-admin-settings-page.css b/css/src/cleantalk-admin-settings-page.css index 28a2b9ce9..711d31678 100644 --- a/css/src/cleantalk-admin-settings-page.css +++ b/css/src/cleantalk-admin-settings-page.css @@ -562,7 +562,8 @@ button[value="save_changes"]:disabled { color: red; } -#apbct_negative_reports_table { +#apbct_negative_reports_table, +#apbct_negative_reports_table_api { width: 100%; border-collapse: collapse; margin: 10px 5px; diff --git a/lib/Cleantalk/ApbctWP/API.php b/lib/Cleantalk/ApbctWP/API.php index d137a851b..ca77589f1 100644 --- a/lib/Cleantalk/ApbctWP/API.php +++ b/lib/Cleantalk/ApbctWP/API.php @@ -3,6 +3,7 @@ namespace Cleantalk\ApbctWP; use Cleantalk\ApbctWP\HTTP\Request; +use Cleantalk\Common\HTTP\Response; /** * Class API. @@ -111,6 +112,43 @@ public static function sendRequest($data, $timeout = 10) ); } - return $request->request(); + $result = $request->request(); + + $http_code = $request->response instanceof Response + ? $request->response->getResponseCode() + : null; + self::handleConnectionReport($data, $result, $url, $http_code); + + return $result; + } + + /** + * Add the API request result to connection reports. Never breaks the API call. + * + * @param array $data + * @param mixed $result + * @param string $url + * @param int|null $http_code + */ + private static function handleConnectionReport($data, $result, $url, $http_code) + { + global $apbct; + + if ( ! $apbct instanceof State || ! defined('APBCT_TBL_CONNECTION_REPORTS') ) { + return; + } + + try { + $apbct->getConnectionReports()->handleApiRequest( + isset($data['method_name']) ? $data['method_name'] : 'unknown', + $data, + $result, + $url, + $http_code + ); + } catch ( \Throwable $e ) { + // Reporting failures must not affect the API request result + return; + } } } diff --git a/lib/Cleantalk/ApbctWP/ConnectionReports.php b/lib/Cleantalk/ApbctWP/ConnectionReports.php index e53011daf..41ef99fc4 100644 --- a/lib/Cleantalk/ApbctWP/ConnectionReports.php +++ b/lib/Cleantalk/ApbctWP/ConnectionReports.php @@ -10,8 +10,11 @@ class ConnectionReports { + const SOURCE_MODERATE = 'moderate'; + const SOURCE_API = 'api'; + /** - * Statistics of state + * Statistics of state (moderate requests) * @var int[] */ public $reports_count = array( @@ -21,6 +24,64 @@ class ConnectionReports 'stat_since' => 0 ); + /** + * Statistics of state (API requests) + * @var int[] + */ + public $api_reports_count = array( + 'positive' => 0, + 'negative' => 0, + 'total' => 0, + 'stat_since' => 0 + ); + + /** + * API methods which are not tracked. 2s_blacklists_db is handled by SFWUpdateSentinel. + * @var string[] + */ + private $api_methods_excluded = array( + '2s_blacklists_db', + ); + + /** + * Error prefixes from API::checkResponse() which mean the server answered (business errors, not connection ones) + * @var string[] + */ + private $api_business_errors_prefixes = array( + 'SERVER_ERROR', + 'NO_DATA', + 'COULDNT_ADD_WL_IP', + ); + + /** + * Request data keys that must never be stored in reports + * @var string[] + */ + private $api_sensitive_keys = array( + 'auth_key', + 'api_key', + 'user_token', + 'key', + 'token', + 'password', + ); + + /** + * Max length of a single API request field kept in report + * @var int + */ + private $api_field_max_length = 500; + + /** + * @var bool|null Cache for the table existence check + */ + private $table_exists = null; + + /** + * @var bool|null Cache for source/method_name columns existence (they appear after DB update) + */ + private $has_source_columns = null; + /** * Instance of DB object * @var DB @@ -34,10 +95,13 @@ class ConnectionReports private $cr_table_name; /** - * Limit of reports to keep - * @var int + * Limit of reports to keep, per source + * @var int[] */ - private $reports_limit = 20; + private $reports_limits = array( + self::SOURCE_MODERATE => 20, + self::SOURCE_API => 20, + ); /** * @var array Current reports data from DB @@ -77,18 +141,64 @@ public function __construct(DB $db, $cr_table_name) */ private function initializeReportsCount($apbct) { - $this->reports_count['positive'] = isset($apbct->data['connection_reports_count']['positive']) - ? $apbct->data['connection_reports_count']['positive'] - : 0; - $this->reports_count['negative'] = isset($apbct->data['connection_reports_count']['negative']) - ? $apbct->data['connection_reports_count']['negative'] - : 0; - $this->reports_count['total'] = isset($apbct->data['connection_reports_count']['total']) - ? $apbct->data['connection_reports_count']['total'] - : 0; - $this->reports_count['stat_since'] = isset($apbct->data['connection_reports_count']['stat_since']) - ? $apbct->data['connection_reports_count']['stat_since'] - : date('d M'); + $this->reports_count = $this->readCountFromData($apbct, 'connection_reports_count'); + $this->api_reports_count = $this->readCountFromData($apbct, 'connection_reports_count_api'); + } + + /** + * @param mixed $apbct + * @param string $data_key + * @return array + */ + private function readCountFromData($apbct, $data_key) + { + $stored = isset($apbct->data[$data_key]) && is_array($apbct->data[$data_key]) + ? $apbct->data[$data_key] + : array(); + + return array( + 'positive' => isset($stored['positive']) ? $stored['positive'] : 0, + 'negative' => isset($stored['negative']) ? $stored['negative'] : 0, + 'total' => isset($stored['total']) ? $stored['total'] : 0, + 'stat_since' => isset($stored['stat_since']) ? $stored['stat_since'] : date('d M'), + ); + } + + /** + * Check once if the reports table exists + * @return bool + */ + private function isTableExists() + { + if ($this->table_exists === null) { + $this->table_exists = $this->db->isTableExists($this->cr_table_name); + } + return $this->table_exists; + } + + /** + * Check once if the table has source/method_name columns + * @return bool + */ + private function hasSourceColumns() + { + if ($this->has_source_columns === null) { + $columns = $this->db->fetchAll('SHOW COLUMNS FROM ' . $this->cr_table_name . " LIKE 'method_name';"); + $this->has_source_columns = ! empty($columns); + } + return $this->has_source_columns; + } + + /** + * Get source of the report row. Legacy rows without source are moderate ones. + * @param array $report + * @return string + */ + private static function getReportSource($report) + { + return isset($report['source']) && $report['source'] === self::SOURCE_API + ? self::SOURCE_API + : self::SOURCE_MODERATE; } /** @@ -136,17 +246,51 @@ private function markReportsDataDirty() /** * Update global stats in state + * @param string $source */ - private function updateStats() + private function updateStats($source = self::SOURCE_MODERATE) { global $apbct; - $positive = isset($this->reports_count['positive']) ? $this->reports_count['positive'] : 0; - $negative = isset($this->reports_count['negative']) ? $this->reports_count['negative'] : 0; - $this->reports_count['total'] = $positive + $negative; - $apbct->data['connection_reports_count'] = $this->reports_count; + if ($source === self::SOURCE_API) { + $this->api_reports_count = $this->recalculateTotal($this->api_reports_count); + $apbct->data['connection_reports_count_api'] = $this->api_reports_count; + } else { + $this->reports_count = $this->recalculateTotal($this->reports_count); + $apbct->data['connection_reports_count'] = $this->reports_count; + } $apbct->saveData(); } + /** + * @param array $count + * @return array + */ + private function recalculateTotal($count) + { + $positive = isset($count['positive']) ? $count['positive'] : 0; + $negative = isset($count['negative']) ? $count['negative'] : 0; + $count['total'] = $positive + $negative; + return $count; + } + + /** + * Increment positive or negative counter of the source + * @param string $source + * @param string $type positive|negative + */ + private function incrementCount($source, $type) + { + if ($source === self::SOURCE_API) { + $this->api_reports_count[$type] = isset($this->api_reports_count[$type]) + ? $this->api_reports_count[$type] + 1 + : 1; + } else { + $this->reports_count[$type] = isset($this->reports_count[$type]) + ? $this->reports_count[$type] + 1 + : 1; + } + } + /** * Array of report's IDs that has null on sent_on field * @return array @@ -192,9 +336,10 @@ private function setReportsAsSent(array $ids) } /** - * Rotates reports in DB, remove the oldest ones by limit and age (6 months). + * Rotates reports of the source in DB, remove the oldest ones by limit and age (6 months). + * @param string $source */ - private function rotateReports() + private function rotateReports($source = self::SOURCE_MODERATE) { $reports_data = $this->getReportsData(); $ids_to_delete = array(); @@ -207,10 +352,14 @@ private function rotateReports() } } - // Remove oldest reports if exceeding limit - if (count($reports_data) >= $this->reports_limit) { - $overlimit = count($reports_data) - $this->reports_limit + 1; - $reports_to_del = array_slice($reports_data, 0, $overlimit); + // Remove oldest reports of the source if exceeding the source limit + $source_reports = array_values(array_filter($reports_data, static function ($report) use ($source) { + return self::getReportSource($report) === $source; + })); + $limit = isset($this->reports_limits[$source]) ? $this->reports_limits[$source] : 20; + if (count($source_reports) >= $limit) { + $overlimit = count($source_reports) - $limit + 1; + $reports_to_del = array_slice($source_reports, 0, $overlimit); $overlimit_ids = array_column($reports_to_del, 'id'); $ids_to_delete = array_unique(array_merge($ids_to_delete, $overlimit_ids)); @@ -259,12 +408,16 @@ private function getReportsDataByIds(array $ids) * @param string $failed_work_urls Current work URLs of CT server that failed * @param array $request_content CleanTalk request content * @param bool $post_blocked_via_js_check Flag if JS check passed request or not + * @param string $source Report source, moderate|api + * @param string|null $method_name API method name */ private function addReportToDb( $lib_report = '', $failed_work_urls = '', $request_content = array(), - $post_blocked_via_js_check = false + $post_blocked_via_js_check = false, + $source = self::SOURCE_MODERATE, + $method_name = null ) { $cr_data = array( 'date' => time(), @@ -272,28 +425,38 @@ private function addReportToDb( 'lib_report' => $lib_report, 'failed_work_urls' => $failed_work_urls, 'request_content' => json_encode(esc_sql($request_content)), - 'js_block' => $post_blocked_via_js_check ? '1' : '0' + 'js_block' => $post_blocked_via_js_check ? '1' : '0', + 'source' => $source, + 'method_name' => TT::toString($method_name), ); - $this->db->prepare( - "INSERT INTO " . $this->cr_table_name . " + $query = "INSERT INTO " . $this->cr_table_name . " SET date = %s, page_url = %s, lib_report = %s, failed_work_urls = %s, request_content = %s, - js_block = %s", - array( - $cr_data['date'], - $cr_data['page_url'], - $cr_data['lib_report'], - $cr_data['failed_work_urls'], - $cr_data['request_content'], - $cr_data['js_block'], - ) + js_block = %s"; + $params = array( + $cr_data['date'], + $cr_data['page_url'], + $cr_data['lib_report'], + $cr_data['failed_work_urls'], + $cr_data['request_content'], + $cr_data['js_block'], ); + if ($this->hasSourceColumns()) { + $query .= ", + source = %s, + method_name = %s"; + $params[] = $cr_data['source']; + $params[] = $cr_data['method_name']; + } + + $this->db->prepare($query, $params); + $this->db->execute($this->db->getQuery()); $this->markReportsDataDirty(); } @@ -312,58 +475,118 @@ public function prepareNegativeReportsHtmlForSettingsPage() return ''; } - $rows = ''; - $stat_since = isset($this->reports_count['stat_since']) ? $this->reports_count['stat_since'] : ''; - $total = isset($this->reports_count['total']) ? $this->reports_count['total'] : ''; - $positive = isset($this->reports_count['positive']) ? $this->reports_count['positive'] : ''; - $negative = isset($this->reports_count['negative']) ? $this->reports_count['negative'] : ''; - - $reports_html = '

From ' - . Escape::escHtml($stat_since) - . ' to ' . Escape::escHtml(date('d M')) . ' has been made ' - . Escape::escHtml($total) - . ' calls, where ' . Escape::escHtml($positive) . ' were success and ' - . Escape::escHtml($negative) . ' were negative -

'; - - foreach ($reports_data as $key => $report) { - $rows .= $this->prepareReportRow($key, $report); - } - - $reports_html .= " - - - - - - - - - - - " - . $rows - . "
Failed connection reports
#DatePage URLReportServer IPBlocked via JSStatus
"; + $moderate_reports = self::filterReportsBySource($reports_data, self::SOURCE_MODERATE); + $api_reports = self::filterReportsBySource($reports_data, self::SOURCE_API); + + $reports_html = '
' . $this->prepareStatsParagraph($this->reports_count, 'Anti-Spam') . '
'; + if ( ! empty($moderate_reports) ) { + $rows = ''; + foreach ($moderate_reports as $key => $report) { + $rows .= $this->prepareModerateReportRow($key, $report); + } + $reports_html .= self::prepareReportsTable( + 'apbct_negative_reports_table', + 'Failed connection reports', + array('Date', 'Page URL', 'Report', 'Server IP', 'Blocked via JS', 'Status'), + $rows + ); + } + + if ( ! empty($this->api_reports_count['total']) || ! empty($api_reports) ) { + $reports_html .= '
' . $this->prepareStatsParagraph($this->api_reports_count, 'API') . '
'; + if ( ! empty($api_reports) ) { + $rows = ''; + foreach ($api_reports as $key => $report) { + $rows .= $this->prepareApiReportRow($key, $report); + } + $reports_html .= self::prepareReportsTable( + 'apbct_negative_reports_table_api', + 'Failed API connection reports', + array('Date', 'Page URL', 'API method', 'Report', 'API URL', 'Status'), + $rows + ); + } + } return $reports_html; } /** - * Prepare single report row HTML + * Get reports of the source, reindexed from 0 + * @param array $reports_data + * @param string $source + * @return array + */ + private static function filterReportsBySource($reports_data, $source) + { + return array_values(array_filter($reports_data, static function ($report) use ($source) { + return self::getReportSource($report) === $source; + })); + } + + /** + * Prepare reports table HTML for the settings page + * @param string $table_id + * @param string $title + * @param string[] $headers Columns headers except the first "#" one + * @param string $rows Rows HTML, already escaped + * @return string + */ + private static function prepareReportsTable($table_id, $title, $headers, $rows) + { + $headers_html = '#'; + foreach ($headers as $header) { + $headers_html .= '' . Escape::escHtml($header) . ''; + } + + return "" + . "' + . '' . $headers_html . '' + . $rows + . '
" . Escape::escHtml($title) . '
'; + } + + /** + * @param array $count + * @param string $label + * @return string + */ + private function prepareStatsParagraph($count, $label) + { + $stat_since = isset($count['stat_since']) ? $count['stat_since'] : ''; + $total = isset($count['total']) ? $count['total'] : ''; + $positive = isset($count['positive']) ? $count['positive'] : ''; + $negative = isset($count['negative']) ? $count['negative'] : ''; + + return '

' . Escape::escHtml($label) . ': from ' + . Escape::escHtml($stat_since) + . ' to ' . Escape::escHtml(date('d M')) . ' has been made ' + . Escape::escHtml($total) + . ' calls, where ' . Escape::escHtml($positive) . ' were success and ' + . Escape::escHtml($negative) . ' were negative

'; + } + + /** + * Get report status label and row color + * @param array $report + * @return string[] [status, color] + */ + private static function getReportStatus($report) + { + return isset($report['sent_on']) && $report['sent_on'] + ? array('Sent', 'gray') + : array('New', 'black'); + } + + /** + * Prepare single Anti-Spam report row HTML * @param int $key * @param array $report * @return string */ - private function prepareReportRow($key, $report) + private function prepareModerateReportRow($key, $report) { - // Determine status and color - if (isset($report['sent_on']) && $report['sent_on']) { - $status = 'Sent'; - $color = 'gray'; - } else { - $status = 'New'; - $color = 'black'; - } + list($status, $color) = self::getReportStatus($report); $report_date = isset($report['date']) ? $report['date'] : time(); $report_page_url = isset($report['page_url']) ? $report['page_url'] : ''; @@ -382,6 +605,33 @@ private function prepareReportRow($key, $report) . ''; } + /** + * Prepare single API report row HTML + * @param int $key + * @param array $report + * @return string + */ + private function prepareApiReportRow($key, $report) + { + list($status, $color) = self::getReportStatus($report); + + $report_date = isset($report['date']) ? $report['date'] : time(); + $report_page_url = isset($report['page_url']) ? $report['page_url'] : ''; + $report_method_name = isset($report['method_name']) ? $report['method_name'] : ''; + $report_lib_report = isset($report['lib_report']) ? $report['lib_report'] : ''; + $report_api_url = isset($report['failed_work_urls']) ? $report['failed_work_urls'] : ''; + + return '' + . '' . Escape::escHtml((int)$key + 1) . '.' + . '' . Escape::escHtml($report_date) . '' + . '' . Escape::escUrl($report_page_url) . '' + . '' . Escape::escHtml($report_method_name) . '' + . '' . Escape::escHtml($report_lib_report) . '' + . '' . Escape::escHtml($report_api_url) . '' + . '' . Escape::escHtml($status) . '' + . ''; + } + /** * Check if there are reports kept * @return bool @@ -406,27 +656,115 @@ public function handleRequest( ) { // Succeeded connection if ($request_response->errno === 0 && empty($request_response->errstr)) { - if (isset($this->reports_count['positive'])) { - $this->reports_count['positive']++; - } else { - $this->reports_count['positive'] = 1; - } + $this->incrementCount(self::SOURCE_MODERATE, 'positive'); } else { // Failed to connect. Add a negative report - $this->rotateReports(); - if (isset($this->reports_count['negative'])) { - $this->reports_count['negative']++; - } else { - $this->reports_count['negative'] = 1; - } + $this->rotateReports(self::SOURCE_MODERATE); + $this->incrementCount(self::SOURCE_MODERATE, 'negative'); $this->addReportToDb( $request_response->errstr, $request_response->failed_connections_urls_string, Helper::arrayObjectToArray($request), - $post_blocked_via_js_check + $post_blocked_via_js_check, + self::SOURCE_MODERATE ); } - $this->updateStats(); + $this->updateStats(self::SOURCE_MODERATE); + } + + /** + * Connection reports handling for API (api.cleantalk.org) requests + * @param string $method_name API method name + * @param array $request_data Data sent to API + * @param mixed $result Result of API::sendRequest() + * @param string $url API URL + * @param int|null $http_code HTTP code of the response if it was received + * @psalm-suppress PossiblyUnusedMethod + */ + public function handleApiRequest($method_name, $request_data, $result, $url, $http_code = null) + { + $method_name = TT::toString($method_name); + + if (in_array($method_name, $this->api_methods_excluded, true) || ! $this->isTableExists()) { + return; + } + + $error = $this->getApiConnectionError($result, $http_code); + + if ($error === null) { + $this->incrementCount(self::SOURCE_API, 'positive'); + } else { + $this->rotateReports(self::SOURCE_API); + $this->incrementCount(self::SOURCE_API, 'negative'); + $this->addReportToDb( + $error, + TT::toString($url), + $this->sanitizeApiRequestData(is_array($request_data) ? $request_data : array()), + false, + self::SOURCE_API, + $method_name + ); + } + $this->updateStats(self::SOURCE_API); + } + + /** + * Get the connection error from API result. Business errors (the server has answered) are not connection errors. + * @param mixed $result + * @param int|null $http_code + * @return string|null Error text or null if there is no connection error + */ + public function getApiConnectionError($result, $http_code = null) + { + $error = null; + + if (is_array($result) && ! empty($result['error'])) { + $error = is_string($result['error']) ? $result['error'] : (string)json_encode($result['error']); + foreach ($this->api_business_errors_prefixes as $prefix) { + if (strpos($error, $prefix) === 0) { + $error = null; + break; + } + } + } + + $http_code = (int)$http_code; + if ($http_code >= 500) { + $error = 'HTTP_CODE_' . $http_code . ($error !== null ? ': ' . $error : ''); + } elseif ($error !== null && $http_code > 0) { + $error = 'HTTP_CODE_' . $http_code . ': ' . $error; + } + + return $error; + } + + /** + * Remove credentials and truncate large fields of API request data + * @param array $request_data + * @return array + */ + private function sanitizeApiRequestData(array $request_data) + { + $result = array(); + foreach ($request_data as $key => $value) { + if (in_array(strtolower((string)$key), $this->api_sensitive_keys, true)) { + $result[$key] = '***'; + continue; + } + if (is_array($value) || is_object($value)) { + $value = (string)json_encode($value); + } + if (is_string($value)) { + $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + if ($length > $this->api_field_max_length) { + $value = (function_exists('mb_substr') + ? mb_substr($value, 0, $this->api_field_max_length) + : substr($value, 0, $this->api_field_max_length)) . '...[truncated]'; + } + } + $result[$key] = $value; + } + return $result; } /** @@ -458,6 +796,100 @@ private function sendEmail(array $unsent_reports_ids, $is_cron_task = false) return false; } + /** + * Get report field as string + * @param array $report + * @param string $field + * @return string + */ + private static function getReportField($report, $field) + { + return isset($report[$field]) ? TT::toString($report[$field]) : ''; + } + + /** + * Group identical reports (by the key fields) to avoid duplicates in the email. + * The newest groups go first. + * @param array $reports + * @param string[] $key_fields + * @return array + * @psalm-suppress MoreSpecificReturnType, LessSpecificReturnStatement + */ + private static function aggregateReports(array $reports, array $key_fields) + { + $groups = array(); + foreach ($reports as $report) { + $key_values = array(); + foreach ($key_fields as $field) { + $key_values[] = self::getReportField($report, $field); + } + $key = md5((string)json_encode($key_values)); + + $timestamp = isset($report['date_timestamp']) ? (int)$report['date_timestamp'] : 0; + $date = self::getReportField($report, 'date'); + + if ( ! isset($groups[$key]) ) { + $groups[$key] = array( + 'report' => TT::toArray($report), + 'count' => 0, + 'first_date' => $date, + 'last_date' => $date, + 'first_ts' => $timestamp, + 'last_ts' => $timestamp, + ); + } + + $groups[$key]['count']++; + if ($timestamp < $groups[$key]['first_ts']) { + $groups[$key]['first_ts'] = $timestamp; + $groups[$key]['first_date'] = $date; + } + if ($timestamp >= $groups[$key]['last_ts']) { + $groups[$key]['last_ts'] = $timestamp; + $groups[$key]['last_date'] = $date; + } + } + + usort($groups, static function ($a, $b) { + return $b['last_ts'] - $a['last_ts']; + }); + + return $groups; + } + + /** + * Prepare email table of aggregated reports + * @param callable[] $columns Header => callback returning escaped cell HTML for the report + * @param array $groups Result of aggregateReports() + * @return string + */ + private static function prepareEmailReportsTable(array $columns, array $groups) + { + if (empty($groups)) { + return '

No new reports.

'; + } + + $html = ''; + foreach (array_keys($columns) as $header) { + $html .= ''; + } + $html .= ''; + + $counter = 0; + foreach ($groups as $group) { + $html .= ''; + foreach ($columns as $render_cell) { + $html .= ''; + } + $html .= '' + . '' + . '' + . ''; + } + + return $html . '
 ' . Escape::escHtml($header) . 'CountFirst dateLast date
' . (++$counter) . '.' . $render_cell($group['report']) . '' . Escape::escHtml($group['count']) . '' . Escape::escHtml($group['first_date']) . '' . Escape::escHtml($group['last_date']) . '

'; + } + /** * Prepare email content * @param array $selection @@ -467,43 +899,63 @@ private function sendEmail(array $unsent_reports_ids, $is_cron_task = false) private function prepareEmailContent(array $selection, $is_cron_task = false) { global $apbct; - $stat_since = isset($this->reports_count['stat_since']) ? $this->reports_count['stat_since'] : ''; - $total = isset($this->reports_count['total']) ? $this->reports_count['total'] : ''; - $positive = isset($this->reports_count['positive']) ? $this->reports_count['positive'] : ''; - $negative = isset($this->reports_count['negative']) ? $this->reports_count['negative'] : ''; + + $moderate_groups = self::aggregateReports( + self::filterReportsBySource($selection, self::SOURCE_MODERATE), + array('page_url', 'lib_report', 'failed_work_urls', 'js_block') + ); + $api_groups = self::aggregateReports( + self::filterReportsBySource($selection, self::SOURCE_API), + array('page_url', 'method_name', 'lib_report', 'failed_work_urls') + ); $message = ' - -

From ' . Escape::escHtml($stat_since) . ' to ' . Escape::escHtml(date('d M')) . ' has been made ' . Escape::escHtml($total) . - ' calls, where ' . Escape::escHtml($positive) . ' were success and ' . Escape::escHtml($negative) . ' were negative

-

Negative report:

- - - - - - - - - '; + '; + + $message .= '

Anti-Spam (moderate servers) connection reports

' + . $this->prepareStatsParagraph($this->reports_count, 'Anti-Spam') + . self::prepareEmailReportsTable( + array( + 'Page URL' => static function ($report) { + return Escape::escUrl(self::getReportField($report, 'page_url')); + }, + 'Library report' => static function ($report) { + return Escape::escHtml(self::getReportField($report, 'lib_report')); + }, + 'Server IP' => static function ($report) { + return Escape::escHtml(self::getReportField($report, 'failed_work_urls')); + }, + 'Blocked via JS' => static function ($report) { + return self::getReportField($report, 'js_block') === '1' ? 'Yes' : 'No'; + }, + ), + $moderate_groups + ); + + $message .= '

API connection reports

' + . $this->prepareStatsParagraph($this->api_reports_count, 'API') + . self::prepareEmailReportsTable( + array( + 'Page URL' => static function ($report) { + return Escape::escUrl(self::getReportField($report, 'page_url')); + }, + 'API method' => static function ($report) { + return Escape::escHtml(self::getReportField($report, 'method_name')); + }, + 'Library report' => static function ($report) { + return Escape::escHtml(self::getReportField($report, 'lib_report')); + }, + 'API URL' => static function ($report) { + return Escape::escHtml(self::getReportField($report, 'failed_work_urls')); + }, + ), + $api_groups + ); - $counter = 0; - foreach ($selection as $report) { - $message .= '' - . '' - . '' - . '' - . '' - . '' - . '' - . ''; - } - - $message .= '
 DatePage URLLibrary reportServer IPBlocked via JS
' . (++$counter) . '.' . TT::toString($report['date']) . '' . Escape::escUrl($report['page_url']) . '' . Escape::escHtml($report['lib_report']) . '' . Escape::escHtml($report['failed_work_urls']) . '' . ($report['js_block'] === '1' ? 'Yes' : 'No') . '

'; $message .= '
' . ($is_cron_task ? 'This is a cron task.' : 'This is a manual task.') . '
'; $message .= '
Site service_id: ' . $apbct->data['service_id'] . '
'; $message .= ''; @@ -575,7 +1027,7 @@ public static function getOptionDescription($sfw_enabled, $brand_name, $email) $sfw_outdated_message = '
' . __('Also, if enabled, a notification will appear in the plugin settings informing you that the SpamFireWall database is outdated.', 'cleantalk-spam-protect'); } return TextPlateStatic::render( - '{{first_row}}
{{second_row}}
{{list_1}}{{list_2}}{{sfw_outdated_message}}', + '{{first_row}}
{{second_row}}
{{list_1}}
{{list_api}}{{list_2}}{{sfw_outdated_message}}', array( 'first_row' => __("Checking this box you allow plugin to send the information about your connection.", 'cleantalk-spam-protect'), 'second_row' => esc_html__( @@ -590,6 +1042,12 @@ public static function getOptionDescription($sfw_enabled, $brand_name, $email) TT::toString($brand_name, __('service', 'cleantalk-spam-protect')) ) ), + 'list_api' => esc_html__( + sprintf( + '- connection status to %s cloud during API requests', + TT::toString($brand_name, __('service', 'cleantalk-spam-protect')) + ) + ), 'list_2' => $send_connection_reports__sfw_text, 'sfw_outdated_message' => $sfw_outdated_message, ) diff --git a/lib/Cleantalk/ApbctWP/PluginSettingsPage/SummaryAndSupportRenderer.php b/lib/Cleantalk/ApbctWP/PluginSettingsPage/SummaryAndSupportRenderer.php index 711429a03..ce58fab47 100644 --- a/lib/Cleantalk/ApbctWP/PluginSettingsPage/SummaryAndSupportRenderer.php +++ b/lib/Cleantalk/ApbctWP/PluginSettingsPage/SummaryAndSupportRenderer.php @@ -338,6 +338,7 @@ private function renderConnectionReports() 'td' => array(), 'th' => array('colspan' => true), 'b' => array(), + 'p' => array(), 'span' => array('id' => true), 'div' => array('id' => true), 'table' => array('id' => true), diff --git a/lib/Cleantalk/Common/Schema.php b/lib/Cleantalk/Common/Schema.php index deefa0cb2..1b6f5d175 100644 --- a/lib/Cleantalk/Common/Schema.php +++ b/lib/Cleantalk/Common/Schema.php @@ -90,6 +90,8 @@ class Schema 'request_content' => 'TEXT NULL DEFAULT NULL', 'sent_on' => 'INT NULL DEFAULT NULL', //timestamp 'js_block' => 'VARCHAR(1) NULL DEFAULT NULL', + 'source' => 'VARCHAR(16) NULL DEFAULT NULL', // moderate|api, NULL for legacy rows means moderate + 'method_name' => 'VARCHAR(64) NULL DEFAULT NULL', '__indexes' => 'PRIMARY KEY (`id`)', '__createkey' => 'INT unsigned primary KEY AUTO_INCREMENT FIRST' ), diff --git a/tests/ApbctWP/TestConnectionReports.php b/tests/ApbctWP/TestConnectionReports.php index 3665afa6d..40dd347c8 100644 --- a/tests/ApbctWP/TestConnectionReports.php +++ b/tests/ApbctWP/TestConnectionReports.php @@ -41,6 +41,11 @@ public function setUp(): void } + public function tearDown(): void + { + $this->connection_reports->wipeReportsData(); + } + public function testCRIsTableExists() { $this->assertTrue($this->db->isTableExists(APBCT_TBL_CONNECTION_REPORTS)); @@ -83,8 +88,12 @@ public function testAddingNegativeReportProcess() $this->assertEquals('TEST 404 ERROR', $data[0]['lib_report']); $this->assertEquals('1', $data[0]['js_block']); $this->assertEquals('test.moderate1.cleantalk.org,test.moderate1.cleantalk.org', $data[0]['failed_work_urls']); + $this->assertEquals('moderate', $data[0]['source']); - $this->assertNotEmpty($this->connection_reports->prepareNegativeReportsHtmlForSettingsPage()); + $html = $this->connection_reports->prepareNegativeReportsHtmlForSettingsPage(); + $this->assertStringContainsString("id='apbct_negative_reports_table'", $html); + $this->assertStringContainsString('Blocked via JS', $html); + $this->assertStringNotContainsString('apbct_negative_reports_table_api', $html); $this->assertTrue($this->connection_reports->hasUnsentReports()); $this->assertEquals('1 reports were sent.', $this->connection_reports->sendUnsentReports()); @@ -139,4 +148,136 @@ public function testEmptyReports() $this->assertEquals('', $this->connection_reports->prepareNegativeReportsHtmlForSettingsPage()); $this->assertEquals(array(), $this->connection_reports->remoteCallOutput()); } + + public function testApiConnectionErrorDetection() + { + $cr = $this->connection_reports; + + $this->assertNull($cr->getApiConnectionError(array('some' => 'data'), 200)); + $this->assertNull($cr->getApiConnectionError(true, 200)); + $this->assertNull($cr->getApiConnectionError(array('error' => 'NO_DATA'), 200)); + $this->assertNull($cr->getApiConnectionError(array('error' => 'COULDNT_ADD_WL_IP'), 200)); + $this->assertNull($cr->getApiConnectionError(array('error' => 'SERVER_ERROR NO: 1 MSG: bad key'), 200)); + + $this->assertEquals('cURL error 28: timeout', $cr->getApiConnectionError(array('error' => 'cURL error 28: timeout'))); + $this->assertEquals('HTTP_CODE_200: JSON_DECODE_ERROR', $cr->getApiConnectionError(array('error' => 'JSON_DECODE_ERROR'), 200)); + $this->assertEquals('HTTP_CODE_502: JSON_DECODE_ERROR', $cr->getApiConnectionError(array('error' => 'JSON_DECODE_ERROR'), 502)); + $this->assertEquals('HTTP_CODE_500', $cr->getApiConnectionError(array('data' => 1), 500)); + } + + public function testApiSuccessReport() + { + global $apbct; + $this->connection_reports->wipeReportsData(); + $positive_before = $this->connection_reports->api_reports_count['positive']; + $moderate_before = $this->connection_reports->reports_count; + + $this->connection_reports->handleApiRequest('notice_paid_till', array('auth_key' => 'secret'), array('valid' => 1), 'https://api.cleantalk.org', 200); + + $this->assertFalse($this->connection_reports->hasNegativeReports()); + $this->assertEquals($positive_before + 1, $this->connection_reports->api_reports_count['positive']); + $this->assertEquals($positive_before + 1, $apbct->data['connection_reports_count_api']['positive']); + $this->assertEquals($moderate_before, $this->connection_reports->reports_count); + } + + public function testApiNegativeReport() + { + $this->connection_reports->wipeReportsData(); + + $this->connection_reports->handleApiRequest( + 'spam_check_cms', + array( + 'method_name' => 'spam_check_cms', + 'auth_key' => 'secret_key', + 'user_token' => 'secret_token', + 'data' => str_repeat('a', 1000), + ), + array('error' => 'cURL error 28: Operation timed out'), + 'https://api.cleantalk.org' + ); + + $data = $this->connection_reports->remoteCallOutput(); + $this->assertCount(1, $data); + $this->assertEquals('api', $data[0]['source']); + $this->assertEquals('spam_check_cms', $data[0]['method_name']); + $this->assertEquals('cURL error 28: Operation timed out', $data[0]['lib_report']); + $this->assertEquals('https://api.cleantalk.org', $data[0]['failed_work_urls']); + $this->assertStringNotContainsString('secret_key', $data[0]['request_content']); + $this->assertStringNotContainsString('secret_token', $data[0]['request_content']); + $this->assertStringContainsString('[truncated]', $data[0]['request_content']); + $html = $this->connection_reports->prepareNegativeReportsHtmlForSettingsPage(); + $this->assertStringContainsString('apbct_negative_reports_table_api', $html); + $this->assertStringContainsString('spam_check_cms', $html); + $this->assertStringNotContainsString("id='apbct_negative_reports_table'", $html); + $this->assertStringNotContainsString('Blocked via JS', $html); + $this->assertTrue($this->connection_reports->hasUnsentReports()); + } + + public function testApiExcludedMethodIsIgnored() + { + $this->connection_reports->wipeReportsData(); + $before = $this->connection_reports->api_reports_count; + + $this->connection_reports->handleApiRequest('2s_blacklists_db', array(), array('error' => 'cURL error'), 'https://api.cleantalk.org'); + + $this->assertFalse($this->connection_reports->hasNegativeReports()); + $this->assertEquals($before, $this->connection_reports->api_reports_count); + } + + public function testSeparateRotationLimits() + { + $this->connection_reports->wipeReportsData(); + + $this->ct_response->errno = 500; + $this->ct_response->errstr = 'MODERATE ERROR'; + for ($i = 0; $i < 5; $i++) { + $this->connection_reports->handleRequest($this->ct_request, $this->ct_response, 0); + } + for ($i = 0; $i < 25; $i++) { + $this->connection_reports->handleApiRequest('notice_paid_till', array(), array('error' => 'API ERROR ' . $i), 'https://api.cleantalk.org'); + } + + $data = $this->connection_reports->remoteCallOutput(); + $moderate = array_filter($data, function ($row) { + return $row['source'] !== 'api'; + }); + $api = array_filter($data, function ($row) { + return $row['source'] === 'api'; + }); + + $this->assertCount(5, $moderate); + $this->assertCount(20, $api); + } + + public function testEmailContentHasBothListsAndAggregatesDuplicates() + { + $this->connection_reports->wipeReportsData(); + + for ($i = 0; $i < 3; $i++) { + $this->connection_reports->handleApiRequest('notice_paid_till', array(), array('error' => 'cURL error 6'), 'https://api.cleantalk.org'); + } + $this->connection_reports->handleApiRequest('email_check', array(), array('error' => 'cURL error 6'), 'https://api.cleantalk.org'); + + $data = $this->connection_reports->remoteCallOutput(); + $method = new ReflectionMethod($this->connection_reports, 'prepareEmailContent'); + $method->setAccessible(true); + $html = $method->invoke($this->connection_reports, $data); + + $this->assertStringContainsString('Anti-Spam (moderate servers) connection reports', $html); + $this->assertStringContainsString('API connection reports', $html); + // No moderate reports - the section is kept with a notice + $this->assertStringContainsString('No new reports.', $html); + // 4 API reports are aggregated into 2 rows + $this->assertEquals(1, substr_count($html, 'notice_paid_till')); + $this->assertEquals(1, substr_count($html, 'email_check')); + $this->assertStringContainsString('notice_paid_tillcURL error 6https://api.cleantalk.org3', $html); + + $this->ct_response->errno = 500; + $this->ct_response->errstr = 'MODERATE ERROR'; + $this->connection_reports->handleRequest($this->ct_request, $this->ct_response, 0); + $html = $method->invoke($this->connection_reports, $this->connection_reports->remoteCallOutput()); + + $this->assertStringNotContainsString('No new reports.', $html); + $this->assertStringContainsString('MODERATE ERROR', $html); + } }