diff --git a/app.php b/app.php index ab52e1a..1bf023f 100644 --- a/app.php +++ b/app.php @@ -53,6 +53,7 @@ public static function get_controllers() { Controller\Rest_API\Init::class, Controller\Admin_Settings\Init::class, Controller\iFrame_Login\Init::class, + Controller\Licensing\Init::class, ]; } diff --git a/app/Controller/Licensing/Init.php b/app/Controller/Licensing/Init.php new file mode 100644 index 0000000..1a1811e --- /dev/null +++ b/app/Controller/Licensing/Init.php @@ -0,0 +1,27 @@ +register_serivces( $this->get_controllers() ); + } + + /** + * Get licensing services. + * + * @return array + */ + public static function get_controllers() { + return [ + License_Manager::class, + ]; + } +} diff --git a/app/Controller/Licensing/License_Manager.php b/app/Controller/Licensing/License_Manager.php new file mode 100644 index 0000000..bf55d67 --- /dev/null +++ b/app/Controller/Licensing/License_Manager.php @@ -0,0 +1,218 @@ +licensing_endpoint ) || is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) || empty( $body['success'] ) || ! isset( $body['license_data'] ) || ! is_array( $body['license_data'] ) ) { + return $response; + } + + $this->pending_license_data = $body['license_data']; + + if ( ! $this->activation_scheduled ) { + $this->activation_scheduled = true; + add_action( 'shutdown', [ $this, 'activate_pending_license' ] ); + } + + return $response; + } + + /** + * Activate the first entitlement accepted by the Directorist EDD API. + * + * @return void + */ + public function activate_pending_license() { + if ( ! is_array( $this->pending_license_data ) ) { + return; + } + + $products = [ + 'plugin' => isset( $this->pending_license_data['plugins'] ) && is_array( $this->pending_license_data['plugins'] ) ? $this->pending_license_data['plugins'] : [], + 'theme' => isset( $this->pending_license_data['themes'] ) && is_array( $this->pending_license_data['themes'] ) ? $this->pending_license_data['themes'] : [], + ]; + + foreach ( $products as $product_type => $items ) { + foreach ( $items as $item ) { + if ( ! $this->is_activatable_item( $item ) ) { + continue; + } + + $activation = $this->activate_license( $item ); + + if ( empty( $activation['success'] ) ) { + continue; + } + + $response = isset( $activation['response'] ) && is_array( $activation['response'] ) ? $activation['response'] : []; + $expires_at = $this->normalize_expiration( isset( $response['expires'] ) ? $response['expires'] : '' ); + + if ( $expires_at < 0 || ( $expires_at > 0 && $expires_at <= time() ) ) { + continue; + } + + update_option( + self::OPTION_NAME, + [ + 'active' => true, + 'item_id' => absint( $item['item_id'] ), + 'product_type' => $product_type, + 'product_key' => $this->get_product_key( $item ), + 'activated_at' => time(), + 'expires_at' => $expires_at, + ], + false + ); + + return; + } + } + + update_option( + self::OPTION_NAME, + [ + 'active' => false, + 'checked_at' => time(), + 'expires_at' => 0, + ], + false + ); + } + + /** + * Determine whether the saved site entitlement is currently active. + * + * @return bool + */ + public static function has_active_license() { + $state = get_option( self::OPTION_NAME, [] ); + + if ( ! is_array( $state ) || empty( $state['active'] ) ) { + return false; + } + + $expires_at = isset( $state['expires_at'] ) ? absint( $state['expires_at'] ) : 0; + + return 0 === $expires_at || $expires_at > time(); + } + + /** + * Call Directorist's EDD activation helper. + * + * @param array $item License item. + * + * @return array + */ + protected function activate_license( $item ) { + if ( ! class_exists( '\\ATBDP_Extensions' ) || ! is_callable( [ '\\ATBDP_Extensions', 'remote_activate_license' ] ) ) { + return [ 'success' => false ]; + } + + $activation = \ATBDP_Extensions::remote_activate_license( $item ); + + return is_array( $activation ) ? $activation : [ 'success' => false ]; + } + + /** + * Check the minimum EDD fields before attempting an activation. + * + * @param mixed $item License item. + * + * @return bool + */ + protected function is_activatable_item( $item ) { + return is_array( $item ) && ! empty( $item['item_id'] ) && ! empty( $item['license'] ) && empty( $item['skip_licencing'] ); + } + + /** + * Normalize the EDD expiration value to a UTC timestamp. + * + * Zero represents a lifetime or unspecified expiration on a valid response; + * negative one represents an invalid expiration value. + * + * @param mixed $expiration EDD expiration value. + * + * @return int + */ + protected function normalize_expiration( $expiration ) { + $expiration = trim( (string) $expiration ); + + if ( '' === $expiration || 'lifetime' === strtolower( $expiration ) ) { + return 0; + } + + try { + $date = new \DateTimeImmutable( $expiration, new \DateTimeZone( 'UTC' ) ); + } catch ( \Exception $exception ) { + return -1; + } + + return $date->getTimestamp(); + } + + /** + * Build a non-sensitive product reference for diagnostics. + * + * @param array $item License item. + * + * @return string + */ + protected function get_product_key( $item ) { + if ( empty( $item['permalink'] ) ) { + return ''; + } + + $path = wp_parse_url( $item['permalink'], PHP_URL_PATH ); + + return sanitize_key( basename( untrailingslashit( (string) $path ) ) ); + } +} diff --git a/app/Controller/Rest_API/Version_1/Admin_Settings/Admin_Settings.php b/app/Controller/Rest_API/Version_1/Admin_Settings/Admin_Settings.php index d15a0a6..bd28d3d 100644 --- a/app/Controller/Rest_API/Version_1/Admin_Settings/Admin_Settings.php +++ b/app/Controller/Rest_API/Version_1/Admin_Settings/Admin_Settings.php @@ -9,6 +9,7 @@ namespace DirectoristAppToolkit\Controller\Rest_API\Version_1\Admin_Settings; use DirectoristAppToolkit\Controller\Rest_API\Version_1\Helper\Rest_Base; +use DirectoristAppToolkit\Controller\Licensing\License_Manager; use DirectoristAppToolkit\Helper\App_Settings as Settings_Helper; defined( 'ABSPATH' ) || exit; @@ -22,6 +23,12 @@ class Admin_Settings extends Rest_Base { protected $rest_base = 'admin-settings'; + protected $read_only_settings = [ + 'has_active_license', + 'payment_currency_symbol', + 'listing_currency_symbol', + ]; + protected $legacy_settings = [ 'enable_multi_directory' => null, 'radius_search_unit' => null, @@ -80,6 +87,12 @@ public function register_routes() { 'permission_callback' => '__return_true', 'args' => [], ], + [ + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => [ $this, 'update_items' ], + 'permission_callback' => [ $this, 'update_items_permissions_check' ], + 'args' => [], + ], ] ); @@ -92,6 +105,136 @@ public function register_routes() { * @return WP_Error|WP_REST_Response */ public function get_items( $request ) { + return rest_ensure_response( $this->prepare_settings_response() ); + } + + /** + * Check whether the current user may update App Toolkit settings. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @return bool|\WP_Error + */ + public function update_items_permissions_check( $request ) { + $capability = (string) apply_filters( 'directorist_app_toolkit_settings_capability', 'manage_options' ); + + if ( current_user_can( $capability ) ) { + return true; + } + + return new \WP_Error( + 'directorist_app_toolkit_rest_cannot_update_settings', + __( 'Sorry, you are not allowed to update app settings.', 'directorist-app-toolkit' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + /** + * Update a partial flat map of settings. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @return \WP_Error|\WP_REST_Response + */ + public function update_items( $request ) { + $payload = $request->get_json_params(); + + if ( ! is_array( $payload ) ) { + $payload = $request->get_body_params(); + } + + if ( ! is_array( $payload ) || empty( $payload ) ) { + return new \WP_Error( + 'directorist_app_toolkit_rest_settings_payload_invalid', + __( 'A non-empty settings object is required.', 'directorist-app-toolkit' ), + [ 'status' => 400 ] + ); + } + + $writable_settings = $this->get_writable_settings(); + $invalid_keys = array_values( array_diff( array_keys( $payload ), array_keys( $writable_settings ) ) ); + + if ( ! empty( $invalid_keys ) ) { + return new \WP_Error( + 'directorist_app_toolkit_rest_settings_keys_invalid', + __( 'One or more settings are unknown or read-only.', 'directorist-app-toolkit' ), + [ + 'status' => 400, + 'keys' => $invalid_keys, + ] + ); + } + + $app_options = []; + $legacy_options = get_option( 'atbdp_option', [] ); + $has_legacy_update = false; + $legacy_options = is_array( $legacy_options ) ? $legacy_options : []; + + foreach ( Settings_Helper::get_tabs() as $tab_key => $tab ) { + $tab_values = Settings_Helper::get_tab_values( $tab_key ); + $has_update = false; + + foreach ( $tab['fields'] as $field_key => $field ) { + if ( ! array_key_exists( $field_key, $payload ) || Settings_Helper::is_section_field( $field ) ) { + continue; + } + + $value = $payload[ $field_key ]; + + if ( isset( $field['type'] ) && 'json' === $field['type'] && ( is_array( $value ) || is_object( $value ) ) ) { + $value = wp_json_encode( $value ); + } + + $tab_values[ $field_key ] = $value; + $has_update = true; + } + + if ( ! $has_update ) { + continue; + } + + $errors = Settings_Helper::validate_tab_values( $tab_key, $tab_values ); + + if ( ! empty( $errors ) ) { + return new \WP_Error( + 'directorist_app_toolkit_rest_settings_validation_failed', + reset( $errors ), + [ + 'status' => 400, + 'errors' => $errors, + ] + ); + } + + $app_options[ $tab['option_key'] ] = Settings_Helper::sanitize_tab_values( $tab_key, $tab_values ); + } + + foreach ( $writable_settings as $rest_key => $setting ) { + if ( 'legacy' !== $setting['source'] || ! array_key_exists( $rest_key, $payload ) ) { + continue; + } + + $legacy_options[ $setting['storage_key'] ] = $this->sanitize_legacy_value( $payload[ $rest_key ] ); + $has_legacy_update = true; + } + + foreach ( $app_options as $option_key => $values ) { + update_option( $option_key, $values, false ); + } + + if ( $has_legacy_update ) { + update_option( 'atbdp_option', $legacy_options ); + } + + return rest_ensure_response( $this->prepare_settings_response() ); + } + + /** + * Prepare the complete public settings response. + * + * @return array + */ + protected function prepare_settings_response() { $_raw_settings = get_option('atbdp_option'); $settings = []; @@ -111,14 +254,76 @@ public function get_items( $request ) { } } - if ( $settings['payment_currency'] !== '' ) { + if ( ! empty( $settings['payment_currency'] ) && function_exists( 'atbdp_currency_symbol' ) ) { $settings['payment_currency_symbol'] = html_entity_decode( atbdp_currency_symbol( $settings['payment_currency'] ) ); } - if ( $settings['listing_currency'] !== '' ) { + if ( ! empty( $settings['listing_currency'] ) && function_exists( 'atbdp_currency_symbol' ) ) { $settings['listing_currency_symbol'] = html_entity_decode( atbdp_currency_symbol( $settings['listing_currency'] ) ); } - - return rest_ensure_response( $settings ); + + $settings['has_active_license'] = License_Manager::has_active_license(); + + return $settings; + } + + /** + * Build the flat REST key map for writable settings. + * + * @return array + */ + protected function get_writable_settings() { + $settings = []; + + foreach ( Settings_Helper::get_tabs() as $tab ) { + foreach ( $tab['fields'] as $field_key => $field ) { + if ( Settings_Helper::is_section_field( $field ) ) { + continue; + } + + $settings[ $field_key ] = [ + 'source' => 'app', + 'storage_key' => $field_key, + ]; + } + } + + foreach ( $this->legacy_settings as $storage_key => $rest_key ) { + $rest_key = is_null( $rest_key ) ? $storage_key : $rest_key; + + if ( in_array( $rest_key, $this->read_only_settings, true ) ) { + continue; + } + + $settings[ $rest_key ] = [ + 'source' => 'legacy', + 'storage_key' => $storage_key, + ]; + } + + return $settings; + } + + /** + * Sanitize legacy Directorist values without changing scalar types. + * + * @param mixed $value Raw value. + * + * @return mixed + */ + protected function sanitize_legacy_value( $value ) { + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + $value[ $key ] = $this->sanitize_legacy_value( $item ); + } + + return $value; + } + + if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { + return $value; + } + + return sanitize_text_field( (string) $value ); } } diff --git a/app/Helper/App_Settings.php b/app/Helper/App_Settings.php index 9e51e7a..b666044 100644 --- a/app/Helper/App_Settings.php +++ b/app/Helper/App_Settings.php @@ -328,6 +328,13 @@ public static function get_tabs() { 'placeholder' => __( "{\n \"sections\": []\n}", 'directorist-app-toolkit' ), 'description' => __( 'JSON configuration for the single listing screen, including visible content blocks and their order.', 'directorist-app-toolkit' ), ], + 'app_layout_all_listings' => [ + 'label' => __( 'All Listings', 'directorist-app-toolkit' ), + 'type' => 'json', + 'default' => '', + 'placeholder' => __( "{\n \"sections\": []\n}", 'directorist-app-toolkit' ), + 'description' => __( 'JSON configuration for the all listings screen, including visible content blocks, ordering, and display rules.', 'directorist-app-toolkit' ), + ], 'app_layout_bottom_navigation' => [ 'label' => __( 'Bottom Navigation', 'directorist-app-toolkit' ), 'type' => 'json',