Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
4.2 KiB
PHP
126 lines
4.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Release;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Admin\SettingsPage;
|
||
use LIT\MCP\Integrations\Divi\Admin\DiviTab;
|
||
use LIT\MCP\Log;
|
||
use Throwable;
|
||
use WP_REST_Response;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* "Prüfung starten" of the Divi tab (spec §6.1): POST /wp-json/lit-mcp/v1/divi-check. A REST request on purpose –
|
||
* not admin-post – so the check runs in the same context as MCP calls (Divi renders its blocks in REST requests,
|
||
* lit-page-render-summary). The form sends the wp_rest nonce as _wpnonce (cookie authentication); only manage_options.
|
||
* One check at a time: an atomic "INSERT IGNORE" on the options table (self::LOCK) is the lock – add_option() reads
|
||
* then writes (wp-includes/option.php) and is not atomic, so two simultaneous requests could both "acquire" it. A
|
||
* lock older than LOCK_TTL is a dead check's, taken over with a conditional UPDATE (compare-and-swap on the value
|
||
* just read) so only one of several concurrent takers wins. A check that throws still releases the lock (finally)
|
||
* and redirects with divi_check_failed (logged, never shown to the browser as an unhandled 500). Answers 303 back
|
||
* to the tab.
|
||
*/
|
||
final class DiviCheckRoute
|
||
{
|
||
public const NAMESPACE = 'lit-mcp/v1';
|
||
public const ROUTE = '/divi-check';
|
||
public const LOCK = 'lit_mcp_divi_check_lock';
|
||
|
||
private const LOCK_TTL = 600;
|
||
|
||
/** @param Closure(): CompatibilityCheck $check */
|
||
public function __construct(private Closure $check, private ?CheckReportStore $reports = null)
|
||
{
|
||
}
|
||
|
||
public function register(): void
|
||
{
|
||
add_action('rest_api_init', [$this, 'registerRoute']);
|
||
}
|
||
|
||
public function registerRoute(): void
|
||
{
|
||
register_rest_route(self::NAMESPACE, self::ROUTE, [
|
||
'methods' => 'POST',
|
||
'callback' => [$this, 'handle'],
|
||
'permission_callback' => static fn (): bool => current_user_can('manage_options'),
|
||
]);
|
||
}
|
||
|
||
public static function url(): string
|
||
{
|
||
return rest_url(self::NAMESPACE . self::ROUTE);
|
||
}
|
||
|
||
public function handle(): WP_REST_Response
|
||
{
|
||
$notice = 'divi_check_running';
|
||
if (self::lock()) {
|
||
try {
|
||
$report = ($this->check)()->run(get_current_user_id());
|
||
($this->reports ?? new CheckReportStore())->save($report);
|
||
ReleaseGate::invalidate();
|
||
$notice = 'divi_checked';
|
||
} catch (Throwable $e) {
|
||
Log::failure('divi check', $e);
|
||
$notice = 'divi_check_failed';
|
||
} finally {
|
||
delete_option(self::LOCK);
|
||
}
|
||
}
|
||
|
||
return new WP_REST_Response(
|
||
null,
|
||
303,
|
||
['Location' => SettingsPage::url(DiviTab::SLUG, ['lit_mcp_notice' => $notice])]
|
||
);
|
||
}
|
||
|
||
private static function lock(): bool
|
||
{
|
||
global $wpdb;
|
||
|
||
if (self::insertLock($wpdb, time())) {
|
||
return true;
|
||
}
|
||
|
||
// get_var(), not get_option(): the row was written with raw SQL, bypassing the options cache.
|
||
$since = $wpdb->get_var($wpdb->prepare(
|
||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
|
||
self::LOCK
|
||
));
|
||
if ($since === null) {
|
||
// The lock was released between our failed insert and this read; try once more.
|
||
return self::insertLock($wpdb, time());
|
||
}
|
||
if (time() - (int) $since < self::LOCK_TTL) {
|
||
return false;
|
||
}
|
||
|
||
$taken = $wpdb->query($wpdb->prepare(
|
||
"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
|
||
(string) time(),
|
||
self::LOCK,
|
||
$since
|
||
));
|
||
|
||
return (int) $taken === 1;
|
||
}
|
||
|
||
/** INSERT IGNORE: 1 row affected = the lock did not exist and is now ours; 0 = someone else holds it. */
|
||
private static function insertLock(\wpdb $wpdb, int $now): bool
|
||
{
|
||
$inserted = $wpdb->query($wpdb->prepare(
|
||
"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
|
||
self::LOCK,
|
||
(string) $now
|
||
));
|
||
|
||
return (int) $inserted === 1;
|
||
}
|
||
}
|