AbstractAutomatticAddressProvider.php
309 lines
| 1 | <?php |
| 2 | declare( strict_types=1 ); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\Internal\AddressProvider; |
| 5 | |
| 6 | use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken; |
| 7 | use Automattic\Jetpack\Constants; |
| 8 | use WC_Address_Provider; |
| 9 | |
| 10 | /** |
| 11 | * Abstract Automattic address provider is an abstract implementation of the WC_Address_Provider that is meant to be used by Automattic services to get support for address autocomplete and maps with minimal code maintenance. |
| 12 | * |
| 13 | * @since 10.1.0 |
| 14 | * @package WooCommerce |
| 15 | */ |
| 16 | abstract class AbstractAutomatticAddressProvider extends WC_Address_Provider { |
| 17 | |
| 18 | /** |
| 19 | * The JWT for the address service. |
| 20 | * |
| 21 | * @var string |
| 22 | */ |
| 23 | private $jwt = null; |
| 24 | |
| 25 | /** |
| 26 | * Loads up the JWT for the address service and saves it to transient. |
| 27 | */ |
| 28 | public function __construct() { |
| 29 | add_filter( 'pre_update_option_woocommerce_address_autocomplete_enabled', array( $this, 'refresh_cache' ) ); |
| 30 | add_action( 'wp_enqueue_scripts', array( $this, 'load_scripts' ) ); |
| 31 | |
| 32 | // Powered by Google branding. |
| 33 | $this->branding_html = 'Powered by <img style="height: 15px; width: 45px; margin-bottom: -2px;" src="' . plugins_url( '/assets/images/address-autocomplete/google.svg', WC_PLUGIN_FILE ) . '" alt="Google logo" />'; |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Get the JWT for the address service, a service should implement an A8C hosted API or some mechanism to get a JWT, this will be passed to frontend code to be used in the address autocomplete and maps. |
| 38 | * |
| 39 | * This method shouldn't implement any caching, it should only fetch the token or throw an exception, if you must handle caching, consider also overriding get_jwt. |
| 40 | * |
| 41 | * @return string The JWT for the address service. |
| 42 | */ |
| 43 | abstract public function get_address_service_jwt(); |
| 44 | |
| 45 | /** |
| 46 | * Get the telemetry status for the address service, this is meant to be overridden by the implementor to return true if the service has permission to send telemetry data. |
| 47 | * |
| 48 | * @return bool The telemetry status for the address service. |
| 49 | */ |
| 50 | public function can_telemetry() { |
| 51 | return false; |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Loads up a JWT from cache or from the implementor side. |
| 56 | * |
| 57 | * @return void |
| 58 | * |
| 59 | * phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing -- As we wrap the throw in a try/catch. |
| 60 | */ |
| 61 | public function load_jwt() { |
| 62 | |
| 63 | // If the address autocomplete is disabled, we don't load the JWT. |
| 64 | if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) { |
| 65 | return; |
| 66 | } |
| 67 | |
| 68 | // If we already have a loaded, valid token, we return early. |
| 69 | if ( $this->jwt && is_string( $this->jwt ) && JsonWebToken::shallow_validate( $this->jwt ) ) { |
| 70 | return; |
| 71 | } |
| 72 | |
| 73 | $cached_jwt = $this->get_cached_option( 'address_autocomplete_jwt' ); |
| 74 | // If we have a cached, valid token, we load it to class and return early. |
| 75 | if ( $cached_jwt && is_string( $cached_jwt ) && JsonWebToken::shallow_validate( $cached_jwt ) ) { |
| 76 | $this->jwt = $cached_jwt; |
| 77 | return; |
| 78 | } |
| 79 | |
| 80 | $retry_data = $this->get_cached_option( 'jwt_retry_data' ); |
| 81 | |
| 82 | if ( $retry_data && isset( $retry_data['try_after'] ) && $retry_data['try_after'] > time() ) { |
| 83 | return; |
| 84 | } |
| 85 | |
| 86 | try { |
| 87 | $fresh_jwt = $this->get_address_service_jwt(); |
| 88 | if ( $fresh_jwt && is_string( $fresh_jwt ) && JsonWebToken::shallow_validate( $fresh_jwt ) ) { |
| 89 | $this->set_jwt( $fresh_jwt ); |
| 90 | // Clear retry data on success. |
| 91 | $this->delete_cached_option( 'jwt_retry_data' ); |
| 92 | return; |
| 93 | } else { |
| 94 | throw new \Exception( 'Invalid JWT received from address service.' ); |
| 95 | } |
| 96 | } catch ( \Exception $e ) { |
| 97 | $retry_data['attempts'] = isset( $retry_data['attempts'] ) ? $retry_data['attempts'] + 1 : 1; |
| 98 | wc_get_logger()->error( |
| 99 | sprintf( |
| 100 | 'Failed loading JWT for %1$s address autocomplete service (attempt %2$d) with error %3$s.', |
| 101 | $this->name, |
| 102 | $retry_data['attempts'], |
| 103 | $e->getMessage() |
| 104 | ), |
| 105 | 'address-autocomplete' |
| 106 | ); |
| 107 | $backoff_hours = pow( 2, $retry_data['attempts'] - 1 ); // 1, 2, 4, 8 hours. |
| 108 | $retry_data['try_after'] = time() + ( $backoff_hours * HOUR_IN_SECONDS ); |
| 109 | $this->update_cached_option( 'jwt_retry_data', $retry_data, DAY_IN_SECONDS ); |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Gets the JWT for the address service. |
| 115 | * |
| 116 | * @return string The JWT for the address service. |
| 117 | */ |
| 118 | public function get_jwt() { |
| 119 | if ( null === $this->jwt ) { |
| 120 | $this->load_jwt(); |
| 121 | } |
| 122 | |
| 123 | return $this->jwt; |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Sets the JWT for the address service. |
| 128 | * |
| 129 | * @param string $jwt The JWT for the address service. |
| 130 | */ |
| 131 | public function set_jwt( $jwt ) { |
| 132 | $this->jwt = $jwt; |
| 133 | if ( null !== $jwt ) { |
| 134 | $cache_duration = $this->get_jwt_cache_duration( $jwt ); |
| 135 | // If the token is expired, we don't cache it and we fetch a new one. |
| 136 | if ( 0 === $cache_duration ) { |
| 137 | $this->jwt = null; |
| 138 | $this->load_jwt(); |
| 139 | return; |
| 140 | } |
| 141 | $this->update_cached_option( 'address_autocomplete_jwt', $jwt, $cache_duration ); |
| 142 | } else { |
| 143 | $this->delete_cached_option( 'address_autocomplete_jwt' ); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * Gets the cache duration for the JWT. |
| 149 | * |
| 150 | * @param string $jwt The JWT for the address service. |
| 151 | * @return int The cache duration for the JWT. |
| 152 | */ |
| 153 | public function get_jwt_cache_duration( $jwt ) { |
| 154 | $parts = JsonWebToken::get_parts( $jwt ); |
| 155 | if ( property_exists( $parts->payload, 'exp' ) ) { |
| 156 | return max( $parts->payload->exp - time(), 0 ); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * Deletes the cached token if we disable the autocomplete service or fetches a new one if it's enabled. |
| 162 | * |
| 163 | * @param string $setting If the service is enabled or disabled. |
| 164 | * @return string the setting value. |
| 165 | */ |
| 166 | public function refresh_cache( $setting ) { |
| 167 | if ( wc_string_to_bool( $setting ) ) { |
| 168 | $this->load_jwt(); |
| 169 | } else { |
| 170 | $this->set_jwt( null ); |
| 171 | } |
| 172 | |
| 173 | return $setting; |
| 174 | } |
| 175 | |
| 176 | /** |
| 177 | * Gets the cached option. |
| 178 | * |
| 179 | * @param string $key The key of the option. |
| 180 | * @return mixed|null The cached option. |
| 181 | */ |
| 182 | private function get_cached_option( $key ) { |
| 183 | $data = get_option( $this->id . '_' . $key ); |
| 184 | if ( is_array( $data ) && isset( $data['data'] ) ) { |
| 185 | if ( ! self::is_expired( $data ) ) { |
| 186 | return $data['data']; |
| 187 | } |
| 188 | $this->delete_cached_option( $key ); |
| 189 | } |
| 190 | return null; |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Updates the cached option. |
| 195 | * |
| 196 | * @param string $key The key of the option. |
| 197 | * @param mixed $value The value of the option. |
| 198 | * @param int $ttl The TTL of the option. |
| 199 | */ |
| 200 | private function update_cached_option( $key, $value, $ttl = DAY_IN_SECONDS ) { |
| 201 | $result = update_option( |
| 202 | $this->id . '_' . $key, |
| 203 | array( |
| 204 | 'data' => $value, |
| 205 | 'updated' => time(), |
| 206 | 'ttl' => $ttl, |
| 207 | ), |
| 208 | false |
| 209 | ); |
| 210 | if ( false === $result ) { |
| 211 | wp_cache_delete( $this->id . '_' . $key, 'options' ); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Deletes the cached option. |
| 217 | * |
| 218 | * @param string $key The key of the option. |
| 219 | */ |
| 220 | private function delete_cached_option( $key ) { |
| 221 | if ( delete_option( $this->id . '_' . $key ) ) { |
| 222 | wp_cache_delete( $this->id . '_' . $key, 'options' ); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * Checks if the cache value is expired. |
| 228 | * |
| 229 | * @param array $cache_contents The cache contents. |
| 230 | * |
| 231 | * @return boolean True if the contents are expired. False otherwise. |
| 232 | */ |
| 233 | private static function is_expired( $cache_contents ) { |
| 234 | if ( ! is_array( $cache_contents ) || ! isset( $cache_contents['updated'] ) || ! isset( $cache_contents['ttl'] ) ) { |
| 235 | // Treat bad/invalid cache contents as expired. |
| 236 | return true; |
| 237 | } |
| 238 | |
| 239 | // Double-check that we have integers for `updated` and `ttl`. |
| 240 | if ( ! is_int( $cache_contents['updated'] ) || ! is_int( $cache_contents['ttl'] ) ) { |
| 241 | return true; |
| 242 | } |
| 243 | |
| 244 | $expires = $cache_contents['updated'] + $cache_contents['ttl']; |
| 245 | $now = time(); |
| 246 | return $expires < $now; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Return asset URL, copied from WC_Frontend_Scripts::get_asset_url. |
| 251 | * |
| 252 | * @param string $path Assets path. |
| 253 | * @return string |
| 254 | */ |
| 255 | public static function get_asset_url( $path ) { |
| 256 | /** |
| 257 | * Filters the asset URL. |
| 258 | * |
| 259 | * @since 3.2.0 |
| 260 | * |
| 261 | * @param string $url The asset URL. |
| 262 | * @param string $path The asset path. |
| 263 | * @return string The filtered asset URL. |
| 264 | */ |
| 265 | return apply_filters( 'woocommerce_get_asset_url', plugins_url( $path, Constants::get_constant( 'WC_PLUGIN_FILE' ) ), $path ); |
| 266 | } |
| 267 | |
| 268 | |
| 269 | /** |
| 270 | * Enqueues the checkout script, checks if it's already registered or not so we don't duplicate, and prints out the JWT to the page to be consumed. |
| 271 | */ |
| 272 | public function load_scripts() { |
| 273 | // If the address autocomplete setting is disabled, don't load the scripts. |
| 274 | if ( wc_string_to_bool( get_option( 'woocommerce_address_autocomplete_enabled', 'no' ) ) !== true ) { |
| 275 | return; |
| 276 | } |
| 277 | |
| 278 | if ( ! is_checkout() ) { |
| 279 | return; |
| 280 | } |
| 281 | |
| 282 | if ( ! $this->get_jwt() ) { |
| 283 | return; |
| 284 | } |
| 285 | |
| 286 | $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; |
| 287 | $version = Constants::get_constant( 'WC_VERSION' ); |
| 288 | |
| 289 | if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'registered' ) ) { |
| 290 | wp_register_script( 'a8c-address-autocomplete-service', self::get_asset_url( 'assets/js/frontend/a8c-address-autocomplete-service' . $suffix . '.js' ), array( 'wc-address-autocomplete' ), $version, array( 'strategy' => 'defer' ) ); |
| 291 | } |
| 292 | |
| 293 | if ( ! wp_script_is( 'a8c-address-autocomplete-service', 'enqueued' ) ) { |
| 294 | wp_enqueue_script( 'a8c-address-autocomplete-service' ); |
| 295 | } |
| 296 | |
| 297 | wp_add_inline_script( |
| 298 | 'a8c-address-autocomplete-service', |
| 299 | sprintf( |
| 300 | 'var a8cAddressAutocompleteServiceKeys = a8cAddressAutocompleteServiceKeys || {}; a8cAddressAutocompleteServiceKeys[ %1$s ] = { key: %2$s, canTelemetry: %3$s };', |
| 301 | wp_json_encode( $this->id, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), |
| 302 | wp_json_encode( $this->get_jwt(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), |
| 303 | wp_json_encode( false !== $this->can_telemetry() && (bool) $this->can_telemetry(), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ) |
| 304 | ), |
| 305 | 'before' |
| 306 | ); |
| 307 | } |
| 308 | } |
| 309 |