<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\PluginController;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\ThemeController;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\VulnerabilityDataController;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\VulnerabilityNoticeController;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\VulnerabilityNotificationController;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\VulnerabilitySyncService;
use ReallySimplePlugins\RSS\Core\Interfaces\FeatureInterface;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
use ReallySimplePlugins\RSS\Core\Traits\HasScheduler;
use ReallySimplePlugins\RSS\Core\Traits\HasViews;
/**
* Runs vulnerability sync hooks.
*
* Daily cron does full sync.
* Update and activation hooks queue small syncs.
*/
final class VulnerabilityController implements FeatureInterface
{
use HasViews;
use HasScheduler;
/**
* Stores queued sync work until cron runs.
*/
private const PENDING_SYNC_OPTION = 'rsssl_vulnerability_pending_sync';
/**
* Cron hook that runs the queued sync.
*/
private const SYNC_EVENT = 'rsssl_vulnerability_run_scheduled_sync';
/**
* Fired after sync is done.
*/
public const SYNC_COMPLETED_ACTION = 'rsssl_vulnerability_sync_completed';
/**
* Wait time before queued sync runs.
*/
private const SYNC_DEBOUNCE = (5 * MINUTE_IN_SECONDS);
private VulnerabilitySyncService $vulnerabilitySyncService;
private EnvironmentConfig $env;
public function __construct(
VulnerabilitySyncService $vulnerabilitySyncService,
EnvironmentConfig $environmentConfig
) {
$this->vulnerabilitySyncService = $vulnerabilitySyncService;
$this->env = $environmentConfig;
}
/**
* Wire all hooks for this feature.
*/
public function register(): void
{
add_action('admin_enqueue_scripts', [$this, 'enqueueStyles']);
add_action(self::SYNC_EVENT, [$this, 'runSync'], 10, 0);
add_filter('rss_core_controller_classes', [$this, 'registerControllers']);
add_action('rss_core_activation', [$this, 'scheduleFullSync'], 10, 1);
add_action('upgrader_process_complete', [$this, 'scheduleUpgradeSync'], 10, 2);
add_action('activate_plugin', [$this, 'schedulePluginActivationSync'], 10, 2);
add_action('after_switch_theme', [$this, 'scheduleThemeSwitchSync'], 10, 0);
add_action('_core_updated_successfully', [$this, 'scheduleCoreUpdateSync'], 10, 1);
add_action('rsssl_daily_cron', [$this, 'scheduleFullSync']);
}
/**
* Registers the controllers related to vulnerability management. This
* method is hooked into the 'rss_core_controller_classes' filter, that
* is applied here {@see Plugin::registerControllers}, to make sure the
* {@see ControllerManager} can register them in the plugin lifecycle.
*/
public function registerControllers(array $existingControllers): array
{
$enablePluginAndThemeDisplay = rsssl_get_option('enable_feedback_in_plugin', false);
$availableControllers = [
VulnerabilityDataController::class,
VulnerabilityNoticeController::class,
VulnerabilityNotificationController::class,
];
if ($enablePluginAndThemeDisplay) {
$availableControllers[] = PluginController::class;
$availableControllers[] = ThemeController::class;
}
return array_merge($existingControllers, $availableControllers);
}
/**
* Enqueues shared vulnerability styling on plugins and themes overview pages.
* we register this here because both the PluginController and ThemeController
* needs the same styles.
*
* @param null|string $hook Current admin page hook suffix. Can be null in
* non-default admin pages. For example when using a page builder plugin.
*/
public function enqueueStyles(?string $hook = null): void
{
if (empty($hook) || !$this->isComponentOverviewScreen($hook)) {
return;
}
$rtl = is_rtl() ? 'rtl/' : '';
$assetsUrl = trailingslashit($this->env->getString('plugin.assets_url'));
$assetsPath = trailingslashit($this->env->getString('plugin.assets_path'));
$url = $assetsUrl . "css/{$rtl}rsssl-plugin.min.css";
$path = $assetsPath . "css/{$rtl}rsssl-plugin.min.css";
$version = $this->env->get('plugin.version');
if (file_exists($path)) {
wp_enqueue_style('rsssl-plugin', $url, [], $version);
}
}
/**
* Determine whether the current admin page is a plugin or theme overview screen.
*
* Supports both regular admin and multisite network-admin pages.
*/
private function isComponentOverviewScreen(string $hook): bool
{
return in_array($hook, ['plugins.php', 'plugins-network.php', 'themes.php', 'themes-network.php'], true);
}
/**
* Queue full sync. Full beats scoped.
*
* Daily cron and product activation use this.
*/
public function scheduleFullSync(...$args): void
{
$pendingSync = $this->getPendingSync();
$pendingSync['full'] = true;
$pendingSync['components'] = [];
$this->storePendingSync($pendingSync);
$this->scheduleDebounced(self::SYNC_EVENT, self::SYNC_DEBOUNCE, []);
}
/**
* Queue one plugin sync from activation.
*
* Network-wide activation uses the same scoped sync.
* Vulnerability data is per component, not per site.
*/
public function schedulePluginActivationSync(string $pluginFile = '', bool $networkWide = false): void
{
$slug = $this->getPluginSlugFromPath($pluginFile);
if ($slug === '') {
return;
}
$this->queueScopedSync('plugin', $slug);
}
/**
* Queue scoped syncs from upgrader events.
*
* If the upgrader payload is unclear, skip the event.
*/
public function scheduleUpgradeSync($upgrader = null, array $hookExtra = []): void
{
$action = isset($hookExtra['action']) && is_string($hookExtra['action']) ? $hookExtra['action'] : '';
if ($action !== 'update') {
return;
}
$type = isset($hookExtra['type']) && is_string($hookExtra['type']) ? $hookExtra['type'] : '';
if ($type === 'plugin') {
$this->schedulePluginUpgradeSync($hookExtra['plugins'] ?? []);
return;
}
if ($type === 'theme') {
$this->scheduleThemeUpgradeSync($hookExtra['themes'] ?? []);
return;
}
if ($type === 'core') {
$this->scheduleCoreUpgradeSync();
return;
}
}
/**
* Queue scoped plugin syncs from an upgrader payload.
*
* @param mixed $plugins Upgrader payload plugin files.
*/
private function schedulePluginUpgradeSync($plugins): void
{
foreach ($this->normalizeComponentSlugsFromUpgradePayload($plugins, 'plugin') as $slug) {
$this->queueScopedSync('plugin', $slug);
}
}
/**
* Queue scoped theme syncs from an upgrader payload.
*
* @param mixed $themes Upgrader payload theme slugs.
*/
private function scheduleThemeUpgradeSync($themes): void
{
foreach ($this->normalizeComponentSlugsFromUpgradePayload($themes, 'theme') as $slug) {
$this->queueScopedSync('theme', $slug);
}
}
/**
* Queue scoped core sync from an upgrader payload.
*/
private function scheduleCoreUpgradeSync(): void
{
$this->queueScopedSync('core', 'wordpress');
}
/**
* Queue one theme sync after theme switch.
*/
public function scheduleThemeSwitchSync(): void
{
$theme = wp_get_theme();
$slug = sanitize_key((string) $theme->get_stylesheet());
if ($slug === '') {
return;
}
$this->queueScopedSync('theme', $slug);
}
/**
* Queue core sync after core update.
*/
public function scheduleCoreUpdateSync(...$args): void
{
$this->queueScopedSync('core', 'wordpress');
}
/**
* Run queued sync.
*
* Full sync only runs when full=true.
* Components sync only runs when components exist.
* Empty queue stops here, no full sync fallback.
*/
public function runSync(): void
{
$pendingSync = $this->getPendingSync();
try {
if ($pendingSync['full'] ?? false) {
$this->vulnerabilitySyncService->syncAllComponents();
} elseif (!empty($pendingSync['components'])) {
$this->vulnerabilitySyncService->syncSelectedComponents(array_values($pendingSync['components']));
} else {
delete_option(self::PENDING_SYNC_OPTION);
return;
}
delete_option(self::PENDING_SYNC_OPTION);
} finally {
// Release the debounce lock so future triggers can schedule again.
$this->releaseDebounceLock(self::SYNC_EVENT);
}
// Notify that the sync has completed.
do_action(self::SYNC_COMPLETED_ACTION);
}
/**
* Add one component to the queued scoped sync.
*
* Full sync stays stronger than any queued scoped work.
*/
private function queueScopedSync(string $type, string $slug): void
{
$type = strtolower(trim($type));
$slug = strtolower(trim($slug));
if ($type === '' || $slug === '') {
return;
}
$pendingSync = $this->getPendingSync();
if (!($pendingSync['full'] ?? false)) {
if (!isset($pendingSync['components']) || !is_array($pendingSync['components'])) {
$pendingSync['components'] = [];
}
$componentKey = $type . ':' . $slug;
$pendingSync['components'][$componentKey] = [
'type' => $type,
'slug' => $slug,
];
}
$this->storePendingSync($pendingSync);
$this->scheduleDebounced(self::SYNC_EVENT, self::SYNC_DEBOUNCE, []);
}
/**
* Read queued sync state from the option.
*
* Bad rows are ignored so one broken value does not break the queue.
*/
private function getPendingSync(): array
{
$pendingSync = get_option(self::PENDING_SYNC_OPTION, []);
if (!is_array($pendingSync)) {
return [
'full' => false,
'components' => [],
];
}
if (!isset($pendingSync['components']) || !is_array($pendingSync['components'])) {
return [
'full' => !empty($pendingSync['full']),
'components' => [],
];
}
$components = [];
foreach ($pendingSync['components'] as $componentKey => $component) {
if (!is_array($component)) {
continue;
}
$type = strtolower(trim((string) ($component['type'] ?? '')));
$slug = strtolower(trim((string) ($component['slug'] ?? '')));
if ($type === '' || $slug === '') {
continue;
}
$components[(string) $componentKey] = [
'type' => $type,
'slug' => $slug,
];
}
return [
'full' => !empty($pendingSync['full']),
'components' => $components,
];
}
/**
* Save queued sync state.
*/
private function storePendingSync(array $pendingSync): void
{
update_option(self::PENDING_SYNC_OPTION, $pendingSync, false);
}
/**
* Turn plugin path into plugin slug.
*/
private function getPluginSlugFromPath(string $pluginPath): string
{
$pluginPath = trim(plugin_basename($pluginPath));
if ($pluginPath === '') {
return '';
}
$directory = dirname($pluginPath);
$slug = $directory !== '.' && $directory !== '/' ? $directory : basename($pluginPath, '.php');
return strtolower($slug);
}
/**
* Extract slugs from upgrader payload.
*
* Plugins use plugin-file to slug conversion.
* Themes use the stylesheet slug as-is.
*
* @param mixed $items Upgrader payload items.
*/
private function normalizeComponentSlugsFromUpgradePayload($items, string $type): array
{
if (!is_array($items)) {
return [];
}
$slugs = [];
foreach ($items as $item) {
if (!is_string($item) || $item === '') {
continue;
}
$slug = sanitize_key($item);
if ($type === 'plugin') {
$slug = $this->getPluginSlugFromPath($item);
}
if ($slug !== '' && !in_array($slug, $slugs, true)) {
$slugs[] = $slug;
}
}
return $slugs;
}
}