| 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 |
|