PluginProbe
PostNL for WooCommerce / trunk
PostNL for WooCommerce vtrunk
5.9.12 5.9.11 5.9.10 5.9.9 5.9.8 5.9.7 5.9.6 trunk 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 3.1.4 3.1.5 3.1.6 3.1.7 4.0.0 4.0.1 4.0.2 4.3.2 4.3.3 4.4.0 4.4.1 All 72 releases
woo-postnl / src / Rest_API / Barcode / Key_Validator.php

Key_Validator.php in PostNL for WooCommerce trunk, at src/Rest_API/Barcode/Key_Validator.php

199 lines 6.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Class Rest_API\Barcode\Key_Validator file.
4 *
5 * Sends a minimal Barcode API request with a candidate API key to verify that
6 * the key is accepted by PostNL. Used when a merchant enters a new API key in
7 * the settings and we want to confirm it works before switching over to it.
8 *
9 * The call deliberately goes to the V1 Barcode endpoint with a plain apikey
10 * header rather than through the PostNL SDK: the SDK is a dev-only dependency
11 * (it raises the PHP floor to 8.2 and is stripped from the release build), so a
12 * merchant build has no SDK to route to. This validator runs on every settings
13 * save, so it must work on a plain PHP 7.4 install with no SDK present.
14 *
15 * @package PostNLWooCommerce\Rest_API\Barcode
16 */
17
18 namespace PostNLWooCommerce\Rest_API\Barcode;
19
20 use PostNLWooCommerce\Main;
21 use PostNLWooCommerce\Shipping_Method\Settings;
22 use PostNLWooCommerce\Utils;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 /**
29 * Class Key_Validator
30 */
31 class Key_Validator {
32
33 /**
34 * Validation reasons. A single value describes the outcome so the save path
35 * and the AJAX endpoint classify a response identically and can never tell a
36 * merchant two different things about the same call.
37 */
38 const REASON_VALID = 'valid';
39 const REASON_MISSING = 'missing';
40 const REASON_INVALID = 'invalid';
41 const REASON_UNREACHABLE = 'unreachable';
42 const REASON_REJECTED = 'rejected';
43
44 /**
45 * Validate an API key by calling the PostNL Barcode endpoint.
46 *
47 * The environment is taken from the caller rather than the stored setting so
48 * that a key entered while switching environments is validated against the
49 * host it will actually be used on.
50 *
51 * @param string $api_key The API key to test.
52 * @param string $customer_code Customer code from settings.
53 * @param string $customer_num Customer number from settings.
54 * @param bool $is_sandbox Whether to test against the sandbox environment.
55 *
56 * @return true|\WP_Error True when the key is valid, otherwise a WP_Error whose
57 * code is one of the REASON_* slugs.
58 */
59 public static function validate( $api_key, $customer_code, $customer_num, $is_sandbox = false ) {
60 // Strip control characters (incl. CR/LF) before the value is used as an
61 // HTTP header, so a crafted on-blur request cannot attempt header
62 // manipulation. Real keys never contain them, so this only cleans junk.
63 $api_key = (string) preg_replace( '/[\x00-\x1F\x7F]+/', '', trim( (string) $api_key ) );
64 $customer_code = trim( (string) $customer_code );
65 $customer_num = trim( (string) $customer_num );
66
67 if ( '' === $customer_code || '' === $customer_num ) {
68 return self::error( self::REASON_MISSING );
69 }
70
71 if ( '' === $api_key ) {
72 return self::error( self::REASON_INVALID );
73 }
74
75 $base = $is_sandbox ? POSTNL_WC_SANDBOX_API_URL : POSTNL_WC_PROD_API_URL;
76 $endpoint = $base . '/shipment/v1_1/barcode';
77 $range = Utils::get_barcode_range( '3S', '' );
78
79 $url = add_query_arg(
80 array(
81 'Type' => '3S',
82 'Serie' => '000000000-999999999',
83 'CustomerCode' => $customer_code,
84 'CustomerNumber' => $customer_num,
85 'Range' => $range,
86 ),
87 $endpoint
88 );
89
90 $response = wp_remote_get(
91 $url,
92 array(
93 'timeout' => 15,
94 'headers' => array(
95 'apikey' => $api_key,
96 'NewKey' => Settings::get_instance()->get_new_key_header_value(),
97 'accept' => 'application/json',
98 'Content-Type' => 'application/json',
99 'SourceSystem' => '35',
100 ),
101 )
102 );
103
104 $logger = Main::get_logger();
105 if ( $logger ) {
106 $logger->write( 'PostNL new API key validation request (V1 barcode).' );
107 }
108
109 $reason = self::classify( $response );
110
111 return self::REASON_VALID === $reason ? true : self::error( $reason );
112 }
113
114 /**
115 * Classify a Barcode API response into a reason.
116 *
117 * The HTTP status code decides first; the body is consulted only to tell a
118 * genuine rejection (a fault or Errors payload returned on a 2xx) apart from
119 * a real barcode. Reading the body first is what made a 429 or 503 carrying
120 * an Apigee fault look like a bad key, so status wins here.
121 *
122 * @param array|\WP_Error $response Result of wp_remote_get().
123 *
124 * @return string One of the REASON_* slugs.
125 */
126 protected static function classify( $response ) {
127 if ( is_wp_error( $response ) ) {
128 return self::REASON_UNREACHABLE;
129 }
130
131 $code = (int) wp_remote_retrieve_response_code( $response );
132 $data = json_decode( wp_remote_retrieve_body( $response ), true );
133
134 if ( 401 === $code || 403 === $code ) {
135 return self::REASON_INVALID;
136 }
137
138 if ( $code >= 200 && $code < 300 ) {
139 if ( is_array( $data ) && ( ! empty( $data['fault'] ) || ! empty( $data['Errors'] ) || ! empty( $data['Error'] ) ) ) {
140 return self::REASON_INVALID;
141 }
142
143 if ( is_array( $data ) && isset( $data['Barcode'] ) ) {
144 return self::REASON_VALID;
145 }
146
147 // A 2xx with no barcode means PostNL never actually minted one, so we
148 // have no proof the key works — treat it as "could not check", not bad.
149 return self::REASON_UNREACHABLE;
150 }
151
152 if ( 429 === $code || $code >= 500 ) {
153 return self::REASON_UNREACHABLE;
154 }
155
156 // Any other 4xx: PostNL understood the request and refused it, most often
157 // because the Customer Code or Number does not go with this key.
158 return self::REASON_REJECTED;
159 }
160
161 /**
162 * Build the WP_Error for a non-valid reason, carrying the reason as its code
163 * and the merchant-facing sentence as its message.
164 *
165 * @param string $reason One of the REASON_* slugs.
166 *
167 * @return \WP_Error
168 */
169 protected static function error( $reason ) {
170 return new \WP_Error( $reason, self::reason_message( $reason ) );
171 }
172
173 /**
174 * Merchant-facing sentence for a validation reason. Kept as the single source
175 * so the save-time notice and the on-blur endpoint word the same outcome the
176 * same way.
177 *
178 * @param string $reason One of the REASON_* slugs.
179 *
180 * @return string
181 */
182 public static function reason_message( $reason ) {
183 switch ( $reason ) {
184 case self::REASON_MISSING:
185 return __( 'Fill in your Customer Code and Customer Number first, then check the key again.', 'postnl-for-woocommerce' );
186
187 case self::REASON_REJECTED:
188 return __( 'PostNL could not process the check. This usually means the Customer Code or Customer Number does not match this key.', 'postnl-for-woocommerce' );
189
190 case self::REASON_UNREACHABLE:
191 return __( 'We could not reach PostNL to check the key. Please try again in a few minutes.', 'postnl-for-woocommerce' );
192
193 case self::REASON_INVALID:
194 default:
195 return __( 'The newly entered API key is invalid. Please check the key and enter it again.', 'postnl-for-woocommerce' );
196 }
197 }
198 }
199