PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.0 2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 All 129 releases
wdesignkit / includes / admin / import / class-wdkit-import-remote.php

class-wdkit-import-remote.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/import/class-wdkit-import-remote.php

1,169 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Taking full-site imports handed over from wdesignkit.com.
4 *
5 * ── The shape, and why it is this shape ────────────────────────────────────
6 *
7 * On the website someone picks a kit, answers questions about their business, has the copy
8 * written and the images chosen, and only then picks which of their WordPress sites to build.
9 * The prepared website is parked on a queue there. This class is the other end: it asks the
10 * queue whether anything is addressed to this site, takes it, and hands it to the importer
11 * this plugin already ships.
12 *
13 * The site calls out; nothing calls in. That is not a preference — `local`, `.dev`, staging
14 * and firewalled installs are most of what people actually build on, and none of them can be
15 * reached from a server. An inbound webhook would have quietly worked for a minority of sites
16 * and mystified everyone else.
17 *
18 * ── What this file does and does not do ────────────────────────────────────
19 *
20 * It moves jobs, and that is all. Claiming one fires `wdkit_import_job_claimed`; running it
21 * is the importer's job, through the entry point it already has. There is deliberately no
22 * import logic here — a second importer that drifts from the first is the failure mode this
23 * whole arrangement is meant to avoid.
24 *
25 * ── How an import cannot happen twice ──────────────────────────────────────
26 *
27 * The queue's own guards are described in ImportJobController on the server. This side adds
28 * one more, for the accident that is local to WordPress: while a job is held, `poll()`
29 * refuses to take another. Two overlapping cron ticks therefore cannot start two imports of
30 * the same site, whatever the queue would have offered.
31 *
32 * ── The one honest limitation ──────────────────────────────────────────────
33 *
34 * WP-Cron only runs when something requests the site. A site nobody is visiting will not
35 * notice a queued import until it gets a request or a real system cron fires. That is the
36 * price of the site making the outbound connection, and it is worth stating plainly rather
37 * than hiding behind a one-minute schedule that quietly does not tick.
38 *
39 * @package Wdesignkit
40 * @since 2.6.6
41 */
42
43 if ( ! defined( 'ABSPATH' ) ) {
44 exit;
45 }
46
47 if ( ! class_exists( 'Wdkit_Import_Remote' ) ) {
48
49 /**
50 * Queue transport for site-side imports.
51 */
52 class Wdkit_Import_Remote {
53
54 /**
55 * The credential this site polls with. Minted once, while an admin is signed in.
56 *
57 * @var string
58 */
59 const TOKEN_OPTION = 'wdkit_import_poll_token';
60
61 /**
62 * The same credential, under the name a provisioned sandbox receives it as.
63 *
64 * A sandbox is created for the visitor rather than connected by them, so nobody ever signs
65 * in on it and register() never runs there. Its token is written straight into the site by
66 * the provisioner, through an endpoint that only accepts this one option name - so this is
67 * the key to read when our own is empty. A site that registered normally never has it.
68 *
69 * @var string
70 */
71 const SANDBOX_TOKEN_OPTION = 'wdk_poll_token';
72
73 /**
74 * The job currently held, if any. Not autoloaded: it carries the whole prepared
75 * website, which is tens of kilobytes and of no interest to any other request.
76 *
77 * @var string
78 */
79 const JOB_OPTION = 'wdkit_import_active_job';
80
81 /**
82 * The WordPress user a queued import runs as.
83 *
84 * Recorded at registration, which is the one moment an actual administrator is present
85 * and their capability has been checked. A cron tick has no current user at all, and
86 * the full-site entry point requires `manage_options` — without this, every queued
87 * import failed with "Insufficient permissions" the instant it started.
88 *
89 * @var string
90 */
91 const USER_OPTION = 'wdkit_import_poll_user';
92
93 /**
94 * Cron hook and its schedule.
95 *
96 * @var string
97 */
98 const CRON_HOOK = 'wdkit_import_poll_jobs';
99 const SCHEDULE = 'wdkit_import_minute';
100
101 /**
102 * How long a held job may go without progress before this side gives up on it.
103 *
104 * Shorter than the server's lease, on purpose: the site should notice its own stalled
105 * import and let go before the queue decides the site is dead and re-offers the job to
106 * it. The alternative ordering has both ends thinking they are mid-import.
107 *
108 * @var int
109 */
110 const HELD_TIMEOUT = 180;
111
112 /**
113 * How many poll() ticks report() retries a completed/failed report that failed to
114 * reach the queue, before giving up and clearing the job anyway.
115 *
116 * At one attempt per scheduled poll, this spans roughly the same order of time as
117 * HELD_TIMEOUT gives a stalled running import — long enough for a transient host issue
118 * to clear, short enough that a site whose outbound calls are blocked outright does not
119 * refuse every future import forever.
120 *
121 * @var int
122 */
123 const PENDING_REPORT_ATTEMPTS = 10;
124
125 /**
126 * Transient that rate-limits the wake route.
127 *
128 * @var string
129 */
130 const WAKE_THROTTLE = 'wdkit_import_wake_throttle';
131
132 /**
133 * Where a wake's nonce waits for the cron run that will use it.
134 *
135 * @var string
136 */
137 const NONCE_OPTION = 'wdkit_import_wake_nonce'; // legacy, cleaned up on load
138
139 /**
140 * Records that tidy_options() has finished, so it stops running on every admin_init.
141 *
142 * @var string
143 */
144 const TIDY_OPTION = 'wdkit_import_tidy_done';
145
146 /**
147 * Bump to make tidy_options() run once more on sites that already ran it.
148 *
149 * @var int
150 */
151 const TIDY_VERSION = 1;
152
153 /**
154 * Shortest gap between two wakes, in seconds.
155 *
156 * Long enough that a loop of requests cannot turn into a loop of claim calls, short
157 * enough that the website's own retry — one per status poll while a job is still
158 * unclaimed — gets through on its second try if the first raced the job's insert.
159 *
160 * @var int
161 */
162 const WAKE_INTERVAL = 5;
163
164 /**
165 * Register the schedule and the poll. Called once, from the import loader.
166 *
167 * @return void
168 */
169 public static function init() {
170 add_filter( 'cron_schedules', array( __CLASS__, 'add_schedule' ) ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected -- a queued import should start promptly; the tick is a cheap POST that returns null when there is no work.
171 add_action( self::CRON_HOOK, array( __CLASS__, 'poll' ) );
172
173 /* Registration needs a signed-in cloud session, so it can only happen while
174 * someone is in wp-admin. Cheap: it is a single option read once a token exists. */
175 add_action( 'admin_init', array( __CLASS__, 'maybe_register' ) );
176
177 add_action( 'admin_init', array( __CLASS__, 'schedule' ) );
178
179 add_action( 'rest_api_init', array( __CLASS__, 'register_wake_route' ) );
180
181 add_action( 'admin_init', array( __CLASS__, 'tidy_options' ) );
182
183 add_action( 'admin_init', array( __CLASS__, 'maybe_poll_from_admin' ) );
184 }
185
186 /**
187 * How often an admin request may stand in for the cron tick, in seconds.
188 */
189 const ADMIN_POLL_INTERVAL = 30;
190
191 /**
192 * Transient throttling the admin-request poll.
193 */
194 const ADMIN_POLL_THROTTLE = 'wdkit_import_admin_poll';
195
196 /**
197 * Poll from an ordinary wp-admin request, after its response has gone out.
198 *
199 * The cron tick needs WordPress to reach its own wp-cron.php, and a site behind a
200 * host's password gate (ZipWP's "Verification" page), basic auth or a blocked loopback
201 * never gets that request - and the cloud's wake is stopped at the same gate. Such a
202 * site sat on a pending job forever, even while its owner had wp-admin open as the
203 * website told them to. Any admin request (including the dashboard heartbeat) now does
204 * the poll itself, at most once per interval.
205 *
206 * @return void
207 */
208 public static function maybe_poll_from_admin() {
209 /* No poll token check here: the follow-up events in poll_after_response() matter on
210 * every site, and poll() itself returns at once when there is no token. */
211 if ( wp_doing_cron() ) {
212 return;
213 }
214
215 /* Without fastcgi_finish_request() the page cannot be released before the poll, and
216 * a claimed job would run inside it - a page load that spins for the whole import.
217 * There, only background admin-ajax requests (the dashboard heartbeat) carry it. */
218 if ( ! function_exists( 'fastcgi_finish_request' ) && ! wp_doing_ajax() ) {
219 return;
220 }
221
222 if ( false !== get_transient( self::ADMIN_POLL_THROTTLE ) ) {
223 return;
224 }
225
226 set_transient( self::ADMIN_POLL_THROTTLE, 1, self::ADMIN_POLL_INTERVAL );
227
228 /* After WordPress's own output flush (priority 1), so the page is already sent. */
229 add_action( 'shutdown', array( __CLASS__, 'poll_after_response' ), 2 );
230 }
231
232 /**
233 * The deferred half of maybe_poll_from_admin().
234 *
235 * @return void
236 */
237 public static function poll_after_response() {
238 if ( function_exists( 'fastcgi_finish_request' ) ) {
239 fastcgi_finish_request();
240 }
241
242 ignore_user_abort( true );
243
244 do_action( self::CRON_HOOK );
245
246 self::run_due_import_events();
247 }
248
249 /**
250 * Import follow-up cron hooks an admin request may run itself.
251 */
252 const FOLLOW_UP_HOOKS = array( 'wdkit_async_sideload_page_images', 'wdkit_regenerate_import_thumbnails', 'wdkit_warm_imported_front' );
253
254 /**
255 * Run this plugin's due import follow-up events, a few per request.
256 *
257 * The deferred image download, thumbnail regeneration and front warm-up are wp-cron
258 * events, and wp-cron never fires on a site behind a host's password gate or with a
259 * blocked loopback - so there the imported pages kept pointing at the kit's demo
260 * images. Bounded per request so no single admin request carries the whole backlog.
261 *
262 * @param int $limit Events per call.
263 * @return int Events run.
264 */
265 public static function run_due_import_events( $limit = 3 ) {
266 if ( ! function_exists( '_get_cron_array' ) ) {
267 return 0;
268 }
269
270 $crons = _get_cron_array();
271
272 if ( empty( $crons ) || ! is_array( $crons ) ) {
273 return 0;
274 }
275
276 $now = time();
277 $ran = 0;
278
279 foreach ( $crons as $timestamp => $hooks ) {
280 if ( $timestamp > $now ) {
281 break;
282 }
283
284 foreach ( self::FOLLOW_UP_HOOKS as $hook ) {
285 if ( empty( $hooks[ $hook ] ) ) {
286 continue;
287 }
288
289 foreach ( $hooks[ $hook ] as $event ) {
290 $args = isset( $event['args'] ) && is_array( $event['args'] ) ? $event['args'] : array();
291
292 /* Unscheduled first, the way wp-cron does, so a concurrent cron run or
293 * the next admin request cannot pick the same event up again. */
294 if ( false === wp_unschedule_event( $timestamp, $hook, $args ) ) {
295 continue;
296 }
297
298 try {
299 do_action_ref_array( $hook, $args );
300 } catch ( Throwable $e ) {
301 // One failed page must not stop the rest; the sweep logs its own failures.
302 }
303
304 if ( ++$ran >= $limit ) {
305 return $ran;
306 }
307 }
308 }
309 }
310
311 return $ran;
312 }
313
314 /**
315 * Bring this site's own import options to the state the current code intends.
316 *
317 * Two things, both about values already on disk rather than about new ones:
318 *
319 * 1. A previous build parked a wake's nonce in an option for a cron run to collect.
320 * That detour is gone; the value is not, on any site that ran it.
321 *
322 * 2. The poll token and the user id are written with autoload off, but that argument
323 * only decides how a row is CREATED — a row that already existed keeps whatever it
324 * had, and on this site both were sitting at `auto`, which means autoloaded. An
325 * autoloaded option is read into memory on every single request and is handed out
326 * by wp_load_alloptions(), so any code on the site gets the poll token without ever
327 * naming it — and the whole alloptions blob goes into the object cache, which on a
328 * shared Redis or Memcached without per-site key prefixes is not a boundary at all.
329 * Neither option is read on a normal page load, so autoloading them buys nothing.
330 *
331 * @return void
332 */
333 public static function tidy_options() {
334 /* This is a migration, and a migration that has run is done. It was re-running its
335 * option reads and autoload flips on EVERY admin_init, for the life of the install,
336 * to fix state that can only be wrong once — an import fires hundreds of admin-ajax
337 * requests, and every one of them paid for it. The flag is cheap to read (autoloaded
338 * with everything else) and retires the whole callback. */
339 if ( self::TIDY_VERSION === (int) get_option( self::TIDY_OPTION, 0 ) ) {
340 return;
341 }
342
343 if ( false !== get_option( self::NONCE_OPTION, false ) ) {
344 delete_option( self::NONCE_OPTION );
345 }
346
347 if ( ! function_exists( 'wp_set_option_autoload' ) ) {
348 /* Deliberately NOT marked done: on a WordPress without wp_set_option_autoload()
349 * the autoload half never ran, and an upgrade should still get it. */
350 return;
351 }
352
353 foreach ( array( self::TOKEN_OPTION, self::USER_OPTION, self::JOB_OPTION ) as $option ) {
354 if ( false !== get_option( $option, false ) ) {
355 wp_set_option_autoload( $option, false );
356 }
357 }
358
359 update_option( self::TIDY_OPTION, self::TIDY_VERSION, false );
360 }
361
362 /**
363 * "Check your queue now."
364 *
365 * The cron tick is a floor on how long someone waits between pressing Build and their
366 * site starting, and on a site with no traffic it is not even a floor — WP-Cron only
367 * runs when a request comes in, so a queued job can sit indefinitely. Measured on a
368 * quiet local site: 83 seconds of waiting in front of 22 seconds of work.
369 *
370 * Requesting wp-cron.php from outside does not fix it either. That only runs events
371 * whose scheduled time has already passed, so it still waits out the rest of the
372 * current interval — 25 seconds in the same measurement. This route skips the schedule
373 * entirely and polls on the spot, which takes the wait to zero.
374 *
375 * ── Why it is unauthenticated ──────────────────────────────────────
376 *
377 * The caller is the website, in the visitor's browser, reaching across origins to the
378 * visitor's own WordPress. A cross-origin request that carries a header needs a CORS
379 * preflight this site would have to answer; one that carries the token in the query
380 * string writes a secret into every access log it passes through. Neither is worth it,
381 * because the route needs no secret: it accepts nothing, returns nothing about the
382 * site, and its whole effect is to make the site ask the queue a question it was going
383 * to ask within thirty seconds anyway. The queue answers on the site's OWN poll token,
384 * which never leaves the site, so a caller cannot direct the work — only its timing.
385 *
386 * That is the same bargain wp-cron.php strikes, and it is throttled for the same
387 * reason: without it, this is a free way to make a site talk to the queue in a loop.
388 *
389 * @since 2.6.5
390 *
391 * @return void
392 */
393 public static function register_wake_route() {
394 register_rest_route(
395 'wdkit/v1',
396 '/import/wake',
397 array(
398 /* POST only. A GET is reachable from an <img> tag, a link prefetch or a
399 * crawler, which would let any third-party page decide when someone's
400 * import runs. */
401 'methods' => 'POST',
402 'callback' => array( __CLASS__, 'wake' ),
403 'permission_callback' => '__return_true',
404 'args' => array(
405 'nonce' => array(
406 /* Not `required`. WordPress would answer a missing one with a 400
407 * naming the parameter, which tells an unauthenticated caller that
408 * this route exists and what it wants. The callback checks it
409 * instead, so every call gets the same answer. */
410 'required' => false,
411 'sanitize_callback' => 'sanitize_text_field',
412 ),
413 ),
414 )
415 );
416 }
417
418 /**
419 * Poll now, unless something already did in the last few seconds.
420 *
421 * @return WP_REST_Response
422 */
423 public static function wake( $request ) {
424 /* One answer for every case.
425 *
426 * An honest response here is an oracle for an unauthenticated caller: a
427 * `not_registered` tells them whether this site is connected to a WDesignKit
428 * account at all, and a `claimed` tells them whether an import is queued for it
429 * right now. Neither is theirs to know, and nothing needs them — the website sends
430 * this request `no-cors` and cannot read the body, and the build's progress is
431 * tracked through the queue instead. */
432 $answer = rest_ensure_response( array( 'ok' => true ) );
433
434 /* No nonce, no poll.
435 *
436 * This is what keeps an unauthenticated route from handing a stranger the timing
437 * of someone else's import. The nonce is minted when the job is queued and handed
438 * only to the account that queued it; it is opaque here, forwarded to the queue,
439 * and the queue will only offer the one job it was minted for. Without it the site
440 * does not so much as ask.
441 *
442 * Deliberately answered the same as every other case: whether a nonce was accepted
443 * is not something a caller gets to learn. */
444 $nonce = is_object( $request ) ? (string) $request->get_param( 'nonce' ) : '';
445 $nonce = trim( $nonce );
446
447 if ( '' === $nonce || strlen( $nonce ) > 64 || ! preg_match( '/^[a-f0-9]+$/i', $nonce ) ) {
448 return $answer;
449 }
450
451 if ( '' === self::poll_token() ) {
452 return $answer;
453 }
454
455 if ( false !== get_transient( self::WAKE_THROTTLE ) ) {
456 return $answer;
457 }
458
459 set_transient( self::WAKE_THROTTLE, 1, self::WAKE_INTERVAL );
460
461 /* Polled here, in this request.
462 *
463 * An earlier version handed this to cron instead, to keep an unauthenticated
464 * request from holding a PHP worker for the length of an import. The nonce above
465 * removes the reason for that: the only caller who can get this far is the one the
466 * queue minted a nonce for, which is the account that queued the job moments ago.
467 *
468 * And the cron detour did not work. spawn_cron() runs the due events by making a
469 * loopback HTTP request to wp-cron.php, and a site whose loopback is blocked — a
470 * .local install, or anything behind basic auth or a firewall — never receives it.
471 * Measured on this one: the job was still pending after two and a half minutes,
472 * which is worse than the plain cron tick it was meant to beat. Those are exactly
473 * the sites that need the wake most, because they are the ones with no traffic to
474 * run cron in the first place.
475 *
476 * The cloud hangs up after ~1s on purpose (fire-and-forget), long before this
477 * finishes, so the run must not be cut off along with the connection. */
478 ignore_user_abort( true );
479
480 do_action( self::CRON_HOOK, $nonce );
481
482 return $answer;
483 }
484
485 /**
486 * The one-minute interval the poll runs on.
487 *
488 * @param array $schedules Existing schedules.
489 * @return array
490 */
491 public static function add_schedule( $schedules ) {
492 if ( ! isset( $schedules[ self::SCHEDULE ] ) ) {
493 $schedules[ self::SCHEDULE ] = array(
494 /* Half a minute, not a whole one.
495 *
496 * This interval is the floor on how long someone waits between pressing
497 * Build and their site starting — measured at 83 seconds on a one-minute
498 * schedule, which was more than a third of the total wait for an import
499 * whose actual work took a fraction of that. A tick costs one small POST
500 * that answers `job: null`, so the shorter interval buys real
501 * responsiveness for almost nothing. */
502 'interval' => 30,
503 'display' => __( 'Every 30 seconds (WDesignKit imports)', 'wdesignkit' ),
504 );
505 }
506
507 return $schedules;
508 }
509
510 /**
511 * Put the recurring poll on the schedule, once.
512 *
513 * Only for a site that has actually registered. An unregistered site has nothing to
514 * ask about, and scheduling a tick that can only fail is noise in the cron list.
515 *
516 * @return void
517 */
518 public static function schedule() {
519 if ( '' === self::poll_token() ) {
520 return;
521 }
522
523 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
524 wp_schedule_event( time() + 30, self::SCHEDULE, self::CRON_HOOK );
525 }
526 }
527
528 /**
529 * Drop this site's import registration - on a WDesignKit logout.
530 *
531 * The poll token is what lets the site act for the account with no session at all
532 * (claiming queued builds, AI content and the credits it spends). Kept after a logout,
533 * a logged-out site went on doing exactly that. The next login registers again. A
534 * sandbox's own token (SANDBOX_TOKEN_OPTION) is untouched - a sandbox never logs in.
535 *
536 * @return void
537 */
538 public static function forget_registration() {
539 delete_option( self::TOKEN_OPTION );
540 delete_option( self::USER_OPTION );
541 self::unschedule();
542 }
543
544 /**
545 * Take the poll off the schedule. For deactivation.
546 *
547 * @return void
548 */
549 public static function unschedule() {
550 $next = wp_next_scheduled( self::CRON_HOOK );
551
552 while ( $next ) {
553 wp_unschedule_event( $next, self::CRON_HOOK );
554
555 $next = wp_next_scheduled( self::CRON_HOOK );
556 }
557 }
558
559 /**
560 * How long to wait before retrying a registration that failed, in seconds.
561 *
562 * maybe_register() runs on every admin_init, and admin-ajax counts - an import fires
563 * hundreds of those. Without this, a site that is signed in but not yet a connected
564 * "manage site" on the account retried the cloud `import/job/register` call, a blocking
565 * HTTP request, on every one of them, and wrote a log line each time. Observed: 30+
566 * failed calls during a single kit import, each one time the admin request waits on.
567 *
568 * @var int
569 */
570 const REGISTER_BACKOFF = 900;
571
572 /**
573 * Transient that holds off the next registration attempt after a failure.
574 *
575 * @var string
576 */
577 const REGISTER_BACKOFF_KEY = 'wdkit_import_register_backoff';
578
579 /**
580 * Register this site for imports if it has not been registered yet.
581 *
582 * @return void
583 */
584 public static function maybe_register() {
585 if ( '' !== self::poll_token() ) {
586 return;
587 }
588
589 if ( ! current_user_can( 'manage_options' ) ) {
590 return;
591 }
592
593 /* A recent attempt failed - do not hammer the cloud (and the log) on every request
594 * until the backoff clears. clear_register_backoff() is the escape hatch for the
595 * moment a site is actually connected on the account. */
596 if ( false !== get_transient( self::REGISTER_BACKOFF_KEY ) ) {
597 return;
598 }
599
600 $result = self::register();
601
602 if ( empty( $result['success'] ) ) {
603 set_transient( self::REGISTER_BACKOFF_KEY, time(), self::REGISTER_BACKOFF );
604 }
605 }
606
607 /**
608 * Register again straight after a WDesignKit login.
609 *
610 * maybe_register() only runs when the site holds no token and no backoff is pending, so
611 * a login did nothing for a site that kept a stale token (registered under an earlier
612 * account or session) or had failed once before signing in - the build it queued sat
613 * "pending" while the site looked logged in (ClickUp 14ynqxywrg4). A fresh session is
614 * exactly when a registration can succeed, so do it now, replacing any old token.
615 *
616 * @return void
617 */
618 public static function register_after_login() {
619 if ( ! current_user_can( 'manage_options' ) ) {
620 return;
621 }
622
623 self::clear_register_backoff();
624
625 $result = self::register();
626
627 if ( empty( $result['success'] ) ) {
628 set_transient( self::REGISTER_BACKOFF_KEY, time(), self::REGISTER_BACKOFF );
629 }
630 }
631
632 /**
633 * Drop the registration backoff so the next admin_init retries immediately.
634 *
635 * For a caller that knows the situation changed - a fresh cloud login, or the site
636 * being connected on the account - and does not want to wait out REGISTER_BACKOFF.
637 *
638 * @return void
639 */
640 public static function clear_register_backoff() {
641 delete_transient( self::REGISTER_BACKOFF_KEY );
642 }
643
644 /**
645 * Ask the cloud for the token this site will poll with.
646 *
647 * Needs a signed-in cloud session, because that session is the only thing that
648 * establishes this site belongs to the account it is asking to be registered under.
649 * Once done, polling needs no session at all — which is the point, since cron has none.
650 *
651 * @return array{success:bool,message:string}
652 */
653 public static function register() {
654 $token = self::cloud_token();
655
656 if ( '' === $token ) {
657 return self::fail( 'Not signed into WDesignKit on this site.' );
658 }
659
660 $response = WDesignKit_Data_Query::get_data(
661 'import/job/register',
662 array(
663 'token' => $token,
664 'site_url' => self::site_url(),
665 )
666 );
667
668 if ( is_wp_error( $response ) || empty( $response['success'] ) || empty( $response['poll_token'] ) ) {
669 $message = ( ! is_wp_error( $response ) && ! empty( $response['message'] ) )
670 ? (string) $response['message']
671 : 'Could not register this site for imports.';
672
673 Wdkit_Import_Log::add( 'remote', array( 'what' => 'register', 'ok' => false, 'message' => $message ) );
674
675 return self::fail( $message );
676 }
677
678 update_option( self::TOKEN_OPTION, (string) $response['poll_token'], false );
679
680 /* Whose authority a queued import will carry. maybe_register() has already checked
681 * this user can manage options; the executor re-checks at run time in case the
682 * account changed in between. */
683 update_option( self::USER_OPTION, (int) get_current_user_id(), false );
684
685 Wdkit_Import_Log::add(
686 'remote',
687 array(
688 'what' => 'register',
689 'ok' => true,
690 'site_id' => isset( $response['site_id'] ) ? (int) $response['site_id'] : 0,
691 )
692 );
693
694 /* Nothing was polling before now, because schedule() declines to run for an
695 * unregistered site. */
696 self::schedule();
697
698 return array(
699 'success' => true,
700 'message' => 'Registered.',
701 );
702 }
703
704 /**
705 * Ask whether anything is queued for this site, and take it if so.
706 *
707 * Returns the job it took, or null. Null is the normal answer and is meant to be
708 * cheap — this runs every minute on every site that has registered.
709 *
710 * @return array|null
711 */
712 public static function poll( $wake_nonce = '' ) {
713 $poll_token = self::poll_token();
714
715 if ( '' === $poll_token ) {
716 return null;
717 }
718
719 /* A previous run's completed/failed report that never reached the queue — the
720 * import itself is long done, only telling the queue about it failed. Retried before
721 * anything else so a site is not stuck refusing every future job (active_job() below
722 * would otherwise stay non-null forever) while the website's screen waits on a report
723 * that was never going to come on its own. */
724 self::retry_pending_report();
725
726 /* Already holding one. Taking a second would mean two imports writing over each
727 * other on the same site, which no amount of server-side care can prevent from
728 * here. A job held with no progress for longer than the timeout is let go first —
729 * see release_stale(). */
730 self::release_stale();
731
732 if ( null !== self::active_job() ) {
733 return null;
734 }
735
736 $claim = array(
737 'site_url' => self::site_url(),
738 'poll_token' => $poll_token,
739 );
740
741 /* Narrows the claim to the one job this nonce was minted for. Empty on the
742 * ordinary cron tick, which claims whatever is waiting. */
743 $wake_nonce = is_string( $wake_nonce ) ? trim( $wake_nonce ) : '';
744
745 if ( '' !== $wake_nonce ) {
746 $claim['wake_nonce'] = $wake_nonce;
747 }
748
749 $response = WDesignKit_Data_Query::get_data( 'import/job/claim', $claim );
750
751 if ( is_wp_error( $response ) || empty( $response['success'] ) ) {
752 $message = ( ! is_wp_error( $response ) && ! empty( $response['message'] ) )
753 ? (string) $response['message']
754 : 'Could not reach the import queue.';
755
756 /* A rejected token means the site was un-registered or its token replaced.
757 * Dropping it stops a pointless minute-by-minute retry and lets the next admin
758 * page load register again. */
759 if ( ! is_wp_error( $response ) && false !== strpos( $message, 'not registered' ) ) {
760 delete_option( self::TOKEN_OPTION );
761 self::unschedule();
762
763 /* Re-register on the very next admin request rather than after an old
764 * failure's backoff: until then a queued build waits on a site that
765 * cannot claim it. */
766 self::clear_register_backoff();
767 }
768
769 Wdkit_Import_Log::add( 'remote', array( 'what' => 'claim', 'ok' => false, 'message' => $message ) );
770
771 return null;
772 }
773
774 if ( empty( $response['job'] ) || ! is_array( $response['job'] ) ) {
775 return null; // nothing queued, the usual case
776 }
777
778 $job = self::store( $response['job'] );
779
780 if ( null === $job ) {
781 return null;
782 }
783
784 Wdkit_Import_Log::add(
785 'remote',
786 array(
787 'what' => 'claim',
788 'ok' => true,
789 'job_id' => $job['job_id'],
790 'kit_id' => $job['kit_id'],
791 'attempt' => $job['attempt'],
792 )
793 );
794
795 /**
796 * Fires when this site has taken a queued import and holds the lease on it.
797 *
798 * The execution layer listens here and runs the job through the importer's own
799 * entry point. Nothing in this file imports anything.
800 *
801 * @since 2.6.6
802 *
803 * @param array $job The claimed job: job_id, claim_token, kit_id, builder,
804 * session_id, payload.
805 */
806 do_action( 'wdkit_import_job_claimed', $job );
807
808 return $job;
809 }
810
811 /**
812 * Tell the queue how a held job is going, or how it ended.
813 *
814 * @param array $args status (running|completed|failed), plus any of stage, progress,
815 * session_id, result, error.
816 * @return bool True when the queue accepted the report.
817 */
818 public static function report( $args ) {
819 $job = self::active_job();
820
821 if ( null === $job ) {
822 return false;
823 }
824
825 $status = isset( $args['status'] ) ? (string) $args['status'] : '';
826
827 if ( ! in_array( $status, array( 'running', 'completed', 'failed' ), true ) ) {
828 return false;
829 }
830
831 $body = array(
832 'site_url' => self::site_url(),
833 'poll_token' => self::poll_token(),
834 'job_id' => $job['job_id'],
835 'claim_token' => $job['claim_token'],
836 'status' => $status,
837 );
838
839 foreach ( array( 'stage', 'progress', 'session_id', 'result', 'error', 'detail', 'yielded' ) as $key ) {
840 if ( isset( $args[ $key ] ) ) {
841 $body[ $key ] = $args[ $key ];
842 }
843 }
844
845 /* The importer's session id is what lets a re-offered job resume rather than
846 * import the kit again, so it is kept locally too — a report that failed to
847 * reach the queue must not lose it. Stored before sending: a `yielded`
848 * report makes the queue wake this site at once, and the woken request resumes from it. */
849 if ( ! empty( $args['session_id'] ) ) {
850 $job['session_id'] = (string) $args['session_id'];
851 update_option( self::JOB_OPTION, $job, false );
852 }
853
854 $response = WDesignKit_Data_Query::get_data( 'import/job/report', $body );
855
856 $ok = ( ! is_wp_error( $response ) && ! empty( $response['success'] ) );
857
858 if ( ! $ok ) {
859 Wdkit_Import_Log::add(
860 'remote',
861 array(
862 'what' => 'report',
863 'ok' => false,
864 'job_id' => $job['job_id'],
865 'status' => $status,
866 'message' => is_wp_error( $response ) ? $response->get_error_message() : ( isset( $response['message'] ) ? $response['message'] : '' ),
867 )
868 );
869 }
870
871 if ( 'running' !== $status ) {
872 if ( $ok ) {
873 /* Delivered. The lease is spent on the server, so holding the local copy
874 * would only block the next import of this site. */
875 self::clear_job();
876 } else {
877 $attempts = 1 + ( ! empty( $job['pending_report_attempts'] ) ? (int) $job['pending_report_attempts'] : 0 );
878
879 /* Capped rather than retried forever: a site whose outbound calls are
880 * blocked entirely would otherwise never deliver this report, and the
881 * refreshed heartbeat below would keep release_stale() from ever reaping it
882 * either — silently refusing every future import on the site permanently.
883 * Giving up after PENDING_REPORT_ATTEMPTS ticks (spread over the poll
884 * interval, so on the order of the same HELD_TIMEOUT a stuck running import
885 * already gets) trades one lost notification for the site staying usable. */
886 if ( $attempts >= self::PENDING_REPORT_ATTEMPTS ) {
887 Wdkit_Import_Log::add(
888 'remote',
889 array(
890 'what' => 'report',
891 'ok' => false,
892 'job_id' => $job['job_id'],
893 'status' => $status,
894 'give_up' => true,
895 'attempts' => $attempts,
896 )
897 );
898
899 self::clear_job();
900 } else {
901 /* The import itself is already done (or failed) — only telling the queue
902 * about it failed, most likely the same host timeout that nearly cut off
903 * the import run itself, landing on this last outbound call instead.
904 * Clearing the job here, as this used to do unconditionally, would
905 * strand the site believing it finished while the queue's screen waits
906 * forever with nothing left locally that could ever tell it otherwise.
907 * Kept instead, with the report queued for poll() to resend on the next
908 * tick, heartbeat refreshed so release_stale() gives the retries the same
909 * room a running import gets rather than reaping this on the very next
910 * poll. */
911 $job['pending_report'] = $args;
912 $job['pending_report_attempts'] = $attempts;
913 $job['heartbeat'] = time();
914 update_option( self::JOB_OPTION, $job, false );
915 }
916 }
917 } else {
918 $job['heartbeat'] = time();
919 update_option( self::JOB_OPTION, $job, false );
920 }
921
922 return $ok;
923 }
924
925 /**
926 * The job this site is holding, or null.
927 *
928 * @return array|null
929 */
930 public static function active_job() {
931 $job = get_option( self::JOB_OPTION, array() );
932
933 if ( ! is_array( $job ) || empty( $job['job_id'] ) || empty( $job['claim_token'] ) ) {
934 return null;
935 }
936
937 return $job;
938 }
939
940 /**
941 * Forget the held job.
942 *
943 * @return void
944 */
945 public static function clear_job() {
946 delete_option( self::JOB_OPTION );
947 }
948
949 /**
950 * Resend a terminal report that was generated but never reached the queue.
951 *
952 * report() queues its own args as `pending_report` on the held job when the outbound
953 * call fails for a 'completed'/'failed' status, instead of clearing the job as it used
954 * to unconditionally — see report(). This resends the exact same args every poll() tick
955 * until one gets through, without re-running the import: the work described by those
956 * args already happened, only the queue was never told.
957 *
958 * @return void
959 */
960 private static function retry_pending_report() {
961 $job = self::active_job();
962
963 if ( null === $job || empty( $job['pending_report'] ) || ! is_array( $job['pending_report'] ) ) {
964 return;
965 }
966
967 self::report( $job['pending_report'] );
968 }
969
970 /**
971 * Let go of a job that has made no progress for too long.
972 *
973 * A held job with a dead heartbeat means the run died — a fatal, a timeout, a
974 * deployment mid-import. Releasing it locally is what allows the queue's own re-offer
975 * to be accepted; without this the site would refuse every future job because it still
976 * believed it was busy.
977 *
978 * @return void
979 */
980 private static function release_stale() {
981 $job = self::active_job();
982
983 if ( null === $job ) {
984 return;
985 }
986
987 $last = ! empty( $job['heartbeat'] ) ? (int) $job['heartbeat'] : (int) ( isset( $job['claimed_at'] ) ? $job['claimed_at'] : 0 );
988
989 $timeout = empty( $job['session_id'] ) ? 60 : self::HELD_TIMEOUT;
990
991 if ( $last > 0 && ( time() - $last ) < $timeout ) {
992 return;
993 }
994
995 Wdkit_Import_Log::add(
996 'remote',
997 array(
998 'what' => 'release_stale',
999 'job_id' => $job['job_id'],
1000 'held_s' => $last > 0 ? ( time() - $last ) : null,
1001 )
1002 );
1003
1004 self::clear_job();
1005 }
1006
1007 /**
1008 * Record a claimed job locally, keeping only the fields this side uses.
1009 *
1010 * Rebuilt rather than stored as received, so a change in the queue's response shape
1011 * cannot put unvalidated data into an option that the importer later reads.
1012 *
1013 * @param array $job Job as the queue returned it.
1014 * @return array|null
1015 */
1016 private static function store( $job ) {
1017 $job_id = isset( $job['job_id'] ) ? (int) $job['job_id'] : 0;
1018 $claim_token = isset( $job['claim_token'] ) ? sanitize_text_field( (string) $job['claim_token'] ) : '';
1019
1020 if ( $job_id <= 0 || '' === $claim_token || empty( $job['payload'] ) || ! is_array( $job['payload'] ) ) {
1021 Wdkit_Import_Log::add( 'remote', array( 'what' => 'claim', 'ok' => false, 'message' => 'Queue returned a job this site cannot use.' ) );
1022
1023 return null;
1024 }
1025
1026 $stored = array(
1027 'job_id' => $job_id,
1028 'claim_token' => $claim_token,
1029 'kit_id' => isset( $job['kit_id'] ) ? sanitize_text_field( (string) $job['kit_id'] ) : '',
1030 'builder' => in_array( isset( $job['builder'] ) ? $job['builder'] : '', array( 'elementor', 'gutenberg' ), true ) ? $job['builder'] : '',
1031 'attempt' => isset( $job['attempt'] ) ? (int) $job['attempt'] : 1,
1032
1033 /* Present on a re-offered job: the importer resumes this session instead of
1034 * importing the kit a second time. */
1035 'session_id' => ! empty( $job['session_id'] ) ? Wdkit_Import_Session::sanitize_id( (string) $job['session_id'] ) : '',
1036
1037 'payload' => $job['payload'],
1038 'claimed_at' => time(),
1039 'heartbeat' => time(),
1040 );
1041
1042 update_option( self::JOB_OPTION, $stored, false );
1043
1044 return $stored;
1045 }
1046
1047 /**
1048 * The user a queued import should run as.
1049 *
1050 * The administrator who registered this site for imports, when they are still an
1051 * administrator. Otherwise the site's longest-standing administrator, because an import
1052 * that cannot run at all is worse than one running as a different admin — and either
1053 * way the work is identical, since nothing about the import is per-user.
1054 *
1055 * 0 when the site has no administrator, which is not a situation to guess around.
1056 *
1057 * @return int
1058 */
1059 public static function run_as_user() {
1060 $stored = (int) get_option( self::USER_OPTION, 0 );
1061
1062 if ( $stored > 0 && user_can( $stored, 'manage_options' ) ) {
1063 return $stored;
1064 }
1065
1066 $admins = get_users(
1067 array(
1068 'role' => 'administrator',
1069 'orderby' => 'ID',
1070 'order' => 'ASC',
1071 'number' => 1,
1072 'fields' => 'ID',
1073 )
1074 );
1075
1076 return ! empty( $admins ) ? (int) $admins[0] : 0;
1077 }
1078
1079 /**
1080 * This site's stored poll credential.
1081 *
1082 * @return string
1083 */
1084 public static function poll_token() {
1085 $token = get_option( self::TOKEN_OPTION, '' );
1086 $token = is_string( $token ) ? trim( $token ) : '';
1087
1088 if ( '' !== $token ) {
1089 return $token;
1090 }
1091
1092 /* Nothing of our own: this may be a sandbox, whose token arrived under the
1093 * provisioner's name. Read rather than copied across, so the one place that writes
1094 * each key stays the only writer. */
1095 $sandbox = get_option( self::SANDBOX_TOKEN_OPTION, '' );
1096
1097 return is_string( $sandbox ) ? trim( $sandbox ) : '';
1098 }
1099
1100 /**
1101 * How this site identifies itself to the queue.
1102 *
1103 * admin_url(), untrailingslashed, because that is the shape kit_manage_sites already
1104 * holds — its rows are written from the plugin's own Get_site_url(), which returns the
1105 * address up to and including `/wp-admin`. The queue normalises the suffix away in any
1106 * case, so home_url() would match too; matching the stored form exactly just means one
1107 * less thing depending on that normalisation.
1108 *
1109 * @return string
1110 */
1111 private static function site_url() {
1112 return untrailingslashit( admin_url() );
1113 }
1114
1115 /**
1116 * How this site identifies itself when it has no signed-in cloud session.
1117 *
1118 * The poll token already names a site and, through kit_manage_sites, the account that
1119 * owns it, so the cloud can authenticate a kit-content request on this pair alone. That
1120 * is what lets a sandbox — where nobody ever logs in — fetch kit content.
1121 *
1122 * Returns an empty array when this site holds no poll token, so callers can merge the
1123 * result in unconditionally without sending a stray site_url on its own.
1124 *
1125 * @return array{poll_token?:string,site_url?:string}
1126 */
1127 public static function cloud_identity() {
1128 $token = self::poll_token();
1129
1130 if ( '' === $token ) {
1131 return array();
1132 }
1133
1134 return array(
1135 'poll_token' => $token,
1136 'site_url' => self::site_url(),
1137 );
1138 }
1139
1140 /**
1141 * The signed-in cloud session's token, or '' when nobody is signed in.
1142 *
1143 * @return string
1144 */
1145 private static function cloud_token() {
1146 if ( ! function_exists( 'wdesignkit_mcp_find_auth_session' ) ) {
1147 return '';
1148 }
1149
1150 $session = wdesignkit_mcp_find_auth_session();
1151
1152 return ( ! empty( $session['found'] ) && ! empty( $session['data']['token'] ) )
1153 ? (string) $session['data']['token']
1154 : '';
1155 }
1156
1157 /**
1158 * @param string $message Reason.
1159 * @return array{success:bool,message:string}
1160 */
1161 private static function fail( $message ) {
1162 return array(
1163 'success' => false,
1164 'message' => $message,
1165 );
1166 }
1167 }
1168 }
1169