From e8b9bc8f988e543aa03a9618904a1a7ebca43e7f Mon Sep 17 00:00:00 2001 From: Konstantin Kovshenin Date: Fri, 21 Apr 2017 15:05:44 +0300 Subject: [PATCH] Helper: First pass at merging the Helper plugin into WooCommerce * Connect a site to a WooCommerce.com account * List available product subscriptions * Activate/deactivate subscriptions and extensions * Serve updates for active subscriptions --- assets/css/helper-rtl.css | 1 + assets/css/helper.css | 1 + assets/css/helper.scss | 160 ++++ includes/admin/class-wc-admin.php | 7 + includes/admin/helper/class-wc-helper-api.php | 112 +++ .../admin/helper/class-wc-helper-options.php | 44 + .../helper/class-wc-helper-plugin-info.php | 60 ++ .../admin/helper/class-wc-helper-updater.php | 218 +++++ includes/admin/helper/class-wc-helper.php | 789 ++++++++++++++++++ includes/admin/helper/views/html-main.php | 185 ++++ .../admin/helper/views/html-oauth-start.php | 20 + .../helper/views/html-section-account.php | 16 + .../helper/views/html-section-notices.php | 7 + 13 files changed, 1620 insertions(+) create mode 100644 assets/css/helper-rtl.css create mode 100644 assets/css/helper.css create mode 100644 assets/css/helper.scss create mode 100644 includes/admin/helper/class-wc-helper-api.php create mode 100644 includes/admin/helper/class-wc-helper-options.php create mode 100644 includes/admin/helper/class-wc-helper-plugin-info.php create mode 100644 includes/admin/helper/class-wc-helper-updater.php create mode 100644 includes/admin/helper/class-wc-helper.php create mode 100644 includes/admin/helper/views/html-main.php create mode 100644 includes/admin/helper/views/html-oauth-start.php create mode 100644 includes/admin/helper/views/html-section-account.php create mode 100644 includes/admin/helper/views/html-section-notices.php diff --git a/assets/css/helper-rtl.css b/assets/css/helper-rtl.css new file mode 100644 index 00000000000..0d3f6a5388c --- /dev/null +++ b/assets/css/helper-rtl.css @@ -0,0 +1 @@ +.wc-helper .alternate,.wc-helper .striped>tbody>:nth-child(odd),.wc-helper ul.striped>:nth-child(odd){background-color:#fff}.wc-helper .comment-ays,.wc-helper .feature-filter,.wc-helper .imgedit-group,.wc-helper .popular-tags,.wc-helper .stuffbox,.wc-helper .widgets-holder-wrap,.wc-helper .wp-editor-container,.wc-helper p.popular-tags,.wc-helper table.widefat{padding-top:5px}.wc-helper .widefat tfoot tr td,.wc-helper .widefat tfoot tr th,.wc-helper .widefat thead tr td,.wc-helper .widefat thead tr th{color:#32373c;padding-top:10px;padding-bottom:15px}.wc-helper .widefat td{border-bottom:1px solid #e5e5e5;padding-top:15px;padding-bottom:15px}.wc-helper .product-name{color:#0073AA}.wc-helper td.color-bar{border-right:solid 4px transparent}.wc-helper td.color-bar.expired{border-right-color:#B81C23}.wc-helper td.color-bar.expiring{border-right-color:orange}.wc-helper td.color-bar.expiring.update-available,.wc-helper td.color-bar.update-available{border-right-color:#8FAE1B}.wc-helper .connect-wrapper{background-color:#fff;margin-bottom:25px;padding:20px;border:1px solid #e5e5e5}.wc-helper .connected{display:inline-block;vertical-align:top}.wc-helper .connected img{border:1px solid #e5e5e5;vertical-align:top}.wc-helper .connected p{display:inline-block;margin:10px 20px 0 0}.wc-helper .buttons{display:block;margin-top:10px}.wc-helper .start-container{background-color:#fff;padding:45px 30px 20px 20px;position:relative;overflow:hidden;border-right:4px solid #cc99c2}.wc-helper .start-container::before{content:"\e01C";font-family:WooCommerce;text-align:center;line-height:1;color:#eee2ec;display:block;width:1em;font-size:192px;top:65%;left:-3%;position:absolute}.wc-helper .start-container h2{font-size:24px;line-height:29px;position:relative}.wc-helper .start-container p{font-size:16px;margin-bottom:30px;position:relative}.wc-helper .wp-core-ui .button-primary{background:#bb77ae;border-color:#A36597;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;color:#fff;text-shadow:0 -1px 1px #A36597,-1px 0 1px #A36597,0 1px 1px #A36597,1px 0 1px #A36597}.wc-helper .wp-core-ui a.button-primary:active,.wc-helper .wp-core-ui a.button-primary:focus,.wc-helper .wp-core-ui a.button-primary:hover{background:#A36597;border-color:#A36597;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597} \ No newline at end of file diff --git a/assets/css/helper.css b/assets/css/helper.css new file mode 100644 index 00000000000..6191c247095 --- /dev/null +++ b/assets/css/helper.css @@ -0,0 +1 @@ +.wc-helper .alternate,.wc-helper .striped>tbody>:nth-child(odd),.wc-helper ul.striped>:nth-child(odd){background-color:#fff}.wc-helper .comment-ays,.wc-helper .feature-filter,.wc-helper .imgedit-group,.wc-helper .popular-tags,.wc-helper .stuffbox,.wc-helper .widgets-holder-wrap,.wc-helper .wp-editor-container,.wc-helper p.popular-tags,.wc-helper table.widefat{padding-top:5px}.wc-helper .widefat tfoot tr td,.wc-helper .widefat tfoot tr th,.wc-helper .widefat thead tr td,.wc-helper .widefat thead tr th{color:#32373c;padding-top:10px;padding-bottom:15px}.wc-helper .widefat td{border-bottom:1px solid #e5e5e5;padding-top:15px;padding-bottom:15px}.wc-helper .product-name{color:#0073AA}.wc-helper td.color-bar{border-left:solid 4px transparent}.wc-helper td.color-bar.expired{border-left-color:#B81C23}.wc-helper td.color-bar.expiring{border-left-color:orange}.wc-helper td.color-bar.expiring.update-available,.wc-helper td.color-bar.update-available{border-left-color:#8FAE1B}.wc-helper .connect-wrapper{background-color:#fff;margin-bottom:25px;padding:20px;border:1px solid #e5e5e5}.wc-helper .connected{display:inline-block;vertical-align:top}.wc-helper .connected img{border:1px solid #e5e5e5;vertical-align:top}.wc-helper .connected p{display:inline-block;margin:10px 0 0 20px}.wc-helper .buttons{display:block;margin-top:10px}.wc-helper .start-container{background-color:#fff;padding:45px 20px 20px 30px;position:relative;overflow:hidden;border-left:4px solid #cc99c2}.wc-helper .start-container::before{content:"\e01C";font-family:WooCommerce;text-align:center;line-height:1;color:#eee2ec;display:block;width:1em;font-size:192px;top:65%;right:-3%;position:absolute}.wc-helper .start-container h2{font-size:24px;line-height:29px;position:relative}.wc-helper .start-container p{font-size:16px;margin-bottom:30px;position:relative}.wc-helper .wp-core-ui .button-primary{background:#bb77ae;border-color:#A36597;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;color:#fff;text-shadow:0 -1px 1px #A36597,1px 0 1px #A36597,0 1px 1px #A36597,-1px 0 1px #A36597}.wc-helper .wp-core-ui a.button-primary:active,.wc-helper .wp-core-ui a.button-primary:focus,.wc-helper .wp-core-ui a.button-primary:hover{background:#A36597;border-color:#A36597;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597;box-shadow:inset 0 1px 0 rgba(255,255,255,.25),0 1px 0 #A36597} \ No newline at end of file diff --git a/assets/css/helper.scss b/assets/css/helper.scss new file mode 100644 index 00000000000..032b14ade86 --- /dev/null +++ b/assets/css/helper.scss @@ -0,0 +1,160 @@ +/*------------------------------------------------------------------------------ + General table styling +------------------------------------------------------------------------------*/ + +.wc-helper { + .striped > tbody > :nth-child(odd), + ul.striped > :nth-child(odd), + .alternate { + background-color: #ffffff; + } + + table.widefat, .wp-editor-container, .stuffbox, p.popular-tags, .widgets-holder-wrap, .popular-tags, .feature-filter, .imgedit-group, .comment-ays { + padding-top: 5px; + } + + .widefat thead tr th, .widefat thead tr td, .widefat tfoot tr th, .widefat tfoot tr td { + color: #32373c; + padding-top: 10px; + padding-bottom: 15px; + } + + .widefat td { + border-bottom: 1px solid #e5e5e5; + padding-top: 15px; + padding-bottom: 15px; + } + + .product-name { + color: #0073AA; + } +} + +/*------------------------------------------------------------------------------ + Expired notification bar +------------------------------------------------------------------------------*/ + +.wc-helper { + td.color-bar { + border-left: solid 4px transparent; + } + + td.color-bar.expired { + border-left-color: #B81C23; + } + + td.color-bar.expiring { + border-left-color: orange; + } + + td.color-bar.update-available { + border-left-color: #8FAE1B; + } + + td.color-bar.expiring.update-available { + border-left-color: #8FAE1B; + } +} + +/*------------------------------------------------------------------------------ + Connected account table +------------------------------------------------------------------------------*/ + +.wc-helper { + .connect-wrapper { + background-color: #fff; + margin-bottom: 25px; + padding: 20px; + border: 1px solid #e5e5e5; + } + + .connected { + display: inline-block; + vertical-align: top; + } + + .connected img { + border: 1px solid #e5e5e5; + vertical-align: top; + } + + .connected p { + display: inline-block; + margin: 10px 0 0 20px; + } + + .buttons { + display: block; + margin-top: 10px; + } +} + +/*------------------------------------------------------------------------------ + Initial connection screen +------------------------------------------------------------------------------*/ + +.wc-helper { + .start-container { + background-color: #ffffff; + padding: 45px 20px 20px 30px; + position: relative; + overflow: hidden; + border-left: 4px solid #cc99c2; + } + + .start-container::before { + content: "\e01C"; + font-family: WooCommerce; + text-align: center; + line-height: 1; + color: #eee2ec; + display: block; + width: 1em; + font-size: 192px; + top: 65%; + right: -3%; + position: absolute; + } + + .start-container h2 { + font-size: 24px; + line-height: 29px; + position: relative; + } + + .start-container p { + font-size: 16px; + margin-bottom: 30px; + position: relative; + } + + .wp-core-ui .button-primary { + background: #bb77ae; + border-color: #A36597; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + color: #fff; + text-shadow: 0 -1px 1px #A36597, 1px 0 1px #A36597, 0 1px 1px #A36597, -1px 0 1px #A36597; + } + + .wp-core-ui a.button-primary:hover { + background: #A36597; + border-color: #A36597; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + } + + .wp-core-ui a.button-primary:focus { + background: #A36597; + border-color: #A36597; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + } + + .wp-core-ui a.button-primary:active { + background: #A36597; + border-color: #A36597; + -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + box-shadow: inset 0 1px 0 rgba(255,255,255,.25), 0 1px 0 #A36597; + } +} diff --git a/includes/admin/class-wc-admin.php b/includes/admin/class-wc-admin.php index 0a73b97efb9..2d56155834f 100644 --- a/includes/admin/class-wc-admin.php +++ b/includes/admin/class-wc-admin.php @@ -73,6 +73,13 @@ class WC_Admin { if ( defined( 'WP_LOAD_IMPORTERS' ) ) { include_once( dirname( __FILE__ ) . '/class-wc-admin-importers.php' ); } + + // Helper + include_once( dirname( __FILE__ ) . '/helper/class-wc-helper-options.php' ); + include_once( dirname( __FILE__ ) . '/helper/class-wc-helper-api.php' ); + include_once( dirname( __FILE__ ) . '/helper/class-wc-helper-updater.php' ); + include_once( dirname( __FILE__ ) . '/helper/class-wc-helper-plugin-info.php' ); + include_once( dirname( __FILE__ ) . '/helper/class-wc-helper.php' ); } /** diff --git a/includes/admin/helper/class-wc-helper-api.php b/includes/admin/helper/class-wc-helper-api.php new file mode 100644 index 00000000000..aa854d9ce84 --- /dev/null +++ b/includes/admin/helper/class-wc-helper-api.php @@ -0,0 +1,112 @@ + parse_url( $url, PHP_URL_HOST ), + 'request_uri' => $request_uri, + 'method' => ! empty( $args['method'] ) ? $args['method'] : 'GET', + ); + + if ( ! empty( $args['body'] ) ) { + $data['body'] = $args['body']; + } + + $signature = hash_hmac( 'sha256', json_encode( $data ), $auth['access_token_secret'] ); + if ( empty( $args['headers'] ) ) { + $args['headers'] = array(); + } + + $args['headers'] = array( + 'Authorization' => 'Bearer ' . $auth['access_token'], + 'X-Woo-Signature' => $signature, + ); + } + + /** + * Wrapper for self::request(). + */ + public static function get( $endpoint, $args = array() ) { + $args['method'] = 'GET'; + return self::request( $endpoint, $args ); + } + + /** + * Wrapper for self::request(). + */ + public static function post( $endpoint, $args = array() ) { + $args['method'] = 'POST'; + return self::request( $endpoint, $args ); + } + + /** + * Using the API base, form a request URL from a given endpoint. + * + * @param string $endpoint The endpoint to request. + * + * @return string The absolute endpoint URL. + */ + public static function url( $endpoint ) { + $endpoint = ltrim( $endpoint, '/' ); + $endpoint = sprintf( '%s/%s', self::$api_base, $endpoint ); + $endpoint = esc_url_raw( $endpoint ); + return $endpoint; + } +} + +WC_Helper_API::load(); diff --git a/includes/admin/helper/class-wc-helper-options.php b/includes/admin/helper/class-wc-helper-options.php new file mode 100644 index 00000000000..4bf5d58e3c8 --- /dev/null +++ b/includes/admin/helper/class-wc-helper-options.php @@ -0,0 +1,44 @@ +slug ) ) { + return $response; + } + + $found_plugin = null; + + // Look through local Woo plugins by slugs. + foreach( WC_Helper::get_local_woo_plugins() as $plugin ) { + $slug = dirname( $plugin['_filename'] ); + if ( dirname( $plugin['_filename'] ) == $args->slug ) { + $plugin['_slug'] = $args->slug; + $found_plugin = $plugin; + break; + } + } + + if ( ! $found_plugin ) { + return $response; + } + + // Fetch the product information from the Helper API. + $request = WC_Helper_API::get( add_query_arg( array( + 'product_id' => absint( $plugin['_product_id'] ), + 'product_slug' => rawurlencode( $plugin['_slug'] ), + ), 'info' ), array( 'authenticated' => true ) ); + + $results = json_decode( wp_remote_retrieve_body( $request ), true ); + if ( ! empty( $results ) ) { + $response = (object) $results; + } + + return $response; + } +} + +WC_Helper_Plugin_Info::load(); diff --git a/includes/admin/helper/class-wc-helper-updater.php b/includes/admin/helper/class-wc-helper-updater.php new file mode 100644 index 00000000000..5c0257b187e --- /dev/null +++ b/includes/admin/helper/class-wc-helper-updater.php @@ -0,0 +1,218 @@ + 'woo-' . $plugin['_product_id'], + 'slug' => $data['slug'], + 'plugin' => $filename, + 'new_version' => $data['version'], + 'url' => $data['url'], + 'package' => '', + 'upgrade_notice' => $data['upgrade_notice'], + ); + + if ( self::_has_active_subscription( $plugin['_product_id'] ) ) { + $item['package'] = $data['package']; + } + + if ( version_compare( $plugin['Version'], $data['version'], '<' ) ) { + $transient->response[ $filename ] = (object) $item; + unset( $transient->no_update[ $filename ] ); + } else { + $transient->no_update[ $filename ] = (object) $item; + unset( $transient->response[ $filename ] ); + } + } + + return $transient; + } + + public static function transient_update_themes( $transient ) { + $update_data = self::get_update_data(); + + foreach ( WC_Helper::get_local_woo_themes() as $theme ) { + if ( empty( $update_data[ $theme['_product_id'] ] ) ) { + continue; + } + + $data = $update_data[ $theme['_product_id'] ]; + $slug = $theme['_stylesheet']; + + $item = array( + 'theme' => $slug, + 'new_version' => $data['version'], + 'url' => $data['url'], + 'package' => '', + ); + + if ( self::_has_active_subscription( $theme['_product_id'] ) ) { + $item['package'] = $data['package']; + } + + if ( version_compare( $theme['Version'], $data['version'], '<' ) ) { + $transient->response[ $slug ] = $item; + } else { + unset( $transient->response[ $slug ] ); + $transient->checked[ $slug ] = $data['version']; + } + } + + return $transient; + } + + /** + * Get update data for all extensions. + * + * Scans through all subscriptions for the connected user, as well + * as all Woo extensions without a subscription, and obtains update + * data for each product. + * + * @return array Update data {product_id => data} + */ + public static function get_update_data() { + $payload = array(); + + // Scan subscriptions. + foreach ( WC_Helper::get_subscriptions() as $subscription ) { + $payload[ $subscription['product_id'] ] = array( + 'product_id' => $subscription['product_id'], + 'file_id' => '', + ); + } + + // Scan local plugins which may or may not have a subscription. + foreach ( WC_Helper::get_local_woo_plugins() as $data ) { + if ( ! isset( $payload[ $data['_product_id'] ] ) ) { + $payload[ $data['_product_id'] ] = array( + 'product_id' => $data['_product_id'], + ); + } + + $payload[ $data['_product_id'] ]['file_id'] = $data['_file_id']; + } + + // Scan local themes + foreach ( WC_Helper::get_local_woo_themes() as $data ) { + if ( ! isset( $payload[ $data['_product_id'] ] ) ) { + $payload[ $data['_product_id'] ] = array( + 'product_id' => $data['_product_id'], + ); + } + + $payload[ $data['_product_id'] ]['file_id'] = $data['_file_id']; + } + + return self::_update_check( $payload ); + } + + /** + * Run an update check API call. + * + * The call is cached based on the payload (product ids, file ids). If + * the payload changes, the cache is going to miss. + * + * @return array Update data for each requested product. + */ + private static function _update_check( $payload ) { + ksort( $payload ); + $hash = md5( json_encode( $payload ) ); + + $cache_key = '_woocommerce_helper_updates'; + if ( false !== ( $data = get_transient( $cache_key ) ) ) { + if ( hash_equals( $hash, $data['hash'] ) ) { + return $data['products']; + } + } + + $data = array( + 'hash' => $hash, + 'updated' => time(), + 'products' => array(), + 'errors' => array(), + ); + + $request = WC_Helper_API::post( 'update-check', array( + 'body' => json_encode( array( 'products' => $payload ) ), + 'authenticated' => true, + ) ); + + if ( wp_remote_retrieve_response_code( $request ) != 200 ) { + $data['errors'][] = 'http-error'; + } else { + $data['products'] = json_decode( wp_remote_retrieve_body( $request ), true ); + } + + set_transient( $cache_key, $data, 12 * HOUR_IN_SECONDS ); + return $data['products']; + } + + /** + * Check for an active subscription. + * + * Checks a given product id against all subscriptions on + * the current site. Returns true if at least one active + * subscription is found. + * + * @param int $product_id The product id to look for. + * + * @return bool True if active subscription found. + */ + private static function _has_active_subscription( $product_id ) { + if ( ! isset( $auth ) ) { + $auth = WC_Helper_Options::get( 'auth' ); + } + + if ( ! isset( $subscriptions ) ) { + $subscriptions = WC_Helper::get_subscriptions(); + } + + if ( empty( $auth['site_id'] ) || empty( $subscriptions ) ) { + return false; + } + + // Check for an active subscription. + foreach ( $subscriptions as $subscription ) { + if ( $subscription['product_id'] != $product_id ) { + continue; + } + + if ( in_array( absint( $auth['site_id'] ), $subscription['connections'] ) ) { + return true; + } + } + + return false; + } + + /** + * Flushes cached update data. + */ + public static function flush_updates_cache() { + delete_transient( '_woocommerce_helper_updates' ); + } +} + +WC_Helper_Updater::load(); diff --git a/includes/admin/helper/class-wc-helper.php b/includes/admin/helper/class-wc-helper.php new file mode 100644 index 00000000000..03e35c500e0 --- /dev/null +++ b/includes/admin/helper/class-wc-helper.php @@ -0,0 +1,789 @@ + 'wc-helper', + 'wc-helper-connect' => 1, + 'wc-helper-nonce' => wp_create_nonce( 'connect' ), + ), admin_url( 'admin.php' ) ); + + include( self::get_view_filename( 'html-oauth-start.php' ) ); + return; + } + $disconnect_url = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-disconnect' => 1, + 'wc-helper-nonce' => wp_create_nonce( 'disconnect' ), + ), admin_url( 'admin.php' ) ); + + $refresh_url = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-refresh' => 1, + 'wc-helper-nonce' => wp_create_nonce( 'refresh' ), + ), admin_url( 'admin.php' ) ); + + // Installed plugins and themes, with or without an active subscription. + $woo_plugins = self::get_local_woo_plugins(); + $woo_themes = self::get_local_woo_themes(); + + $site_id = absint( $auth['site_id'] ); + $subscriptions = self::get_subscriptions(); + $updates = WC_Helper_Updater::get_update_data(); + $subscriptions_product_ids = wp_list_pluck( $subscriptions, 'product_id' ); + + foreach ( $subscriptions as &$subscription ) { + $subscription['active'] = in_array( $site_id, $subscription['connections'] ); + + $subscription['activate_url'] = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-activate' => 1, + 'wc-helper-product-key' => $subscription['product_key'], + 'wc-helper-product-id' => $subscription['product_id'], + 'wc-helper-nonce' => wp_create_nonce( 'activate:' . $subscription['product_key'] ), + ), admin_url( 'admin.php' ) ); + + $subscription['deactivate_url'] = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-deactivate' => 1, + 'wc-helper-product-key' => $subscription['product_key'], + 'wc-helper-product-id' => $subscription['product_id'], + 'wc-helper-nonce' => wp_create_nonce( 'deactivate:' . $subscription['product_key'] ), + ), admin_url( 'admin.php' ) ); + + $subscription['local'] = array( + 'installed' => false, + 'active' => false, + 'version' => null, + ); + + $local = wp_list_filter( array_merge( $woo_plugins, $woo_themes ), array( '_product_id' => $subscription['product_id'] ) ); + + if ( ! empty( $local ) ) { + $local = array_shift( $local ); + $subscription['local']['installed'] = true; + $subscription['local']['version'] = $local['Version']; + + if ( $local['_type'] == 'plugin' ) { + if ( is_plugin_active( $local['_filename'] ) ) { + $subscription['local']['active'] = true; + } elseif ( is_multisite() && is_plugin_active_for_network( $local['_filename'] ) ) { + $subscription['local']['active'] = true; + } + } elseif ( $local['_type'] == 'theme' ) { + if ( in_array( $local['_stylesheet'], array( get_stylesheet(), get_template() ) ) ) { + $subscription['local']['active'] = true; + } + } + } + } + + // Break the by-ref. + unset( $subscription ); + + // Installed products without a subscription. + $no_subscriptions = array(); + foreach ( array_merge( $woo_plugins, $woo_themes ) as $filename => $data ) { + if ( in_array( $data['_product_id'], $subscriptions_product_ids ) ) { + continue; + } + + $no_subscriptions[ $filename ] = $data; + } + + // We have an active connection. + include( self::get_view_filename( 'html-main.php' ) ); + return; + } + + /** + * Enqueue admin scripts and styles. + */ + public static function admin_enqueue_scripts() { + $screen = get_current_screen(); + if ( $screen->id == 'woocommerce_page_wc-helper' ) { + wp_enqueue_style( 'woocommerce-helper', WC()->plugin_url() . '/assets/css/helper.css', array(), WC_VERSION ); + } + } + + /** + * Various success/error notices. + * + * Runs during admin page render, so no headers/redirects here. + * + * @return array Array pairs of message/type strings with notices. + */ + private static function _get_return_notices() { + $return_status = isset( $_GET['wc-helper-status'] ) ? $_GET['wc-helper-status'] : null; + $notices = array(); + + switch ( $return_status ) { + case 'activate-success': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $notices[] = array( + 'message' => sprintf( '%s activated successfully. You will now receive updates for this product.', esc_html( $subscription['product_name'] ) ), + 'type' => 'updated', + ); + break; + + case 'activate-error': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $notices[] = array( + 'message' => sprintf( 'An error has occurred when activating %s. Please try again later.', esc_html( $subscription['product_name'] ) ), + 'type' => 'error', + ); + break; + + case 'deactivate-success': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $local = self::_get_local_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $message = sprintf( 'Subscription for %s deactivated successfully. You will no longer receive updates for this product.', esc_html( $subscription['product_name'] ) ); + + if ( $local && is_plugin_active( $local['_filename'] ) && current_user_can( 'activate_plugins' ) ) { + $deactivate_plugin_url = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-deactivate-plugin' => 1, + 'wc-helper-product-id' => $subscription['product_id'], + 'wc-helper-nonce' => wp_create_nonce( 'deactivate-plugin:' . $subscription['product_id'] ), + ), admin_url( 'admin.php' ) ); + + $message = sprintf( 'Subscription for %1$s deactivated successfully. You will no longer receive updates for this product. Click here if you wish to deactive the plugin as well.', esc_html( $subscription['product_name'] ), esc_url( $deactivate_plugin_url ) ); + } + + $notices[] = array( + 'message' => $message, + 'type' => 'updated', + ); + break; + + case 'deactivate-error': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $notices[] = array( + 'message' => sprintf( 'An error has occurred when deactivating the subscription for %s. Please try again later.', esc_html( $subscription['product_name'] ) ), + 'type' => 'error', + ); + break; + + case 'deactivate-plugin-success': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $notices[] = array( + 'message' => sprintf( 'The extension %s has been deactivated successfully.', esc_html( $subscription['product_name'] ) ), + 'type' => 'updated', + ); + break; + + case 'deactivate-plugin-error': + $subscription = self::_get_subscription_from_product_id( absint( $_GET['wc-helper-product-id'] ) ); + $notices[] = array( + 'message' => sprintf( 'An error has occurred when deactivating the extension %1$s. Please proceed to the Plugins screen to deactivate it manually.', esc_html( $subscription['product_name'] ), admin_url( 'plugins.php' ) ), + 'type' => 'error', + ); + break; + + case 'helper-connected': + $notices[] = array( + 'message' => 'You have successfully connected your store to WooCommerce.com', + 'type' => 'updated', + ); + break; + + case 'helper-disconnected': + $notices[] = array( + 'message' => 'You have successfully disconnected your store from WooCommerce.com', + 'type' => 'updated', + ); + break; + + case 'helper-refreshed': + $notices[] = array( + 'message' => 'Authentication and subscription caches refreshed successfully.', + 'type' => 'updated', + ); + break; + } + + return $notices; + } + + /** + * Various early-phase actions with possible redirects. + */ + public static function current_screen( $screen ) { + if ( $screen->id != 'woocommerce_page_wc-helper' ) { + return; + } + + if ( ! empty( $_GET['wc-helper-connect'] ) ) { + return self::_helper_auth_connect(); + } + + if ( ! empty( $_GET['wc-helper-return'] ) ) { + return self::_helper_auth_return(); + } + + if ( ! empty( $_GET['wc-helper-disconnect'] ) ) { + return self::_helper_auth_disconnect(); + } + + if ( ! empty( $_GET['wc-helper-refresh'] ) ) { + return self::_helper_auth_refresh(); + } + + if ( ! empty( $_GET['wc-helper-activate'] ) ) { + return self::_helper_subscription_activate(); + } + + if ( ! empty( $_GET['wc-helper-deactivate'] ) ) { + return self::_helper_subscription_deactivate(); + } + + if ( ! empty( $_GET['wc-helper-deactivate-plugin'] ) ) { + return self::_helper_plugin_deactivate(); + } + } + + /** + * Initiate a new OAuth connection. + */ + private static function _helper_auth_connect() { + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'connect' ) ) { + self::log( 'Could not verify nonce in _helper_auth_connect' ); + wp_die( 'Could not verify nonce' ); + } + + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-return' => 1, + 'wc-helper-nonce' => wp_create_nonce( 'connect' ), + ), admin_url( 'admin.php' ) ); + + $request = WC_Helper_API::post( 'oauth/request_token', array( + 'body' => array( + 'home_url' => home_url(), + 'redirect_uri' => $redirect_uri, + ), + ) ); + + $code = wp_remote_retrieve_response_code( $request ); + + if ( $code != 200 ) { + self::log( sprintf( 'Call to oauth/request_token returned a non-200 response code (%d)', $code ) ); + wp_die( 'Something went wrong' ); + } + + $secret = json_decode( wp_remote_retrieve_body( $request ) ); + if ( empty( $secret ) ) { + self::log( sprintf( 'Call to oauth/request_token returned an invalid body: %s', wp_remote_retrieve_body( $request ) ) ); + wp_die( 'Something went wrong' ); + } + + $connect_url = add_query_arg( array( + 'home_url' => rawurlencode( home_url() ), + 'redirect_uri' => rawurlencode( $redirect_uri ), + 'secret' => rawurlencode( $secret ), + ), WC_Helper_API::url( 'oauth/authorize' ) ); + + wp_redirect( esc_url_raw( $connect_url ) ); + die(); + } + + /** + * Return from WooCommerce.com OAuth flow. + */ + private static function _helper_auth_return() { + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'connect' ) ) { + self::log( 'Could not verify nonce in _helper_auth_return' ); + wp_die( 'Something went wrong' ); + } + + // Bail if the user clicked deny. + if ( ! empty( $_GET['deny'] ) ) { + wp_safe_redirect( admin_url( 'admin.php?page=wc-helper' ) ); + die(); + } + + // We do need a request token... + if ( empty( $_GET['request_token'] ) ) { + self::log( 'Request token not found in _helper_auth_return' ); + wp_die( 'Something went wrong' ); + } + + // Obtain an access token. + $request = WC_Helper_API::post( 'oauth/access_token', array( + 'body' => array( + 'request_token' => $_GET['request_token'], + 'home_url' => home_url(), + ), + ) ); + + $code = wp_remote_retrieve_response_code( $request ); + + if ( $code !== 200 ) { + self::log( sprintf( 'Call to oauth/access_token returned a non-200 response code (%d)', $code ) ); + wp_die( 'Something went wrong' ); + } + + $access_token = json_decode( wp_remote_retrieve_body( $request ), true ); + if ( ! $access_token ) { + self::log( sprintf( 'Call to oauth/access_token returned an invalid body: %s', wp_remote_retrieve_body( $request ) ) ); + wp_die( 'Something went wrong' ); + } + + WC_Helper_Options::update( 'auth', array( + 'access_token' => $access_token['access_token'], + 'access_token_secret' => $access_token['access_token_secret'], + 'site_id' => $access_token['site_id'], + 'user_id' => get_current_user_id(), + 'updated' => time(), + ) ); + + // Obtain the connected user info. + if ( ! self::_flush_authentication_cache() ) { + self::log( 'Could not obtain connected user info in _helper_auth_return' ); + WC_Helper_Options::update( 'auth', array() ); + wp_die( 'Something went wrong.' ); + } + + self::_flush_subscriptions_cache(); + + wp_safe_redirect( add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => 'helper-connected', + ), admin_url( 'admin.php' ) ) ); + die(); + } + + /** + * Disconnect from WooCommerce.com, clear OAuth tokens. + */ + private static function _helper_auth_disconnect() { + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'disconnect' ) ) { + self::log( 'Could not verify nonce in _helper_auth_disconnect' ); + wp_die( 'Could not verify nonce' ); + } + + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => 'helper-disconnected', + ), admin_url( 'admin.php' ) ); + + + $result = WC_Helper_API::post( 'oauth/invalidate_token', array( + 'authenticated' => true, + ) ); + + WC_Helper_Options::update( 'auth', array() ); + WC_Helper_Options::update( 'auth_user_data', array() ); + + self::_flush_subscriptions_cache(); + self::_flush_updates_cache(); + + wp_safe_redirect( $redirect_uri ); + die(); + } + + /** + * User hit the Refresh button, clear all caches. + */ + private static function _helper_auth_refresh() { + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'refresh' ) ) { + self::log( 'Could not verify nonce in _helper_auth_refresh' ); + wp_die( 'Could not verify nonce' ); + } + + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => 'helper-refreshed', + ), admin_url( 'admin.php' ) ); + + self::_flush_authentication_cache(); + self::_flush_subscriptions_cache(); + self::_flush_updates_cache(); + + wp_safe_redirect( $redirect_uri ); + die(); + } + + /** + * Active a product subscription. + */ + private static function _helper_subscription_activate() { + $product_key = $_GET['wc-helper-product-key']; + $product_id = absint( $_GET['wc-helper-product-id'] ); + + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'activate:' . $product_key ) ) { + self::log( 'Could not verify nonce in _helper_subscription_activate' ); + wp_die( 'Could not verify nonce' ); + } + + $request = WC_Helper_API::post( 'activate', array( + 'authenticated' => true, + 'body' => json_encode( array( + 'product_key' => $product_key, + ) ), + ) ); + + $activated = wp_remote_retrieve_response_code( $request ) == 200; + $body = json_decode( wp_remote_retrieve_body( $request ), true ); + if ( ! $activated && ! empty( $body['code'] ) && $body['code'] == 'already_connected' ) { + $activated = true; + } + + // Attempt to activate this plugin. + $local = self::_get_local_from_product_id( $product_id ); + if ( $local && $local['_type'] == 'plugin' && current_user_can( 'activate_plugins' ) && ! is_plugin_active( $local['_filename'] ) ) { + activate_plugin( $local['_filename'] ); + } + + self::_flush_subscriptions_cache(); + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => $activated ? 'activate-success' : 'activate-error', + 'wc-helper-product-id' => $product_id, + ), admin_url( 'admin.php' ) ); + + wp_safe_redirect( $redirect_uri ); + die(); + } + + /** + * Deactivate a product subscription. + */ + private static function _helper_subscription_deactivate() { + $product_key = $_GET['wc-helper-product-key']; + $product_id = absint( $_GET['wc-helper-product-id'] ); + + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'deactivate:' . $product_key ) ) { + self::log( 'Could not verify nonce in _helper_subscription_deactivate' ); + wp_die( 'Could not verify nonce' ); + } + + $request = WC_Helper_API::post( 'deactivate', array( + 'authenticated' => true, + 'body' => json_encode( array( + 'product_key' => $product_key, + ) ), + ) ); + + $code = wp_remote_retrieve_response_code( $request ); + $deactivated = $code == 200; + if ( ! $deactivated ) { + self::log( sprintf( 'Deactivate API call returned a non-200 response code (%d)', $code ) ); + } + + self::_flush_subscriptions_cache(); + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => $deactivated ? 'deactivate-success' : 'deactivate-error', + 'wc-helper-product-id' => $product_id, + ), admin_url( 'admin.php' ) ); + + wp_safe_redirect( $redirect_uri ); + die(); + } + + /** + * Deactivate a plugin. + */ + private static function _helper_plugin_deactivate() { + $product_id = absint( $_GET['wc-helper-product-id'] ); + $deactivated = false; + + if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( $_GET['wc-helper-nonce'], 'deactivate-plugin:' . $product_id ) ) { + self::log( 'Could not verify nonce in _helper_plugin_deactivate' ); + wp_die( 'Could not verify nonce' ); + } + + if ( ! current_user_can( 'activate_plugins' ) ) { + wp_die( 'You are not allowed to manage plugins on this site.' ); + } + + $local = wp_list_filter( array_merge( self::get_local_woo_plugins(), + self::get_local_woo_themes() ), array( '_product_id' => $product_id ) ); + + // Attempt to deactivate this plugin or theme. + if ( ! empty( $local ) ) { + $local = array_shift( $local ); + if ( is_plugin_active( $local['_filename'] ) ) { + deactivate_plugins( $local['_filename'] ); + } + + $deactivated = ! is_plugin_active( $local['_filename'] ); + } + + $redirect_uri = add_query_arg( array( + 'page' => 'wc-helper', + 'wc-helper-status' => $deactivated ? 'deactivate-plugin-success' : 'deactivate-plugin-error', + 'wc-helper-product-id' => $product_id, + ), admin_url( 'admin.php' ) ); + + wp_safe_redirect( $redirect_uri ); + die(); + } + + /** + * Get a local plugin/theme entry from product_id. + * + * @param int $product_id The product id. + * + * @return array|bool The array containing the local plugin/theme data or false. + */ + private static function _get_local_from_product_id( $product_id ) { + $local = wp_list_filter( array_merge( self::get_local_woo_plugins(), + self::get_local_woo_themes() ), array( '_product_id' => $product_id ) ); + + if ( ! empty( $local ) ) { + return array_shift( $local ); + } + + return false; + } + + /** + * Get a subscription entry from product_id. If multiple subscriptions are + * found with the same product id, will return the first one in the list, so + * only use this method to get things like extension name, version, etc. + * + * @param int $product_id The product id. + * + * @return array|bool The array containing sub data or false. + */ + private static function _get_subscription_from_product_id( $product_id ) { + $subscriptions = wp_list_filter( self::get_subscriptions(), array( 'product_id' => $product_id ) ); + if ( ! empty( $subscriptions ) ) { + return array_shift( $subscriptions ); + } + + return false; + } + + /** + * Additional theme style.css and plugin file headers. + * + * Format: Woo: product_id:file_id + */ + public static function extra_headers( $headers ) { + $headers[] = 'Woo'; + return $headers; + } + + /** + * Obtain a list of locally installed Woo extensions. + */ + public static function get_local_woo_plugins() { + if ( ! function_exists( 'get_plugins' ) ) { + require_once( ABSPATH . 'wp-admin/includes/plugin.php' ); + } + + $plugins = get_plugins(); + $woo_plugins = array(); + + // Back-compat for woothemes_queue_update(). + $_compat = array(); + if ( ! empty( $GLOBALS['woothemes_queued_updates'] ) ) { + foreach( $GLOBALS['woothemes_queued_updates'] as $_compat_plugin ) { + $_compat[ $_compat_plugin->file ] = array( + 'product_id' => $_compat_plugin->product_id, + 'file_id' => $_compat_plugin->file_id, + ); + } + } + + foreach ( $plugins as $filename => $data ) { + if ( empty( $data['Woo'] ) && ! empty( $_compat[ $filename ] ) ) { + $data['Woo'] = sprintf( '%d:%s', $_compat[ $filename ]['product_id'], $_compat[ $filename ]['file_id'] ); + } + + if ( empty( $data['Woo'] ) ) { + continue; + } + + list( $product_id, $file_id ) = explode( ':', $data['Woo'] ); + if ( empty( $product_id ) || empty( $file_id ) ) { + continue; + } + + $data['_filename'] = $filename; + $data['_product_id'] = absint( $product_id ); + $data['_file_id'] = $file_id; + $data['_type'] = 'plugin'; + $woo_plugins[ $filename ] = $data; + } + + return $woo_plugins; + } + + /** + * Get locally installed Woo themes. + */ + public static function get_local_woo_themes() { + $themes = wp_get_themes(); + $woo_themes = array(); + + foreach ( $themes as $theme ) { + $header = $theme->get( 'Woo' ); + + // Back-compat for theme_info.txt + if ( ! $header ) { + $txt = $theme->get_stylesheet_directory() . '/theme_info.txt'; + if ( is_readable( $txt ) ) { + $txt = file_get_contents( $txt ); + $txt = preg_split( "#\s#", $txt ); + if ( count( $txt ) >= 2 ) { + $header = sprintf( '%d:%s', $txt[0], $txt[1] ); + } + } + } + + if ( empty( $header ) ) { + continue; + } + + list( $product_id, $file_id ) = explode( ':', $header ); + if ( empty( $product_id ) || empty( $file_id ) ) { + continue; + } + + $data = array( + 'Name' => $theme->get( 'Name' ), + 'Version' => $theme->get( 'Version' ), + 'Woo' => $header, + + '_filename' => $theme->get_stylesheet() . '/style.css', + '_stylesheet' => $theme->get_stylesheet(), + '_product_id' => absint( $product_id ), + '_file_id' => $file_id, + '_type' => 'theme', + ); + + $woo_themes[ $data['_filename'] ] = $data; + } + + return $woo_themes; + } + + /** + * Get the connected user's subscriptions. + * + * @return array + */ + public static function get_subscriptions() { + $cache_key = '_woocommerce_helper_subscriptions'; + if ( false !== ( $data = get_transient( $cache_key ) ) ) { + return $data; + } + + // Obtain the connected user info. + $request = WC_Helper_API::get( 'subscriptions', array( + 'authenticated' => true, + ) ); + + if ( wp_remote_retrieve_response_code( $request ) != 200 ) { + set_transient( $cache_key, array(), 15 * MINUTE_IN_SECONDS ); + return array(); + } + + $data = json_decode( wp_remote_retrieve_body( $request ), true ); + if ( empty( $data ) || ! is_array( $data ) ) { + $data = array(); + } + + set_transient( $cache_key, $data, 1 * HOUR_IN_SECONDS ); + return $data; + } + + private static function _flush_subscriptions_cache() { + delete_transient( '_woocommerce_helper_subscriptions' ); + } + + private static function _flush_authentication_cache() { + $request = WC_Helper_API::get( 'oauth/me', array( + 'authenticated' => true, + ) ); + + if ( wp_remote_retrieve_response_code( $request ) !== 200 ) { + return false; + } + + $user_data = json_decode( wp_remote_retrieve_body( $request ), true ); + if ( ! $user_data ) { + return false; + } + + WC_Helper_Options::update( 'auth_user_data', array( + 'name' => $user_data['name'], + 'email' => $user_data['email'], + ) ); + + return true; + } + + private static function _flush_updates_cache() { + WC_Helper_Updater::flush_updates_cache(); + } + + /** + * Log a helper event. + * + * @param string $message Log message. + * @param string $level Optional, defaults to info, valid levels: + * emergency|alert|critical|error|warning|notice|info|debug + */ + public static function log( $message, $level = 'info' ) { + if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) { + return; + } + + if ( ! isset( self::$log ) ) { + self::$log = wc_get_logger(); + } + + self::$log->log( $level, $message, array( 'source' => 'helper' ) ); + } +} + +WC_Helper::load(); diff --git a/includes/admin/helper/views/html-main.php b/includes/admin/helper/views/html-main.php new file mode 100644 index 00000000000..c8433c213f1 --- /dev/null +++ b/includes/admin/helper/views/html-main.php @@ -0,0 +1,185 @@ + + +
+

+ + + + +

Subscriptions

+

Below is a list of products available on your WooCommerce.com account. To receive plugin updates please make sure the product is installed, activated and connected to your WooCommerce.com account.

+ + + + + + + + + + + + + ' ) ) { + $update_available = true; + } + + $download_url = $subscription['product_url']; + if ( ! $installed && ! empty( $updates[ $product_id ]['package'] ) ) { + $download_url = $updates[ $product_id ]['package']; + } + + $classes = array( + 'color-bar' => true, + 'expired' => $subscription['expired'], + 'expiring' => $subscription['expiring'], + 'update-available' => $update_available, + 'autorenews' => $subscription['autorenew'], + ); + + $classes = array_filter( $classes, function( $i ) { return (bool) $i; } ); + $classes = array_keys( $classes ); + ?> + + + + + + + + + + + + + +
ProductSubscriptionSite usageAction
+
+ + Update to + + + + available + [?] + + + + available + [?] + + + + available + +
+ + Expired :(
+ + + Auto renews on:
+ + + Expiring soon!
+ + + Expires on:
+ + +
+ + 0 ) { + printf( '(%d of %d used)', $subscription['sites_active'], $subscription['sites_max'] ); + } + ?>
+ + Not installed
+ + Installed on this site
+ +
+ + + + Update + + + Renew + + Deactivate + + Renew + + Activate + + Download + + + Renew + +
Could not find any subscriptions on your WooCommerce.com account
+ + +

Installed Extensions Without a Subscription

+ + + + + + + + + + + + $data ) : ?> + ' ) ) { + $update_available = true; + } + + $product_url = '#'; + if ( ! empty( $updates[ $product_id ]['url'] ) ) { + $product_url = $updates[ $product_id ]['url']; + } elseif ( ! empty( $data['PluginURI'] ) ) { + $product_url = $data['PluginURI']; + } + ?> + + + + + + + +
ProductSubscriptionAction
+
+ installed + + (latest) + +
+ + + available + [?] + + +
No/Invalid subscription + Purchase Subscription +
+ +
diff --git a/includes/admin/helper/views/html-oauth-start.php b/includes/admin/helper/views/html-oauth-start.php new file mode 100644 index 00000000000..97c9fd36de0 --- /dev/null +++ b/includes/admin/helper/views/html-oauth-start.php @@ -0,0 +1,20 @@ + + +
+

+ + +
+
+ WooCommerce + + +

Sorry to see you go. Feel free to reconnect again using the button below.

+ + +

+

+

Connect your store

+
+
+
diff --git a/includes/admin/helper/views/html-section-account.php b/includes/admin/helper/views/html-section-account.php new file mode 100644 index 00000000000..d3b0efc28f1 --- /dev/null +++ b/includes/admin/helper/views/html-section-account.php @@ -0,0 +1,16 @@ + + +

Your Account

+ +
+
+ +

+ %s', esc_html( $auth_user_data['email'] ) ); ?> + + Refresh + Disconnect + +

+
+
diff --git a/includes/admin/helper/views/html-section-notices.php b/includes/admin/helper/views/html-section-notices.php new file mode 100644 index 00000000000..46792dab880 --- /dev/null +++ b/includes/admin/helper/views/html-section-notices.php @@ -0,0 +1,7 @@ + + + +
+ +
+