2048) { return false; } $parts = wp_parse_url($url); if ( !is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment']) || isset($parts['port']) ) { return false; } // Allow only one subdomain level beneath da.pf.japanpost.jp. if (!preg_match( '/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.da\.pf\.japanpost\.jp$/D', $parts['host'] )) { return false; } $allowed_paths = [ 'token' => ['/api/v1/j/token', '/api/v2/j/token'], 'search' => ['/api/v1/searchcode/', '/api/v2/searchcode/'], ]; if ( !isset($allowed_paths[$type]) || !in_array($parts['path'] ?? '', $allowed_paths[$type], true) ) { return false; } if (!wp_http_validate_url($url)) { return false; } return $url; } /** * Settings sanitization. */ function jpzac_sanitize_setting($value, $option) { if (!is_string($value)) { return get_option($option, ''); } $value = trim($value); if ($option === 'jpzac_secret_key' && $value === '') { return get_option($option, ''); } if (in_array($option, [ 'jpzac_token_endpoint', 'jpzac_search_endpoint' ], true)) { $type = ($option === 'jpzac_token_endpoint') ? 'token' : 'search'; $valid = jpzac_validate_api_url($value, $type); if (!$valid) { add_settings_error( $option, 'jpzac_invalid_url', '有効なHTTPSのAPI URLを指定してください。' ); return get_option($option, ''); } return $valid; } return sanitize_text_field($value); } /** * Settings page. */ add_action('admin_menu', function () { add_submenu_page( 'woocommerce', 'JPZAC 設定', 'JPZAC', 'manage_woocommerce', 'jpzac-autofill', 'jpzac_autofill_options_page' ); }, 62); function jpzac_autofill_options_page() { if (!current_user_can('manage_woocommerce')) { wp_die(esc_html__('権限がありません。', 'jpzac')); } ?>
'; echo esc_html__('空欄の場合は既存のキーを保持します。', 'jpzac'); echo '
'; } }, 'jpzac-autofill', 'jpzac_autofill_section' ); } }); /** * Cache helpers. */ /** * One-time migration of pre-1.3.6 option keys. * Keep previously configured API credentials without requiring re-entry. */ function jpzac_migrate_legacy_options() { foreach (['client_id', 'secret_key', 'token_endpoint', 'search_endpoint'] as $suffix) { $old_name = 'zac_' . $suffix; $new_name = 'jpzac_' . $suffix; $old_value = get_option($old_name, null); if ($old_value === null) { continue; } if (get_option($new_name, null) === null) { add_option($new_name, $old_value, '', false); } // Only delete a legacy key once its value has been copied successfully. if (get_option($new_name, null) === $old_value) { delete_option($old_name); } } } add_action('init', 'jpzac_migrate_legacy_options', 1); function jpzac_cache_set($key, $value, $ttl) { $ttl = max(1, (int) $ttl); if (wp_using_ext_object_cache()) { return wp_cache_set($key, $value, 'jpzac', $ttl); } return set_transient('jpzac_' . $key, $value, $ttl); } function jpzac_cache_get($key) { if (wp_using_ext_object_cache()) { return wp_cache_get($key, 'jpzac'); } return get_transient('jpzac_' . $key); } function jpzac_cache_delete($key) { if (wp_using_ext_object_cache()) { return wp_cache_delete($key, 'jpzac'); } return delete_transient('jpzac_' . $key); } function jpzac_is_valid_address_cache($cached) { if (!is_array($cached) || empty($cached)) { return false; } foreach ($cached as $address) { if ( !is_array($address) || !isset($address['pref'], $address['city'], $address['town']) || !is_string($address['pref']) || !is_string($address['city']) || !is_string($address['town']) || $address['pref'] === '' || $address['city'] === '' ) { return false; } } return true; } /** * Invalidate token after API settings change. */ foreach ([ 'jpzac_client_id', 'jpzac_secret_key', 'jpzac_token_endpoint', 'jpzac_search_endpoint' ] as $jpzac_option) { add_action('update_option_' . $jpzac_option, function () { jpzac_cache_delete('jpzac_api_token'); }); } /** * Public REST rate limiting. * * 60 requests per 5 minutes per IP. * Note: application-level best effort, not a substitute * for server/CDN rate limiting. */ function jpzac_check_rate_limit() { global $wpdb; $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; if (!filter_var($ip, FILTER_VALIDATE_IP)) { $ip = 'unknown'; } // A fixed five-minute window, shared across requests from the same IP. $window = (int) floor(time() / (5 * MINUTE_IN_SECONDS)); $ip_hash = hash('sha256', $ip); $key = 'jpzac_rate_' . $ip_hash . '_' . $window; $lock_name = 'jpzac_rate_' . substr($ip_hash, 0, 40); // Serialize the read/increment/write operation across PHP workers. $locked = (int) $wpdb->get_var( $wpdb->prepare('SELECT GET_LOCK(%s, 1)', $lock_name) ); if ($locked !== 1) { return new WP_Error( 'jpzac_rate_limited', '少し時間をおいてお試しください。', ['status' => 429] ); } try { $count = (int) get_transient($key); // Preserve the active counter from versions before the prefix change. $legacy_key = 'zac_rate_' . $ip_hash . '_' . $window; $count = max($count, (int) get_transient($legacy_key)); if ($count >= 60) { return new WP_Error( 'jpzac_rate_limited', '少し時間をおいてお試しください。', ['status' => 429] ); } // The window is encoded in the key; expiry never resets its count. set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS); return true; } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name)); } } /** * Site-wide throttle for uncached /preload requests only. * This deliberately does not affect /address checkout requests. */ function jpzac_check_preload_global_limit() { global $wpdb; $window = (int) floor(time() / (5 * MINUTE_IN_SECONDS)); $key = 'jpzac_preload_global_' . $window; $lock_name = 'jpzac_' . substr(hash('sha256', $wpdb->prefix . 'preload_global'), 0, 40); $locked = (int) $wpdb->get_var( $wpdb->prepare('SELECT GET_LOCK(%s, 1)', $lock_name) ); if ($locked !== 1) { return new WP_Error('jpzac_rate_limited', '少し時間をおいてお試しください。', ['status' => 429]); } try { $count = (int) get_transient($key); // Preserve the active global preload counter during the upgrade. $legacy_key = 'zac_preload_global_' . $window; $count = max($count, (int) get_transient($legacy_key)); if ($count >= 20) { return new WP_Error('jpzac_rate_limited', '少し時間をおいてお試しください。', ['status' => 429]); } set_transient($key, $count + 1, 10 * MINUTE_IN_SECONDS); return true; } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name)); } } /** Set a short cooldown after a failed token request. */ function jpzac_token_failure($error) { jpzac_cache_set('token_failure_cooldown', 1, 30); return $error; } function jpzac_get_address_data($zipcode) { if (!is_string($zipcode) && !is_numeric($zipcode)) { return new WP_Error('invalid_zip', '郵便番号が不正です'); } $zipcode = preg_replace('/[^0-9]/', '', (string) $zipcode); if (!preg_match('/^[0-9]{7}$/D', $zipcode)) { return new WP_Error('invalid_zip', '郵便番号は7桁必要です'); } // Address cache. $address_cache_key = 'jpzac_addr_' . $zipcode; $cached = jpzac_cache_get($address_cache_key); if (jpzac_is_valid_address_cache($cached)) { return $cached; } // API settings. $client_id = get_option('jpzac_client_id', ''); $secret_key = get_option('jpzac_secret_key', ''); $token_api = jpzac_validate_api_url( get_option('jpzac_token_endpoint', ''), 'token' ); $search_api = jpzac_validate_api_url( get_option('jpzac_search_endpoint', ''), 'search' ); if (!$client_id || !$secret_key || !$token_api || !$search_api) { return new WP_Error('config_error', 'API情報が未設定または不正です'); } // Token cache. $token_cache_key = 'jpzac_api_token'; $token = jpzac_cache_get($token_cache_key); if (!$token) { global $wpdb; $lock_name = 'jpzac_' . substr(hash('sha256', $wpdb->prefix . 'token_refresh'), 0, 40); $locked = (int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock_name)); if ($locked !== 1) { return new WP_Error('token_busy', '認証処理中です。少し待って再試行してください'); } try { // Another request may already have refreshed the token. $token = jpzac_cache_get($token_cache_key); if (!$token) { if (jpzac_cache_get('token_failure_cooldown') !== false) { return new WP_Error('token_cooldown', '認証処理を一時停止しています'); } $token_response = wp_safe_remote_post($token_api, [ 'timeout' => 10, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 65536, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json' ], 'body' => wp_json_encode([ 'grant_type' => 'client_credentials', 'client_id' => $client_id, 'secret_key' => $secret_key ]) ]); if (is_wp_error($token_response)) { return jpzac_token_failure(new WP_Error('token_connection_error', '認証サーバーへの接続に失敗しました')); } if (wp_remote_retrieve_response_code($token_response) !== 200) { return jpzac_token_failure(new WP_Error('token_http_error', 'API認証に失敗しました')); } $token_data = json_decode( wp_remote_retrieve_body($token_response), true ); if (!is_array($token_data)) { return jpzac_token_failure(new WP_Error('token_json_error', '認証応答が不正です')); } $token = $token_data['token'] ?? ''; if (!is_string($token) || $token === '' || strlen($token) > 8192) { return jpzac_token_failure(new WP_Error('token_error', 'トークン取得に失敗しました')); } $expires_in = isset($token_data['expires_in']) && is_numeric($token_data['expires_in']) ? (int) $token_data['expires_in'] : 600; $ttl = max(1, min(86400, (int) floor($expires_in * 0.9))); jpzac_cache_set($token_cache_key, $token, $ttl); } } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock_name)); } } // Search endpoint. $url = $search_api . $zipcode; $headers = [ 'Authorization' => 'Bearer ' . $token, 'Accept' => 'application/json' ]; $response = wp_safe_remote_get($url, [ 'timeout' => 2.5, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 262144, 'headers' => $headers ]); if (is_wp_error($response)) { return new WP_Error('address_connection_error', '住所検索APIへの接続に失敗しました'); } $status = wp_remote_retrieve_response_code($response); if ($status === 401) { jpzac_cache_delete($token_cache_key); return new WP_Error('address_auth_error', 'API認証の更新が必要です'); } if ($status !== 200) { return new WP_Error('address_http_error', '住所検索APIでエラーが発生しました'); } $data = json_decode(wp_remote_retrieve_body($response), true); if (!is_array($data)) { return new WP_Error('address_json_error', '住所検索応答が不正です'); } if (empty($data['addresses']) || !is_array($data['addresses'])) { return new WP_Error('not_found', '住所が見つかりませんでした'); } $normalized = []; foreach (array_slice($data['addresses'], 0, 100) as $item) { if (!is_array($item)) { continue; } $address = [ 'pref' => sanitize_text_field( is_string($item['pref_name'] ?? null) ? $item['pref_name'] : '' ), 'city' => sanitize_text_field( is_string($item['city_name'] ?? null) ? $item['city_name'] : '' ), 'town' => sanitize_text_field( is_string($item['town_name'] ?? null) ? $item['town_name'] : '' ) ]; if ($address['pref'] === '' || $address['city'] === '') { continue; } $normalized[] = $address; } if (!$normalized) { return new WP_Error('not_found', '住所が見つかりませんでした'); } jpzac_cache_set( $address_cache_key, $normalized, DAY_IN_SECONDS ); return $normalized; } /** Public-facing message. Never expose internal API details to customers. */ function jpzac_customer_error_message($code) { if ($code === 'jpzac_rate_limited') { return '少し時間をおいてお試しください。'; } return '住所を取得できません。'; } /** * REST API. */ add_action('rest_api_init', function () { register_rest_route('jpzac/v1', '/address', [ 'methods' => WP_REST_Server::READABLE, 'permission_callback' => '__return_true', 'args' => [ 'zipcode' => [ 'required' => true, 'type' => 'string', 'validate_callback' => function ($value) { return is_string($value) && (bool) preg_match('/^[0-9]{3}-?[0-9]{4}$/D', $value); } ] ], 'callback' => function ($request) { $zipcode = $request->get_param('zipcode'); $normalized_zip = str_replace('-', '', $zipcode); // Cached results do not consume rate limit. $cached = jpzac_cache_get('jpzac_addr_' . $normalized_zip); if (jpzac_is_valid_address_cache($cached)) { return [ 'success' => true, 'data' => $cached ]; } $limit = jpzac_check_rate_limit(); if (is_wp_error($limit)) { return new WP_REST_Response([ 'success' => false, 'data' => [ 'message' => jpzac_customer_error_message($limit->get_error_code()) ] ], 429); } $result = jpzac_get_address_data($normalized_zip); if (is_wp_error($result)) { return new WP_REST_Response([ 'success' => false, 'data' => [ 'message' => jpzac_customer_error_message($result->get_error_code()) ] ], 400); } return [ 'success' => true, 'data' => $result ]; } ]); register_rest_route('jpzac/v1', '/preload', [ 'methods' => WP_REST_Server::READABLE, 'permission_callback' => '__return_true', 'callback' => function () { $token = jpzac_cache_get('jpzac_api_token'); if ($token) { return [ 'success' => true, 'cached' => true ]; } $limit = jpzac_check_rate_limit(); if (is_wp_error($limit)) { return new WP_REST_Response([ 'success' => false, 'data' => [ 'message' => jpzac_customer_error_message($limit->get_error_code()) ] ], 429); } $global_limit = jpzac_check_preload_global_limit(); if (is_wp_error($global_limit)) { return new WP_REST_Response([ 'success' => false, 'data' => [ 'message' => jpzac_customer_error_message($global_limit->get_error_code()) ] ], 429); } $result = jpzac_get_address_data('1000000'); if (is_wp_error($result)) { return new WP_REST_Response([ 'success' => false, 'cached' => false ], 503); } return [ 'success' => true, 'cached' => false ]; } ]); }); /** * WooCommerce postcode validation. * Keep the original JP compatibility behavior. */ add_filter('woocommerce_validate_postcode', function ($valid, $postcode, $country) { if ($country === 'JP') { return true; } return $valid; }, 10, 3); /** * Frontend scripts. */ add_action('wp_enqueue_scripts', function () { if (!function_exists('is_checkout') || !function_exists('is_account_page')) { return; } if (!is_checkout() && !is_account_page()) { return; } wp_enqueue_script( 'jpzac-autofill', plugin_dir_url(__FILE__) . 'jpzac.js', [], '1.3.6', true ); wp_add_inline_script( 'jpzac-autofill', 'window.jpzacAjax = ' . wp_json_encode([ 'ajax_url' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('jpzac_nonce'), 'restUrl' => esc_url_raw(rest_url()), 'restNonce' => wp_create_nonce('wp_rest') ]) . ';', 'before' ); }); /** * WooCommerce HPOS and Blocks compatibility. */ add_action('before_woocommerce_init', function () { if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) { \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', __FILE__, true ); \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true ); } }); add_action('wp_enqueue_scripts', function () { wp_enqueue_style( 'jpzac-style', plugin_dir_url(__FILE__) . 'style.css', array(), '1.3.6' ); });