Files
WP-LIT-MCP/src/Integrations/Divi/Release/DiviCheckRoute.php
T

126 lines
4.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}