PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / trunk
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! vtrunk
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / mcp-core / Support / AjaxLoopbackDispatcher.php

AjaxLoopbackDispatcher.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! trunk, at modules/mcp-core/Support/AjaxLoopbackDispatcher.php

338 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Authenticated in-process HTTP loopback to `admin-ajax.php` for the FSI
4 * `templately_pack_*` actions (spec 045, research.md §1–§3).
5 *
6 * Lives beside `RestDispatcher` — the two are siblings, one per calling
7 * convention: in-process `rest_do_request()` for REST routes, an authenticated
8 * loopback for transport-coupled ajax handlers. Neither knows anything about a
9 * particular capability, which is why both sit in mcp-core rather than in a
10 * feature module.
11 *
12 * WHY a loopback instead of 041's in-process `RestDispatcher`: the FSI
13 * handlers are `wp_ajax_*` (not REST) and are transport-coupled —
14 * `Concerns\RunsImport::finishRequestHeaders()` sends SSE headers + calls
15 * `fastcgi_finish_request()`, and each handler `exit`s at a time-slice
16 * boundary. Calling them in-process would emit SSE into / prematurely end the
17 * MCP request. A loopback replays the exact browser request, one SSE chunk at
18 * a time, with zero importer changes (FR-012). See plan.md Complexity Tracking.
19 *
20 * AUTH: the MCP request is authenticated by the mcp-adapter transport
21 * (Application Password / STDIO), so `get_current_user_id()` is reliable, but
22 * that identity does not carry into a fresh loopback HTTP request. We mint a
23 * short-lived real session for the resolved user and attach its auth cookies,
24 * plus a freshly-generated `templately_nonce` — the underlying handler's own
25 * nonce + capability checks then pass exactly as for a browser request. No
26 * secret is stored; the session token is created per call and expires in 5m.
27 *
28 * @package Templately\Modules\McpCore\Support
29 */
30
31 namespace Templately\Modules\McpCore\Support;
32
33 use WP_Error;
34 use WP_Http_Cookie;
35 use WP_Session_Tokens;
36
37 class AjaxLoopbackDispatcher {
38
39 /** Session/cookie lifetime for a single loopback (seconds). */
40 const AUTH_TTL = 300;
41
42 /**
43 * Loopback a JSON action (handlers that end in `wp_send_json*`), e.g.
44 * `import_settings`, `import_status`, `import_revert`.
45 *
46 * @param string $action Bare action name (without the `templately_pack_` prefix).
47 * @param array $args Request params (nonce is added automatically).
48 * @param string $method GET|POST.
49 * @param int $timeout
50 * @return array|WP_Error Decoded JSON body, or a `loopback_failed` WP_Error.
51 */
52 public static function dispatch_json( string $action, array $args = [], string $method = 'POST', int $timeout = 60 ) {
53 $response = self::request( $action, $args, $method, $timeout );
54 if ( is_wp_error( $response ) ) {
55 return $response;
56 }
57
58 $body = wp_remote_retrieve_body( $response );
59 $decoded = json_decode( $body, true );
60
61 // Robustness: admin-ajax can prepend output before wp_send_json's payload
62 // (PHP notices/deprecations when WP_DEBUG_DISPLAY is on, or stray plugin
63 // output), which json_decode() then rejects. wp_send_json emits the JSON as
64 // the final output, so retry from each '{' until one parses through to the end.
65 if ( ! is_array( $decoded ) ) {
66 for ( $pos = strpos( $body, '{' ); false !== $pos; $pos = strpos( $body, '{', $pos + 1 ) ) {
67 $candidate = json_decode( substr( $body, $pos ), true );
68 if ( is_array( $candidate ) ) {
69 $decoded = $candidate;
70 break;
71 }
72 }
73 }
74
75 if ( ! is_array( $decoded ) ) {
76 return new WP_Error(
77 'loopback_failed',
78 __( 'The import backend returned an unreadable response.', 'templately' )
79 );
80 }
81
82 return $decoded;
83 }
84
85 /**
86 * Read a failure out of a decoded ajax body, or null when it succeeded.
87 *
88 * Exists because the envelope moved. Spec 043 made every admin-ajax response
89 * `{success, code, message, data:{status, severity, retryable, fields,
90 * context}}` at HTTP 200 — the human message is now at the TOP level and
91 * retryability is the first-class `data.retryable`, replacing the
92 * `create_session_and_download` handler's old bespoke `data.should_retry`
93 * key (see Concerns\HandlesSession). Callers written against the old shape
94 * read `data.message`, which is now always absent: every failure would
95 * silently collapse to a generic fallback string and every retryable one
96 * would look permanent. Reading the envelope in ONE place is what keeps that
97 * from having to be remembered at each call site.
98 *
99 * @param array|mixed $decoded Decoded body from {@see self::dispatch_json()}.
100 * @param string $fallback Message to use when the envelope carries none.
101 * @return WP_Error|null
102 */
103 public static function envelope_error( $decoded, string $fallback ): ?WP_Error {
104 if ( ! is_array( $decoded ) ) {
105 return new WP_Error( 'loopback_failed', $fallback );
106 }
107
108 if ( ! empty( $decoded['success'] ) ) {
109 return null;
110 }
111
112 $data = is_array( $decoded['data'] ?? null ) ? $decoded['data'] : [];
113
114 $message = '';
115 foreach ( [ $decoded['message'] ?? '', $data['message'] ?? '' ] as $candidate ) {
116 if ( is_string( $candidate ) && '' !== $candidate ) {
117 $message = $candidate;
118 break;
119 }
120 }
121
122 return new WP_Error(
123 is_string( $decoded['code'] ?? null ) && '' !== $decoded['code'] ? $decoded['code'] : 'loopback_failed',
124 '' !== $message ? $message : $fallback,
125 [ 'retryable' => ! empty( $data['retryable'] ) ]
126 );
127 }
128
129 /**
130 * Loopback the SSE `import` / `create_session_and_download` actions and
131 * return the parsed events from the single chunk the handler streams
132 * before it `exit`s (one time-slice — poll-driven advance, FR-005).
133 *
134 * @param string $action Bare action name.
135 * @param array $args Request params.
136 * @param int $timeout Seconds to allow one slice.
137 * @return array|WP_Error List of parsed event arrays, or `loopback_failed`.
138 */
139 public static function dispatch_sse( string $action, array $args = [], int $timeout = 120 ) {
140 // FSI reads the session id from $_GET and the nonce from $_GET/$_POST;
141 // send as a GET query so `import` resolves it the way the browser does.
142 $response = self::request( $action, $args, 'GET', $timeout );
143 if ( is_wp_error( $response ) ) {
144 return $response;
145 }
146
147 return self::parse_sse( wp_remote_retrieve_body( $response ) );
148 }
149
150 /**
151 * Perform the authenticated loopback request.
152 *
153 * @param string $action
154 * @param array $args
155 * @param string $method
156 * @param int $timeout
157 * @return array|WP_Error The raw wp_remote_* response array, or WP_Error.
158 */
159 private static function request( string $action, array $args, string $method, int $timeout ) {
160 $user_id = get_current_user_id();
161 if ( ! $user_id ) {
162 return new WP_Error( 'loopback_failed', __( 'No authenticated user for the import loopback.', 'templately' ) );
163 }
164
165 $auth = self::mint_auth( $user_id );
166 if ( is_wp_error( $auth ) ) {
167 return $auth;
168 }
169 $cookies = $auth['cookies'];
170
171 $params = array_merge( $args, [
172 'action' => 'templately_pack_' . $action,
173 // Marks this as an MCP-loopback-driven request so MCP can cap the FSI
174 // slice duration under the gateway timeout (see MCP::mcp_fsi_slice_cap).
175 'templately_mcp_driven' => 1,
176 // The nonce MUST be built against the same session token the loopback
177 // request will present (the minted cookie's token). wp_create_nonce()
178 // here would use THIS (MCP) request's session token — which is empty,
179 // because the MCP request authenticates via Application Password with no
180 // logged-in cookie — and would then fail wp_verify_nonce() in the
181 // loopback, where get_session_token() returns the minted token. So
182 // compute the nonce explicitly for that token.
183 'nonce' => self::nonce_for_token( 'templately_nonce', $user_id, $auth['token'] ),
184 ] );
185
186 // Loopback base URL. Defaults to the site's own admin-ajax. On hosts that
187 // cannot reach their own public URL (reverse proxies, split-horizon DNS,
188 // containerized setups — the same class of environment where WP-Cron's
189 // loopback fails), override via the TEMPLATELY_MCP_FSI_LOOPBACK_URL
190 // constant or the `templately_mcp_fsi_loopback_url` filter to point at an
191 // internally-reachable address; the correct vhost is preserved by the Host
192 // header below so cookies/home_url resolve as normal.
193 $default_url = defined( 'TEMPLATELY_MCP_FSI_LOOPBACK_URL' ) && TEMPLATELY_MCP_FSI_LOOPBACK_URL
194 ? TEMPLATELY_MCP_FSI_LOOPBACK_URL
195 : admin_url( 'admin-ajax.php' );
196 $url = apply_filters( 'templately_mcp_fsi_loopback_url', $default_url );
197 $http_args = [
198 'timeout' => $timeout,
199 'blocking' => true,
200 'cookies' => $cookies,
201 'sslverify' => false, // self-loopback; the dev filter already relaxes this too.
202 'headers' => [
203 'Accept' => 'text/event-stream, application/json',
204 'Host' => wp_parse_url( home_url(), PHP_URL_HOST ),
205 ],
206 ];
207
208 if ( 'GET' === strtoupper( $method ) ) {
209 // Encoded RECURSIVELY. add_query_arg() serialises through build_query()
210 // with url-encoding disabled, so the values have to arrive encoded — but
211 // a flat `array_map( 'rawurlencode', … )` raises a TypeError the moment
212 // any capability passes an array-valued argument, which would take the
213 // whole import down with a PHP error rather than a reportable failure.
214 $response = wp_remote_get( add_query_arg( self::encode_query( $params ), $url ), $http_args );
215 } else {
216 $http_args['body'] = $params;
217 $response = wp_remote_post( $url, $http_args );
218 }
219
220 if ( is_wp_error( $response ) ) {
221 return new WP_Error(
222 'loopback_failed',
223 sprintf(
224 /* translators: %s: underlying HTTP error. */
225 __( 'The site could not reach itself to run the import (loopback blocked): %s', 'templately' ),
226 $response->get_error_message()
227 )
228 );
229 }
230
231 $code = wp_remote_retrieve_response_code( $response );
232 if ( $code && $code >= 400 ) {
233 return new WP_Error(
234 'loopback_failed',
235 sprintf(
236 /* translators: %d: HTTP status code. */
237 __( 'The import backend returned HTTP %d.', 'templately' ),
238 $code
239 )
240 );
241 }
242
243 return $response;
244 }
245
246 /**
247 * rawurlencode every scalar in a (possibly nested) query array.
248 *
249 * @param array $params
250 * @return array
251 */
252 private static function encode_query( array $params ): array {
253 return array_map(
254 static function ( $value ) {
255 return is_array( $value )
256 ? self::encode_query( $value )
257 : rawurlencode( (string) $value );
258 },
259 $params
260 );
261 }
262
263 /**
264 * Mint a short-lived real session for the user, returning the session token
265 * plus the auth cookies wp validates on the loopback. A genuine session
266 * token is required — a cookie without one fails `WP_Session_Tokens::verify()`
267 * — and the caller also needs the token to build a matching nonce.
268 *
269 * @param int $user_id
270 * @return array{token:string,cookies:WP_Http_Cookie[]}|WP_Error
271 */
272 private static function mint_auth( int $user_id ) {
273 if ( ! function_exists( 'wp_generate_auth_cookie' ) ) {
274 require_once ABSPATH . WPINC . '/pluggable.php';
275 }
276
277 $expiration = time() + self::AUTH_TTL;
278 $manager = WP_Session_Tokens::get_instance( $user_id );
279 $token = $manager->create( $expiration );
280
281 $auth_scheme = is_ssl() ? 'secure_auth' : 'auth';
282 $auth_cookie_name = is_ssl() ? SECURE_AUTH_COOKIE : AUTH_COOKIE;
283
284 return [
285 'token' => $token,
286 'cookies' => [
287 new WP_Http_Cookie( [
288 'name' => LOGGED_IN_COOKIE,
289 'value' => wp_generate_auth_cookie( $user_id, $expiration, 'logged_in', $token ),
290 ] ),
291 new WP_Http_Cookie( [
292 'name' => $auth_cookie_name,
293 'value' => wp_generate_auth_cookie( $user_id, $expiration, $auth_scheme, $token ),
294 ] ),
295 ],
296 ];
297 }
298
299 /**
300 * Build a `templately_nonce` bound to a specific user + session token,
301 * replicating wp_create_nonce()'s formula so the value verifies in the
302 * loopback request (where get_session_token() returns $token). We cannot use
303 * wp_create_nonce() directly because it reads THIS request's session token,
304 * which is empty under Application-Password auth. Formula stable since WP 3.x.
305 *
306 * @param string $action
307 * @param int $uid
308 * @param string $token
309 * @return string
310 */
311 private static function nonce_for_token( string $action, int $uid, string $token ): string {
312 $tick = wp_nonce_tick( $action );
313 return substr( wp_hash( $tick . '|' . $action . '|' . $uid . '|' . $token, 'nonce' ), -12, 10 );
314 }
315
316 /**
317 * Parse an SSE body (`event: …\n data: {json}\n\n`) into a list of decoded
318 * `data:` payloads. Non-JSON data lines are skipped.
319 *
320 * @param string $body
321 * @return array
322 */
323 private static function parse_sse( string $body ): array {
324 $events = [];
325 foreach ( preg_split( '/\r\n|\r|\n/', $body ) as $line ) {
326 if ( 0 !== strpos( $line, 'data:' ) ) {
327 continue;
328 }
329 $json = trim( substr( $line, strlen( 'data:' ) ) );
330 $decoded = json_decode( $json, true );
331 if ( is_array( $decoded ) ) {
332 $events[] = $decoded;
333 }
334 }
335 return $events;
336 }
337 }
338