| 1 |
<?php |
| 2 |
namespace Templately\Utils; |
| 3 |
|
| 4 |
use Templately\Modules\Auth\REST\Login; |
| 5 |
use Templately\Utils\Response\ErrorCode; |
| 6 |
use Templately\Utils\Response\ResponseNormalizer; |
| 7 |
use Templately\Utils\Response\RetryPolicy; |
| 8 |
use WP_Error; |
| 9 |
|
| 10 |
class Http extends Base { |
| 11 |
/** |
| 12 |
* API Endpoint |
| 13 |
* @var string |
| 14 |
*/ |
| 15 |
private $url = 'https://app.templately.com/api/plugin'; |
| 16 |
|
| 17 |
/** |
| 18 |
* Development Mode |
| 19 |
* @var boolean |
| 20 |
*/ |
| 21 |
private $dev_mode = false; |
| 22 |
|
| 23 |
/** |
| 24 |
* API Query |
| 25 |
* @var string |
| 26 |
*/ |
| 27 |
public $query = null; |
| 28 |
/** |
| 29 |
* API Endpoint |
| 30 |
* @var string |
| 31 |
*/ |
| 32 |
public $endpoint = null; |
| 33 |
|
| 34 |
/** |
| 35 |
* Setting the development mode. |
| 36 |
*/ |
| 37 |
public function __construct() { |
| 38 |
$this->dev_mode = Helper::is_dev_api(); |
| 39 |
} |
| 40 |
|
| 41 |
/** |
| 42 |
* Determining the endpoint URL based on the mode. |
| 43 |
* |
| 44 |
* @return string |
| 45 |
*/ |
| 46 |
public function url() { |
| 47 |
if ( Helper::is_dev_api() ) { |
| 48 |
$this->url = 'https://app.templately.dev/api/plugin'; |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Filter the API endpoint URL |
| 53 |
* |
| 54 |
* @since 3.5.0 |
| 55 |
* @param string $url The endpoint URL |
| 56 |
*/ |
| 57 |
$this->url = apply_filters('templately_dev_api_endpoint_url', $this->url); |
| 58 |
|
| 59 |
return $this->url; |
| 60 |
} |
| 61 |
|
| 62 |
/** |
| 63 |
* Generate Google OAuth authentication URL |
| 64 |
* |
| 65 |
* @param string $redirect_to Optional redirect path after authentication |
| 66 |
* @return string The Google auth URL with query parameters |
| 67 |
*/ |
| 68 |
public function google_auth_url($redirect_to = '', $current_url = '') { |
| 69 |
$base_url = $this->url(); |
| 70 |
// Replace /api/plugin with /api/auth/plugin/google |
| 71 |
$auth_url = str_replace('/api/plugin', '/api/auth/plugin/google', $base_url); |
| 72 |
|
| 73 |
// Get the referer to return to the exact same page we initiated login from securely |
| 74 |
if ( ! empty( $current_url ) ) { |
| 75 |
$referer = esc_url_raw( $current_url ); |
| 76 |
} else { |
| 77 |
$referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; |
| 78 |
} |
| 79 |
|
| 80 |
$return_url = wp_validate_redirect( $referer, '' ); |
| 81 |
|
| 82 |
if ( empty( $return_url ) ) { |
| 83 |
$return_url = admin_url( 'admin.php?page=templately' ); |
| 84 |
} |
| 85 |
|
| 86 |
// Unique random state — doubles as cache busting and as the CSRF token the |
| 87 |
// callback validates. Only minted into a transient for a logged-in user: |
| 88 |
// this endpoint is public, and an anonymous caller could otherwise flood |
| 89 |
// wp_options with tokens that can never authorize anything. |
| 90 |
$state = wp_generate_password( 32, false ); |
| 91 |
$state_owner = get_current_user_id(); |
| 92 |
|
| 93 |
if ( $state_owner > 0 ) { |
| 94 |
Database::set_transient( 'google_state_' . $state, $state_owner, 15 * MINUTE_IN_SECONDS ); |
| 95 |
} |
| 96 |
|
| 97 |
$return_params = [ |
| 98 |
'templately_google_login' => '1', |
| 99 |
'templately_state' => $state, |
| 100 |
]; |
| 101 |
|
| 102 |
// Add redirect-to parameter if provided |
| 103 |
if (!empty($redirect_to)) { |
| 104 |
$return_params['redirect-to'] = $redirect_to; |
| 105 |
} |
| 106 |
|
| 107 |
$site_url_with_params = add_query_arg($return_params, $return_url); |
| 108 |
|
| 109 |
$query_params = [ |
| 110 |
'site_url' => urlencode($site_url_with_params), |
| 111 |
'site_ip' => Helper::get_ip(), |
| 112 |
'state' => $state, |
| 113 |
]; |
| 114 |
|
| 115 |
return add_query_arg($query_params, $auth_url); |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* @param array $args |
| 120 |
* @return string |
| 121 |
*/ |
| 122 |
protected function prepareArgs( $args ) { |
| 123 |
$prepareArgs = ""; |
| 124 |
foreach ( $args as $key => $value ) { |
| 125 |
switch ( true ) { |
| 126 |
case is_int( $value ): |
| 127 |
case is_bool( $value ): |
| 128 |
case is_string( $value ) && ( $value === 'true' || $value === 'false' ): |
| 129 |
$prepareArgs .= "$key:" . $value . ","; |
| 130 |
break; |
| 131 |
default: |
| 132 |
$prepareArgs .= "$key:" . '"' . Helper::esc_json_string( $value ) . '"' . ","; |
| 133 |
break; |
| 134 |
} |
| 135 |
} |
| 136 |
|
| 137 |
return rtrim( $prepareArgs, ',' ); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Preparing query for the endpoint. |
| 142 |
* |
| 143 |
* @param string $query_name |
| 144 |
* @param string $params |
| 145 |
* @param array $funcArgs |
| 146 |
* @param array ...$args |
| 147 |
* @return Http |
| 148 |
*/ |
| 149 |
public function query( $query_name, $params, $funcArgs = [], ...$args ) { |
| 150 |
$query = '{'; |
| 151 |
$query .= $query_name; |
| 152 |
if ( is_array( $funcArgs ) && ! empty( $funcArgs ) ) { |
| 153 |
$query .= "(" . $this->prepareArgs( $funcArgs ) . ")"; |
| 154 |
} |
| 155 |
if ( ! empty( $params ) ) { |
| 156 |
$query .= "{"; |
| 157 |
$query .= $params; |
| 158 |
$query .= "}"; |
| 159 |
} |
| 160 |
$query .= '}'; |
| 161 |
|
| 162 |
$this->endpoint = $query_name; |
| 163 |
$this->query = ! empty( $args ) ? sprintf( $query, ...$args ) : $query; |
| 164 |
return $this; |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* Preparing mutation for the endpoint. |
| 169 |
* |
| 170 |
* @param string $mutate |
| 171 |
* @param string $params |
| 172 |
* @param array $funcArgs |
| 173 |
* @param array ...$args |
| 174 |
* @return Http |
| 175 |
*/ |
| 176 |
public function mutation( $mutate, $params, $funcArgs = [], ...$args ) { |
| 177 |
$this->query( $mutate, $params, $funcArgs, ...$args ); |
| 178 |
$mutation = 'mutation'; |
| 179 |
$mutation .= $this->query; |
| 180 |
$this->endpoint = $mutate; |
| 181 |
$this->query = ! empty( $args ) ? sprintf( $mutation, ...$args ) : $mutation; |
| 182 |
return $this; |
| 183 |
} |
| 184 |
|
| 185 |
/** |
| 186 |
* This function is responsible for Remote HTTP POST |
| 187 |
* |
| 188 |
* @param string $query |
| 189 |
* @param array $args |
| 190 |
* @return mixed |
| 191 |
*/ |
| 192 |
public function post( $args = [] ) { |
| 193 |
if ( empty( $query ) ) { |
| 194 |
$query = $this->query; |
| 195 |
} |
| 196 |
|
| 197 |
$headers = [ |
| 198 |
'Content-Type' => 'application/json', |
| 199 |
'Accept' => 'application/json', |
| 200 |
'x-templately-ip' => Helper::get_ip(), |
| 201 |
'x-templately-url' => home_url( '/' ), |
| 202 |
'x-templately-version' => TEMPLATELY_VERSION, |
| 203 |
]; |
| 204 |
|
| 205 |
if ( ! empty( $args['headers'] ) ) { |
| 206 |
$headers = wp_parse_args( $args['headers'], $headers ); |
| 207 |
unset( $args['headers'] ); |
| 208 |
} |
| 209 |
|
| 210 |
if ( defined( 'TEMPLATELY_DEBUG_LOG' ) && TEMPLATELY_DEBUG_LOG ) { |
| 211 |
Helper::log( 'URL: ' . $this->url() ); |
| 212 |
Helper::log( 'QUERY: ' . $query ); |
| 213 |
} |
| 214 |
|
| 215 |
$_default_args = [ |
| 216 |
'timeout' => $this->dev_mode ? 120 : 30, |
| 217 |
'headers' => $headers, |
| 218 |
'body' => wp_json_encode( [ |
| 219 |
'query' => $query |
| 220 |
] ) |
| 221 |
]; |
| 222 |
|
| 223 |
$args = wp_parse_args( $args, $_default_args ); |
| 224 |
|
| 225 |
// 043 / PRD PHP-1 — the retry decision moved to RetryPolicy. |
| 226 |
// |
| 227 |
// This loop retried on WP_Error only, and with NO DELAY: three requests |
| 228 |
// within milliseconds at a server that had just failed to answer one. It |
| 229 |
// also treated every HTTP status as final, so a 502 from a restarting |
| 230 |
// gateway was never retried at all. RetryPolicy adds the transient |
| 231 |
// statuses and a jittered backoff. |
| 232 |
$attempt = 0; |
| 233 |
$maxRetries = defined( 'TEMPLATELY_HTTP_RETRY' ) ? (int) TEMPLATELY_HTTP_RETRY : RetryPolicy::MAX_ATTEMPTS; |
| 234 |
|
| 235 |
// Entry-point marker (engagement telemetry). A URL QUERY PARAM so the cloud's |
| 236 |
// access logs capture it with zero cloud-side code — never a GraphQL argument |
| 237 |
// (unknown arguments fail GraphQL validation; a query param on the endpoint |
| 238 |
// URL is ignored by the resolver). Appended here, NOT in url(): url() also |
| 239 |
// feeds google_auth_url(), which must stay clean. |
| 240 |
$request_url = $this->url(); |
| 241 |
if ( '' !== Helper::get_request_source() ) { |
| 242 |
$request_url = add_query_arg( 'tl_source', Helper::get_request_source(), $request_url ); |
| 243 |
} |
| 244 |
|
| 245 |
while ( true ) { |
| 246 |
$response = wp_remote_post( $request_url, $args ); |
| 247 |
|
| 248 |
if ( $attempt + 1 >= $maxRetries || ! RetryPolicy::should_retry( $response, $attempt ) ) { |
| 249 |
break; |
| 250 |
} |
| 251 |
|
| 252 |
RetryPolicy::wait( $attempt ); |
| 253 |
$attempt++; |
| 254 |
} |
| 255 |
|
| 256 |
$retryCount = $attempt + 1; |
| 257 |
|
| 258 |
if ( defined( 'TEMPLATELY_DEBUG_LOG' ) && TEMPLATELY_DEBUG_LOG ) { |
| 259 |
Helper::log( 'Retry Count: ' . $retryCount ); |
| 260 |
// Helper::log( 'RAW RESPONSE: ' ); |
| 261 |
// Helper::log( $response ); |
| 262 |
// Helper::log( 'END RAW RESPONSE' ); |
| 263 |
} |
| 264 |
|
| 265 |
return $this->maybeErrors( $response, $args ); |
| 266 |
} |
| 267 |
|
| 268 |
/** |
| 269 |
* Formatting the self::post() response |
| 270 |
* |
| 271 |
* @param mixed $response |
| 272 |
* @param array $args |
| 273 |
* @return mixed |
| 274 |
*/ |
| 275 |
private function maybeErrors( &$response, $args = [] ) { |
| 276 |
// 043 FR-003 — every shape the cloud can return is classified in ONE |
| 277 |
// place now. The hand-rolled cascade this replaced grew a branch per |
| 278 |
// discovered shape and still disagreed with the equivalent cascade in |
| 279 |
// `Helper::make_api_request()`; see the 28 captured fixtures in |
| 280 |
// `specs/043-core-api-response-contract/fixtures/`. |
| 281 |
$normalized = ResponseNormalizer::normalize( $response, [ |
| 282 |
'endpoint' => $this->endpoint, |
| 283 |
] ); |
| 284 |
|
| 285 |
if ( $normalized->is_error() ) { |
| 286 |
$error = $normalized->error(); |
| 287 |
|
| 288 |
if ( defined( 'TEMPLATELY_DEBUG_LOG' ) && TEMPLATELY_DEBUG_LOG ) { |
| 289 |
Helper::log( 'ERROR: ' . $error->code() . ' — ' . $error->message() ); |
| 290 |
} |
| 291 |
|
| 292 |
// An expired session still tears down the stored login — but it now |
| 293 |
// ALSO returns a real error. It used to return a plain array |
| 294 |
// (`['redirect' => true, …]`), which every `is_wp_error()` caller |
| 295 |
// read as SUCCESS and happily passed on as a payload. That is the |
| 296 |
// INV-2 class of bug this contract exists to remove. |
| 297 |
if ( ErrorCode::AUTH_EXPIRED === $error->code() || ErrorCode::INVALID_API_KEY === $error->code() ) { |
| 298 |
Login::get_instance()->delete(); |
| 299 |
} |
| 300 |
|
| 301 |
return $error; |
| 302 |
} |
| 303 |
|
| 304 |
$_response = $normalized->payload(); |
| 305 |
|
| 306 |
if ( defined( 'TEMPLATELY_DEBUG_LOG' ) && TEMPLATELY_DEBUG_LOG ) { |
| 307 |
Helper::log( 'RESPONSE: ' ); |
| 308 |
Helper::log( $_response ); |
| 309 |
Helper::log( 'END RESPONSE' ); |
| 310 |
} |
| 311 |
|
| 312 |
return $_response; |
| 313 |
} |
| 314 |
|
| 315 |
} |
| 316 |
|