first commit

This commit is contained in:
2026-04-28 15:13:50 +02:00
commit a95acc355b
63745 changed files with 9487948 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Controllers;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
class DashboardController implements ControllerInterface
{
protected EnvironmentConfig $env;
public function __construct(EnvironmentConfig $environmentConfig)
{
$this->env = $environmentConfig;
}
public function register(): void
{
// Redirect on the activation hook, but do it after anything else.
add_action('rss_core_activation', [$this, 'maybeRedirectToDashboard'], 9999);
}
/**
* Redirect to dashboard page on activation, but only if the user manually
* activated the plugin via the plugins overview. React will handle
* redirect to onboarding if needed.
*
* @param string $pageSource The page where the activation was triggered,
* usually 'plugins.php' or 'update.php'.
*/
public function maybeRedirectToDashboard(string $pageSource = ''): void
{
if ($pageSource !== 'plugins.php' && $pageSource !== 'update.php') {
return;
}
wp_safe_redirect($this->env->getUrl('plugin.dashboard_url'));
exit;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features;
use ReallySimplePlugins\RSS\Core\Managers\FeatureManager;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\RequestStorage;
/**
* Each Feature should have a {FeatureName}Loader class that extends this
* abstract loader. The {@see FeatureManager} will use the loader to
* determine if a feature should be loaded.
*
* @internal Without loading all the feature classes, composer will prevent
* requiring the files entirely. Even tho the Feature namespace falls
* withing the psr-4 scope.
*/
abstract class AbstractLoader
{
protected EnvironmentConfig $env;
protected RequestStorage $request;
public function __construct(
EnvironmentConfig $environmentConfig,
RequestStorage $request
)
{
$this->env = $environmentConfig;
$this->request = $request;
}
/**
* Method should return true if the feature is enabled. This can check
* setting values or user capabilities for example.
*/
abstract public function isEnabled(): bool;
/**
* Method should return true if the context of the user is in the scope of
* the feature to be loaded. For example: some features only need to load
* on our dashboard and others also in each REST API request.
*/
abstract public function inScope(): bool;
/**
* Check if the current user is on the Dashboard page.
* @todo Responsibility for retrieving the dashboard "page" value should
* be added somewhere and it should be globally accessible.
*/
protected function userIsOnDashboard(): bool
{
$pageVisitedByUser = $this->request->getString('global.page');
$dashboardUrl = $this->env->getString('plugin.dashboard_url');
$pluginPageQueryString = wp_parse_url($dashboardUrl, PHP_URL_QUERY);
parse_str($pluginPageQueryString, $parsedQuery);
$pluginDashboardPage = ($parsedQuery['page'] ?? '');
return $pageVisitedByUser === $pluginDashboardPage;
}
/**
* Check if the current request is a WP JSON request. This is better than
* the WordPress native function `wp_is_json_request()`, because that
* returns false when visiting /wp-json/ or ?rest_route= (for plain
* permalinks) endpoint. We need a true value there to activate
* features that register REST routes. For example
* {@see \ReallySimplePlugins\RSS\Core\Features\Onboarding\OnboardingController}
*
* @internal Ignore the phpcs errors for this method, as they are false
* positives. We do not actually use the $_GET or $_SERVER variables
* directly, but we need to check if they are set and contain the
* expected values.
*/
protected function requestIsRestRequest(): bool
{
$pluginHttpNamespace = $this->env->getString('http.namespace');
$restUrlPrefix = trailingslashit(rest_get_url_prefix());
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$currentRequestUri = ($_SERVER['REQUEST_URI'] ?? '');
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
$isPlainPermalink = (
isset($_GET['rest_route'])
&& (strpos($_GET['rest_route'], $pluginHttpNamespace) !== false)
);
return (strpos($currentRequestUri, $restUrlPrefix) !== false) || $isPlainPermalink;
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Onboarding;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Interfaces\DoActionInterface;
use ReallySimplePlugins\RSS\Core\Interfaces\FeatureInterface;
use ReallySimplePlugins\RSS\Core\Services\CertificateService;
use ReallySimplePlugins\RSS\Core\Services\EmailService;
use ReallySimplePlugins\RSS\Core\Services\RelatedPluginService;
use ReallySimplePlugins\RSS\Core\Services\SecureSocketsService;
use ReallySimplePlugins\RSS\Core\Services\SettingsConfigService;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
use ReallySimplePlugins\RSS\Core\Support\Utility\StringUtility;
use ReallySimplePlugins\RSS\Core\Traits\HasNonces;
class OnboardingController implements FeatureInterface, DoActionInterface
{
use HasNonces;
private EmailService $emailService;
private OnboardingFeatureService $service;
private SecureSocketsService $sslService;
private RelatedPluginService $pluginService;
private SettingsConfigService $settingsService;
private CertificateService $certificateService;
private EnvironmentConfig $env;
public function __construct(
OnboardingFeatureService $service,
SecureSocketsService $sslService,
EmailService $emailService,
RelatedPluginService $pluginService,
SettingsConfigService $settingsService,
CertificateService $certificateService,
EnvironmentConfig $environmentConfig
) {
$this->env = $environmentConfig;
$this->service = $service;
$this->sslService = $sslService;
$this->emailService = $emailService;
$this->pluginService = $pluginService;
$this->settingsService = $settingsService;
$this->certificateService = $certificateService;
}
public function register(): void
{
add_filter('rsssl_run_test', [$this, 'processOnboardingTest'], 10, 3);
add_filter('rsssl_do_action', [$this, 'rssslDoAction'], 10, 3);
add_action($this->env->getString('onboarding.queue_event'), [$this, 'processQueuedEvent']);
}
/**
* Method processes the onboarding request for SSL activation. The responses
* are validated by the {@see rsssl_run_test} filter.
* @return array|bool
*/
public function processOnboardingTest(array $response, string $action, array $data)
{
switch ($action) {
case 'activate_ssl':
$data['is_rest_request'] = true;
$response = $this->sslService->activateSSL($data);
break;
case 'activate_ssl_networkwide':
$response = $this->service->processMultisiteActivationStep();
break;
default:
return $response;
}
return $response;
}
/**
* Method to dynamically parse onboarding actions. The action is parsed to
* PascalCase and if it is an onboarding action a dedicated method is
* called based on the format: process{OnboardingAction}Action. These
* methods all have access to the Storage object which contains all data
* of the request. Each method cán use it, but its not mandatory of course.
*
* @uses processOnboardingDataAction, processGetModalStatusAction
* @uses processDismissModalAction, processOverrideSslDetectionAction
* @uses processUpdateEmailAction, processActivateAction
* @uses processDownloadAction
*/
public function rssslDoAction(array $response, string $action, $data): array
{
$actionableMethod = 'process' . StringUtility::snakeToPascalCase($action) . 'Action';
// Current action is not one we want to process
if (method_exists($this, $actionableMethod) === false) {
return $response;
}
// Method exists. Try to execute and return the response.
try {
$storage = new Storage($data);
return $this->$actionableMethod($storage);
} catch (\Exception $exception) {
return array_merge($response, [
'success' => false,
'message' => $exception->getMessage(),
]);
}
}
/**
* Resets onboarding state when user clicks the "Activate SSL" button
* after dismissal.
*
* When a user dismisses the onboarding modal and later clicks the
* "Activate SSL" button, we need to reset the onboarding state options
* (rsssl_show_onboarding and rsssl_onboarding_dismissed) to allow the
* onboarding flow to proceed.
*
* The OnboardingLoader has a bypass that loads the Controller when it
* detects the activateSSLClicked flag in the request body. Once loaded,
* we reset the options here at the start of processOnboardingDataAction
* so the onboarding state is consistent for the rest of the request and
* future requests.
*
* We can't split this into two separate actions (one to reset, one to
* fetch data) because a separate reset action without the
* activateSSLClicked flag would be blocked by the Loader checking the
* dismissed state.
*
* @return void
*/
private function onActivateSslClick(): void
{
$this->service->resetOnboarding();
}
/**
* Two possibilities:
* - a new install: show activation notice, and process onboarding
* - an upgrade to 6. Only show new features.
* @internal action: onboarding_data
* @throws \RuntimeException
*/
protected function processOnboardingDataAction(Storage $data): array
{
$nonce = $data->getString('nonce');
if ($this->verifyNonce($nonce, 'rsssl_nonce') === false) {
throw new \RuntimeException(esc_html__('Nonce validation failed', 'really-simple-ssl'));
}
// Reset onboarding state if user clicked "Activate SSL" after dismissal
if ($data->getBoolean('activateSSLClicked')) {
$this->onActivateSslClick();
}
// For an upgrade from free, we should check the rsssl_free_deactivated
// option. When upgrading from Pro from Free, rsssl_deactivate_alternate
// is called in the Free plugin. Therefore, we have to check this option.
// This is not something we can easily change, because the free plugin has
// to be updated before we can check this in Pro.
$isUpgradeFromFree = get_option('rsssl_free_deactivated');
delete_option('rsssl_free_deactivated');
$stepsGenerator = App::getInstance()->make(OnboardingStepsGenerator::class);
$onboardingSteps = $stepsGenerator->generate($isUpgradeFromFree);
//if the user called with a refresh action, clear the cache
if ($data->getBoolean('forceRefresh')) {
delete_transient('rsssl_certinfo');
}
return [
'steps' => $onboardingSteps,
'ssl_enabled' => rsssl_get_option('ssl_enabled'),
'ssl_detection_overridden' => get_option('rsssl_ssl_detection_overridden'),
'certificate_valid' => $this->certificateService->isValid(),
'networkwide' => (is_multisite() && rsssl_is_networkwide_active()),
'network_activation_status' => get_site_option('rsssl_network_activation_status'),
'rsssl_upgraded_from_free' => $isUpgradeFromFree,
];
}
/**
* Method determines if the onboarding modal should be shown
* @internal action: get_modal_status
*/
protected function processGetModalStatusAction(Storage $data): array
{
return [
'dismissed' => ($this->service->showOnboardingModal() === false),
];
}
/**
* Method processes the user action to dismiss the onboarding modal. It will
* trigger the event to process any queued items immediately.
* @internal action: dismiss_modal
*/
protected function processDismissModalAction(Storage $data): array
{
$updated = update_option('rsssl_onboarding_dismissed', $data->getBoolean('dismiss'), false);
if (!empty($this->service->getQueuedItems())) {
$this->service->manuallyProcessQueueNow();
}
return [
'success' => $updated,
];
}
/**
* Update SSL detection overridden option
* @internal action: override_ssl_detection
*/
protected function processOverrideSslDetectionAction(Storage $data): array
{
if ($data->getBoolean('overrideSSL')) {
$success = update_option('rsssl_ssl_detection_overridden', true, false);
} else {
$success = delete_option('rsssl_ssl_detection_overridden');
}
return [
'success' => $success
];
}
/**
* Method processes the given email, if it is valid we send a verification
* mail. If the user choose to receive tips&tricks then we add them to
* our mailing list as well.
* @internal action: update_email
*/
protected function processUpdateEmailAction(Storage $data): array
{
$email = $data->getEmail('email');
// Abort.
if (is_email($email) === false) {
return [
'success' => false,
];
}
rsssl_update_option('send_notifications_email', 1);
if ($data->getBoolean('includeTips')) {
$this->emailService->addEmailToMailingList($email);
}
$this->emailService->setEmail($email);
return $this->emailService->sendVerificationMail();
}
/**
* Method processes the download action for a related plugin. It does not
* immediately download this related plugin, but it adds it to a queue that
* is processed on the next page load. This prevents users breaking the
* process by refreshing the page (for example)
* @internal action: download
*/
protected function processDownloadAction(Storage $data): array
{
$this->service->queueOnboardingItem(
$data->getTitle('id'),
'download'
);
return [
'next_action' => 'activate',
'success' => true,
];
}
/**
* Method processes the activation action for a related plugin. It does not
* immediately activate this related plugin, but it adds it to a queue that
* is processed on the next page load. This prevents users breaking the
* process by refreshing the page (for example)
* @internal action: activate
*/
protected function processActivateAction(Storage $data): array
{
$this->service->queueOnboardingItem(
$data->getTitle('id'),
'activate'
);
return [
'next_action' => 'completed',
'success' => true,
];
}
/**
* Process the plugins to download/activate queue
*/
public function processQueuedEvent(): void
{
$queuedItems = $this->service->getQueuedItems();
foreach ($queuedItems as $key => &$item) {
if (!isset($item['status'], $item['action']) || $item['status'] !== 'pending') {
continue;
}
// Mark as processing
$item['status'] = 'processing';
$this->service->updateQueuedItems($queuedItems);
$this->pluginService->setPluginConfigBySlug($item['item_id']);
$success = $this->pluginService->executeAction(
sanitize_text_field($item['action'])
);
// Update status
$item['status'] = $success ? 'completed' : 'failed';
$item['completed'] = time();
}
$this->service->updateQueuedItems($queuedItems);
$this->service->cleanupQueuedItems();
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Onboarding;
use ReallySimplePlugins\RSS\Core\Services\GlobalOnboardingService;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
/**
* Business logic for the onboarding feature.
* Queue management and feature-specific onboarding logic.
* Extends {@see GlobalOnboardingService} to inherit global onboarding methods.
*/
class OnboardingFeatureService extends GlobalOnboardingService
{
protected EnvironmentConfig $env;
public function __construct(EnvironmentConfig $environmentConfig)
{
$this->env = $environmentConfig;
}
/**
* Helper method to check if we should show the onboarding.
* @todo: I guess the order of the checks is important. If not, the code
* can be optimized a bit.
*/
public function showOnboardingModal(): bool
{
$userDismissedOnboarding = (bool) get_option('rsssl_onboarding_dismissed');
if ($userDismissedOnboarding) {
return false;
}
if ($this->wpConfigNeedsFixing()) {
return false; // First fix wp-config
}
if ($this->multisiteActivationNotCompleted()) {
return true; // Finish activation with the onboarding modal
}
$sslIsEnabled = (bool) rsssl_get_option('ssl_enabled');
$showOnboardingAfterUpdateOrUpgrade = (bool) get_option('rsssl_show_onboarding');
if ($sslIsEnabled && ($showOnboardingAfterUpdateOrUpgrade === false)) {
return false; // No onboarding if ssl already enabled, except after upgrade
}
$constantDismissedOnboarding = (defined('RSSSL_DISMISS_ACTIVATE_SSL_NOTICE') && RSSSL_DISMISS_ACTIVATE_SSL_NOTICE);
if ($constantDismissedOnboarding) {
return false;
}
if (rsssl_user_can_manage() === false) {
return false;
}
return true;
}
/**
* For multisite environments, check if the activation process was
* started but not completed.
*
* @return bool True if multisite activation is incomplete
*/
private function multisiteActivationNotCompleted(): bool
{
return (is_multisite() && RSSSL()->multisite->ssl_activation_started_but_not_completed());
}
/**
* Check if wp-config needs fixing before showing onboarding.
*
* @todo: This check seems very legacy as these admin checks are present
* since 2.2. Do we still need to prevent loading the onboarding when
* we need to fix the wp-config? Do we even still fix the wp-config?
* If no, just remove this.
*
* @return bool True if wp-config needs fixing
*/
private function wpConfigNeedsFixing(): bool
{
if (RSSSL()->admin->configuration_loaded === false) {
RSSSL()->admin->detect_configuration();
}
// wp-config still need fixes
if (RSSSL()->admin->do_wpconfig_loadbalancer_fix() && !RSSSL()->admin->wpconfig_has_fixes()) {
return true;
}
// wp-config has fixes, but still not OK
if (RSSSL()->admin->wpconfig_ok() === false) {
return true;
}
return false;
}
/**
* Method processes the SSL activation step of the onboarding for multisite
* instances.
*/
public function processMultisiteActivationStep(): array
{
return RSSSL()->multisite->process_ssl_activation_step();
}
/**
* Get the items from the onboarding queue
*/
public function getQueuedItems(): array
{
$handle = $this->env->getString('onboarding.queue_option');
return get_option($handle, []);
}
/**
* Update the onboarding queue with the given array. It overrides the
* current queue completely.
*/
public function updateQueuedItems(array $queue): bool
{
$handle = $this->env->getString('onboarding.queue_option');
return update_option($handle, $queue, false);
}
/**
* Add an item to the onboarding queue. Method will also schedule the
* queue event if not already done to make sure the queue will be
* processed. Returns true when queue is correctly scheduled.
* Process is done by {@see OnboardingController::processQueuedEvent}
*/
public function queueOnboardingItem(string $itemId, string $action): bool
{
$queue = $this->getQueuedItems();
$key = sanitize_key($itemId) . '_' . sanitize_key($action);
$queue[$key] = [
'item_id' => $itemId,
'action' => $action,
'status' => 'pending',
];
$this->updateQueuedItems($queue);
// Schedule and spawn the queue event when not yet scheduled
$event = $this->env->getString('onboarding.queue_event');
if (!wp_next_scheduled($event)) {
$scheduled = wp_schedule_single_event(time() + 10, $event);
$spawned = spawn_cron();
return ($scheduled === true && $spawned === true);
}
return true;
}
/**
* Clean up queued items and only keep failed or processing items. If
* empty, we delete the queue option completely, otherwise we reschedule
* a single event to retry the leftover items
*/
public function cleanupQueuedItems(): void
{
$queuedItems = $this->getQueuedItems();
$optionHandle = $this->env->getString('onboarding.queue_option');
$eventHandle = $this->env->getString('onboarding.queue_event');
/**
* Statuses to keep even when the cleanup is triggered. Can be used to
* debug why an action did not complete. In such a case, the status is
* stuck 'failed'.
*/
$shouldKeepStatusEvenWhenCleaned = apply_filters('rsssl_cleanup_onboarding_statuses', ['processing']);
// Only keep failed or processing items
$cleanedQueue = array_filter($queuedItems, static function ($item) use ($shouldKeepStatusEvenWhenCleaned) {
$status = $item['status'] ?? '';
return in_array($status, $shouldKeepStatusEvenWhenCleaned);
});
if (empty($cleanedQueue)) {
delete_option($optionHandle);
return;
}
// Queue contains failed or processing items, schedule a next run
update_option($optionHandle, $cleanedQueue, false);
wp_schedule_single_event(time() + 600, $eventHandle);
}
/**
* Manually process the queue right now. Useful in situations where we do
* not need to wait for the event to be triggered automatically. For example
* when the onboarding is dismissed in
* {@see OnboardingController::processDismissModalAction}
*/
public function manuallyProcessQueueNow(): void
{
$eventHandle = $this->env->getString('onboarding.queue_event');
wp_clear_scheduled_hook($eventHandle);
// fire!
do_action($eventHandle);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Onboarding;
use ReallySimplePlugins\RSS\Core\Features\AbstractLoader;
class OnboardingLoader extends AbstractLoader
{
/**
* @inheritDoc
*/
public function isEnabled(): bool
{
if (rsssl_user_can_manage() === false) {
return false;
}
$onboardingQueueHasItems = $this->hasOnboardingQueueItems();
if ($onboardingQueueHasItems) {
return true; // To process the items
}
// Enable if user clicked "Activate SSL" button
// This allows the modal to fetch data when explicitly opened by user
// after dismissal
if ($this->request->getBoolean('body.activateSSLClicked')) {
return true;
}
// Enable if we're in the Let's Encrypt wizard context
// The wizard needs onboarding data for the activate SSL step
if ($this->requestIsLetsEncryptRequest()) {
return true;
}
return (
(bool) get_option('rsssl_show_onboarding', false) === true
&& (bool) get_option('rsssl_onboarding_dismissed', false) === false
);
}
/**
* @inheritDoc
*/
public function inScope(): bool
{
$onboardingQueueHasItems = $this->hasOnboardingQueueItems();
if ($onboardingQueueHasItems) {
return true; // To process the items
}
return rsssl_admin_logged_in() && ($this->userIsOnDashboard() || $this->requestIsRestRequest());
}
/**
* Returns true when the onboarding queue has items. For example if a user
* has chosen to install plugins, these actions are queued and should be
* processed later. Therefor this method is used to enable the feature
* for request processing.
*/
private function hasOnboardingQueueItems(): bool
{
$items = get_option($this->env->getString('onboarding.queue_option'), []);
return !empty($items);
}
/**
* Check if the current request is in the Let's Encrypt wizard context.
* The Let's Encrypt wizard uses the onboarding component for the activate
* SSL step, so we need to enable the onboarding feature when in this context.
*
* @internal We access $_GET and $_SERVER superglobals directly for read-only
* context detection. These values are only used for comparison checks, not
* output or database queries, so sanitization and nonce verification are not
* required. The phpcs warnings are intentionally suppressed.
*/
protected function requestIsLetsEncryptRequest(): bool
{
// Check GET parameters for direct page loads
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if (
isset($_GET['letsencrypt']) && $_GET['letsencrypt'] === '1'
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
&& isset($_GET['page']) && $_GET['page'] === 'really-simple-security'
) {
return true;
}
// Check referer for REST API requests from the Let's Encrypt wizard
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$referer = ($_SERVER['HTTP_REFERER'] ?? '');
if (
strpos($referer, 'letsencrypt=1') !== false
&& strpos($referer, 'page=really-simple-security') !== false
) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Onboarding;
use ReallySimplePlugins\RSS\Core\Services\CertificateService;
use ReallySimplePlugins\RSS\Core\Services\RelatedPluginService;
use ReallySimplePlugins\RSS\Core\Services\SettingsConfigService;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\UriConfig;
class OnboardingStepsGenerator
{
public array $steps = [];
private bool $proPluginEnabled;
private RelatedPluginService $pluginService;
private SettingsConfigService $settingsService;
private CertificateService $certificateService;
private UriConfig $uriConfig;
public function __construct(
RelatedPluginService $pluginService,
SettingsConfigService $settingsService,
CertificateService $certificateService,
UriConfig $uriConfig
) {
$this->uriConfig = $uriConfig;
$this->pluginService = $pluginService;
$this->settingsService = $settingsService;
$this->certificateService = $certificateService;
$this->proPluginEnabled = defined('rsssl_pro');
}
public function generate(bool $isUpgradeFromFree = false): array
{
if ($isUpgradeFromFree) {
$steps = [
$this->activateLicenseStep(),
$this->proStep(),
];
} else {
$steps = [
$this->activateSslStep(),
$this->emailStep(),
$this->essentialFeaturesStep(),
$this->activateLicenseStep(),
$this->relatedPluginsStep(),
$this->proStep(),
];
}
// Remove empty steps
$steps = array_filter($steps);
// Re-order keys to prevent issues after array_filter
return array_values($steps);
}
/**
* The activate SSL step include items related to SSL detection and
* configuration, but only when the user is not upgrading from the free to
* the pro version.
*/
private function activateSslStep(): array
{
$items = [];
if (strpos(site_url(), 'https://') === false) {
$items[] = [
'title' => esc_html__('You may need to login in again, have your credentials prepared.', 'really-simple-ssl'),
'status' => 'inactive',
'id' => 'login',
];
}
// Add single SSL certificate test-outcome item to the step
$items[] = $this->getSslCertificateTestResultItem();
return [
'id' => 'activate_ssl',
'title' => esc_html__('Welcome to Really Simple Security', 'really-simple-ssl'),
'subtitle' => esc_html__('The onboarding wizard will help to configure essential security features in 1 minute! Select your hosting provider to start.', 'really-simple-ssl'),
'items' => $items,
];
}
/**
* Method is used for determining the single certificate status item based
* on the detection outcome. This prevents stacking multiple certificate
* specific notices at once.
*/
private function getSslCertificateTestResultItem(): array
{
if ($this->certificateService->isValid()) {
return [
'title' => esc_html__('An SSL certificate has been detected', 'really-simple-ssl'),
'status' => 'success',
'id' => 'certificate',
];
}
if ($this->certificateService->detectionFailed()) {
return [
'title' => esc_html__('Could not test certificate', 'really-simple-ssl') . ' ' . esc_html__('Automatic certificate detection is not possible on your server.', 'really-simple-ssl'),
'status' => 'error',
'id' => 'certificate',
];
}
return [
'title' => esc_html__('No SSL certificate has been detected.', 'really-simple-ssl') . ' ' . esc_html__('Please refresh the SSL status if a certificate has been installed recently.', 'really-simple-ssl'),
'status' => 'error',
'id' => 'certificate',
];
}
/**
* The email step is used to verify the email address of the user and to
* send a test email to confirm that email is correctly configured on their
* site. But only when the user is not upgrading from the free to the pro
* version of the plugin.
*/
private function emailStep(): array
{
return [
'id' => 'email',
'title' => esc_html__('Verify your email', 'really-simple-ssl'),
'subtitle' => esc_html__('Really Simple Security will send email notifications and security warnings from your server. We will send a test email to confirm that email is correctly configured on your site. Look for the confirmation button in the email.', 'really-simple-ssl'),
'button' => esc_html__('Save and continue', 'really-simple-ssl'),
];
}
/**
* The essential features step prompts user with recommended features. But
* only if the user is not upgrading from free to pro. If a user is using
* the free version of the plugin some pro features are included in the
* step as well, this is done for upsell purposes.
*/
private function essentialFeaturesStep(): array
{
$subtitle = esc_html__('Instantly configure these essential features.', 'really-simple-ssl');
if ($this->proPluginEnabled === false) {
$subtitle .= ' ' . sprintf(
wp_kses_post(__('Please %sconsider upgrading to Pro%s to enjoy all simple and performant security features.', 'really-simple-ssl')),
'<a href="' . $this->uriConfig->getUrl('rsp.upgrade_from_free') . '" target="_blank">',
'</a>'
);
}
// If pro is not enabled we do some upselling with premium features
$includePremiumSettingsForUpsellPurposes = ($this->proPluginEnabled === false);
return [
'id' => 'features',
'title' => esc_html__('Essential security', 'really-simple-ssl'),
'subtitle' => $subtitle,
'items' => $this->settingsService->getRecommendedSettings($includePremiumSettingsForUpsellPurposes),
'button' => esc_html__('Enable', 'really-simple-ssl'),
];
}
/**
* In this step we ask the user to save and activate their license. Only
* needed is the pro version of the plugin is active.
*/
private function activateLicenseStep(): array
{
/// No need for a license step if freemium is enabled
if ($this->proPluginEnabled === false) {
return [];
}
return [
'id' => 'activate_license',
'title' => esc_html__('Activate your license key', 'really-simple-ssl'),
'subtitle' => '',
'items' => [
'type' => 'license',
],
'button' => esc_html__('Activate', 'really-simple-ssl'),
'value' => '',
];
}
/**
* This step is always included. If a user is using the free version these
* recommended settings are disabled (greyed-out) for upsell purposes.
*/
private function proStep(): array
{
return [
'id' => 'pro',
'title' => 'Really Simple Security Pro',
'subtitle' => esc_html__('Heavyweight security features, in a lightweight performant plugin from Really Simple Plugins. Get started with below features and get the latest and greatest updates for peace of mind!', 'really-simple-ssl'),
'items' => $this->settingsService->getRecommendedProSettings(),
'button' => esc_html__('Install', 'really-simple-ssl'),
];
}
/**
* This step will prompt users with other plugins of Really Simple Plugins.
* Only included if the user is not upgrading from free to pro, then this
* step was already done in the onboarding of the free version.
*/
private function relatedPluginsStep(): array
{
return [
'id' => 'plugins',
'title' => esc_html__('We think you will like this', 'really-simple-ssl'),
'subtitle' => esc_html__('Really Simple Plugins is also the author of the below privacy-focused plugins including consent management and legal documents!', 'really-simple-ssl'),
'items' => $this->pluginService->getOnboardingConfig(),
'button' => esc_html__('Install', 'really-simple-ssl'),
];
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Clients;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Support\Helpers\VulnerabilityConfig;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
/**
* HTTP client for retrieving vulnerability data from the
* Really Simple Plugins Vulnerability API.
*
* Responsible only for making HTTP requests and normalizing API responses.
*/
final class VulnerabilityClient
{
private VulnerabilityConfig $vulnerabilityConfig;
private EnvironmentConfig $env;
private string $baseUrl;
public function __construct(
VulnerabilityConfig $vulnerabilityConfig,
EnvironmentConfig $environmentConfig
) {
$this->vulnerabilityConfig = $vulnerabilityConfig;
$this->env = $environmentConfig;
$this->baseUrl = $this->resolveBaseUrl();
}
/**
* @param string $component Slug of the component (e.g. "wordpress", "contact-form-7").
* @param string $name Human-readable component name (e.g. "WordPress", "Contact Form 7").
* @param string $type Component type: "core", "plugin", or "theme".
* @param string|null $version Optional version for core components; ignored for plugins/themes.
*
* @return array{
* success: bool,
* status: int,
* data: array<array-key, mixed>,
* message?: string
* }
*/
public function fetchVulnerabilities(
string $component,
string $name,
string $type,
?string $version = null
): array {
$url = $this->baseUrl;
$query = $this->buildQuery($component, $name, $type, $version);
$url = add_query_arg($query, $url);
$response = wp_safe_remote_get($url, [
'timeout' => $this->vulnerabilityConfig->getInt('client.timeout', 10),
'headers' => [
'Accept' => 'application/json',
],
]);
if (is_wp_error($response)) {
return [
'success' => false,
'status' => 0,
'data' => [],
'message' => $response->get_error_message(),
];
}
$statusCode = (int)wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
return $this->normalizeResponse($statusCode, $body);
}
/**
* @return array{success: bool, status: int, data: array<array-key, mixed>, message?: string}
*/
public function fetchCore(string $version): array
{
return $this->fetchVulnerabilities(
'wordpress',
'WordPress',
'core',
$version
);
}
/**
* @return array{success: bool, status: int, data: array<array-key, mixed>, message?: string}
*/
public function fetchPlugin(string $slug, string $name): array
{
return $this->fetchVulnerabilities(
$slug,
$name,
'plugin'
);
}
/**
* @return array{success: bool, status: int, data: array<array-key, mixed>, message?: string}
*/
public function fetchTheme(string $slug, string $name): array
{
return $this->fetchVulnerabilities(
$slug,
$name,
'theme'
);
}
/**
* Builds the query array for a vulnerability request based on component
* data and an optional version for core components.
*
* @param string $component Component slug.
* @param string $name Component name.
* @param string $type Component type.
* @param string|null $version Optional version for core components.
*
* @return array<string, string>
*/
private function buildQuery(string $component, string $name, string $type, ?string $version): array
{
$query = [
'component[eq]' => $component,
'name[eq]' => $name,
'type[eq]' => $type,
];
if ($type === 'core' && $version !== null && $version !== '') {
$query['version[eq]'] = $version;
}
return $query;
}
/**
* Resolves and caches the base URL for vulnerability requests.
*
* Priority:
* 1) client config (base_uri + namespace/version/endpoint)
* 2) environment config (plugin.url + http.namespace/http.version + client endpoint)
*/
private function resolveBaseUrl(): string
{
$base = rtrim($this->vulnerabilityConfig->getString('client.base_uri'), '/');
$namespace = trim($this->vulnerabilityConfig->getString('client.namespace'), '/');
$version = trim($this->vulnerabilityConfig->getString('client.version'), '/');
$endpoint = trim($this->vulnerabilityConfig->getString('client.endpoint'), '/');
if ($base !== '' && $namespace !== '' && $version !== '' && $endpoint !== '') {
return sprintf(
'%s/wp-json/%s/%s/%s',
$base,
$namespace,
$version,
$endpoint
);
}
$envBase = rtrim($this->env->getString('plugin.url'), '/');
$envNamespace = trim($this->env->getString('http.namespace'), '/');
$envVersion = trim($this->env->getString('http.version'), '/');
if ($endpoint === '') {
$endpoint = 'vulnerabilities';
}
return sprintf(
'%s/wp-json/%s/%s/%s',
$envBase,
$envNamespace,
$envVersion,
$endpoint
);
}
/**
* Normalizes the decoded API response into a consistent shape.
*
* @param int $statusCode HTTP status code from the response.
* @param string $body Raw response body.
*
* @return array{
* success: bool,
* status: int,
* data: array<array-key, mixed>,
* message?: string
* }
*/
private function normalizeResponse(int $statusCode, string $body): array
{
$decoded = json_decode($body, true);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
return [
'success' => false,
'status' => $statusCode,
'data' => [],
'message' => 'Invalid JSON response from vulnerability API.',
];
}
if (isset($decoded['data']) && is_array($decoded['data'])) {
$apiData = $decoded['data'];
$message = isset($decoded['message']) ? (string)$decoded['message'] : null;
$apiStatus = isset($decoded['status']) ? (string)$decoded['status'] : '';
return [
'success' => $apiStatus === 'success',
'status' => $statusCode,
'data' => $apiData, // In a correct search we only have an array of one.
'message' => $message,
];
}
return [
'success' => false,
'status' => $statusCode,
'data' => [],
'message' => 'Unexpected response format from vulnerability API.',
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\VulnerabilityPresentationService;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Traits\HasFrontendUrl;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Traits\HasViews;
/**
* Integrates vulnerability indicators into the WordPress Plugins admin screen.
*
* This controller is responsible only for UI integration:
* - It registers admin hooks for the Plugins overview table.
* - It fetches precomputed vulnerability data from repositories.
* - It delegates formatting and labels to presentation services.
*
* No business logic or persistence is handled here.
*/
final class PluginController implements ControllerInterface
{
use HasViews;
use HasFrontendUrl;
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
private VulnerabilityPresentationService $presentationService;
public function __construct(
VulnerabilityStorageRepository $vulnerabilityStorageRepository,
VulnerabilityPresentationService $presentationService
) {
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
$this->presentationService = $presentationService;
}
/**
* Registers WordPress admin hooks for extending the Plugins overview table.
*
* Hooks registered:
* - 'manage_plugins_columns' to add a new column for vulnerabilities.
* - 'manage_plugins_custom_column' to render content in the custom column.
*
* This method is intended to be called once during plugin bootstrapping.
*/
public function register(): void
{
add_filter('manage_plugins_columns', [$this, 'addVulnerabilityColumn']);
add_action('manage_plugins_custom_column', [$this, 'renderVulnerabilityColumn'], 10, 2);
add_filter('manage_plugins-network_columns', [$this, 'addVulnerabilityColumn']);
add_action('manage_plugins-network_custom_column', [$this, 'renderVulnerabilityColumn'], 10, 2);
}
/**
* Adds a new column to the Plugins table in the WordPress admin.
*
* The returned array represents the column headers, where the key is the column slug
* and the value is the displayed column title.
*
* @param array<string, string> $columns Existing columns keyed by slug.
* @return array<string, string> Modified columns including the vulnerability column.
*/
public function addVulnerabilityColumn(array $columns): array
{
$columns['rsssl_vulnerabilities'] = __('Vulnerabilities', 'really-simple-ssl');
return $columns;
}
/**
* Renders the vulnerability indicator content for a plugin row in the admin table.
*
* This method is called by WordPress for each plugin row when rendering custom columns.
*
* @param string $columnName The current column slug being rendered.
* @param string $pluginFile The plugin file path relative to the plugins directory.
* This is used to determine the plugin slug.
*
* Slug normalization is necessary because plugins can be either single PHP files
* or directories containing multiple files.
*
* Output is echoed directly as per WordPress admin table rendering conventions.
*/
public function renderVulnerabilityColumn(string $columnName, string $pluginFile): void
{
if ($columnName !== 'rsssl_vulnerabilities') {
return;
}
// Normalize slug: plugins can be directories or single files, handle both cases.
$slug = dirname($pluginFile);
if ($slug === '.' || $slug === '/') {
$slug = basename($pluginFile, '.php');
}
$slug = strtolower($slug);
$highestSeverity = $this->vulnerabilityStorageRepository->getHighestSeverityForPluginSlug($slug);
if ($highestSeverity === null) {
echo '';
return;
}
$label = $this->presentationService->getLabelForSeverity($highestSeverity->severity);
echo $this->view('features/vulnerability/plugin-column', [
'severity' => $highestSeverity->severity,
'label' => $label,
'frontendUrl' => $this->getFrontendUrl(
'plugin',
$slug,
$highestSeverity->lookup
),
]);
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\VulnerabilityPresentationService;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Traits\HasFrontendUrl;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
/**
* Integrates vulnerability indicators into the WordPress Themes admin screen.
*
* This controller is responsible only for UI integration:
* - It enqueues the Themes overview script on the correct admin screen.
* - It exposes a pre-shaped payload via `wp_localize_script()` for that script.
* - It delegates vulnerability data access to repositories and presentation details
* (labels, messaging) to dedicated services.
*
* No business logic (severity calculation, storage writes) is performed here.
*/
final class ThemeController implements ControllerInterface
{
use HasFrontendUrl;
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
private VulnerabilityPresentationService $presentationService;
private EnvironmentConfig $env;
public function __construct(
VulnerabilityStorageRepository $vulnerabilityStorageRepository,
VulnerabilityPresentationService $presentationService,
EnvironmentConfig $env
) {
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
$this->presentationService = $presentationService;
$this->env = $env;
}
/**
* Registers WordPress admin hooks for the Themes overview integration.
*
* This is called once during plugin boot by the controller manager.
*/
public function register(): void
{
add_action('admin_enqueue_scripts', [$this, 'enqueueThemeAssets']);
}
/**
* Enqueues the Themes overview script and attaches localized vulnerability data.
*
* WordPress passes the current admin page hook suffix as `$hook`. We only
* enqueue and localize on the Themes overview screen (`themes.php`).
*
* @param string $hook Current admin page hook suffix.
*/
public function enqueueThemeAssets(string $hook): void
{
if (!$this->isThemeOverviewScreen($hook)) {
return;
}
$assetsBasePath = trailingslashit($this->env->getString('core.assets_path'));
$assetsBaseUrl = trailingslashit($this->env->getString('core.assets_url'));
$version = $this->env->get('plugin.version');
$jsUrl = $assetsBaseUrl . 'js/rsssl-theme-vulnerabilities.js';
$jsPath = $assetsBasePath . 'js/rsssl-theme-vulnerabilities.js';
if (!file_exists($jsPath)) {
return;
}
$handle = 'rsssl-theme-vulnerabilities';
wp_enqueue_script(
$handle,
$jsUrl,
[],
$version,
true
);
$this->outputThemeVulnerabilityData($handle);
}
/**
* Determine whether the current admin request targets a Themes overview screen.
*
* Supports both regular admin and multisite network-admin theme pages by
* checking the passed hook suffix first and falling back to `get_current_screen()`.
*/
private function isThemeOverviewScreen(string $hook): bool
{
if (in_array($hook, ['themes.php', 'themes-network.php'], true)) {
return true;
}
if (!function_exists('get_current_screen')) {
return false;
}
$screen = get_current_screen();
if ($screen === null) {
return false;
}
return in_array((string) $screen->base, ['themes', 'themes-network'], true);
}
/**
* Localizes vulnerable theme data for the Themes overview script.
*
* The payload is exposed to JavaScript under the global `rssslVulnerabilities`
* object (WordPress convention via `wp_localize_script()`).
*
* @param string $handle The script handle to attach the localized payload to.
*/
public function outputThemeVulnerabilityData(string $handle): void
{
$themes = $this->buildThemePayload();
wp_localize_script(
$handle,
'rssslVulnerabilities',
[
'themes' => $themes,
]
);
}
/**
* Builds the Themes overview payload (vulnerable themes only).
*
* @return list<array{
* slug: non-empty-string,
* severity: non-empty-string,
* label: string,
* info: string,
* url: string
* }>
*/
private function buildThemePayload(): array
{
$allComponents = $this->vulnerabilityStorageRepository->getAllRaw();
if ($allComponents === []) {
return [];
}
$themes = [];
foreach ($allComponents as $allComponent) {
if (!is_array($allComponent)) {
continue;
}
if (strtolower((string)($allComponent['type'] ?? '')) !== 'theme') {
continue;
}
$slug = (string)($allComponent['slug'] ?? '');
if ($slug === '') {
continue;
}
$highest = $this->vulnerabilityStorageRepository->getHighestSeverityForThemeSlug($slug);
if ($highest === null) {
continue;
}
$severity = $highest->severity;
$label = $this->presentationService->getLabelForSeverity($severity);
$themes[] = [
'slug' => $slug,
'severity' => $severity,
'label' => $label,
'info' => __('Really Simple Security detected a vulnerability in this theme', 'really-simple-ssl'),
'url' => $this->getFrontendUrl(
'theme',
$slug,
$highest->lookup
),
];
}
return $themes;
}
}

View File

@@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\CoreRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\PluginRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\ThemeRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\VulnerabilityEmailService;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Support\Helpers\VulnerabilityConfig;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Traits\HasFrontendUrl;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Interfaces\DoActionInterface;
use ReallySimplePlugins\RSS\Core\Traits\HasViews;
/**
* Routes vulnerability-related data actions for the admin UI.
*
* Notes: Read-only; delegates data access to repositories and delivery to services.
*/
final class VulnerabilityDataController implements ControllerInterface, DoActionInterface
{
use HasViews;
use HasFrontendUrl;
/**
* Handles the 'rsssl_do_action' action for vulnerability-related data requests.
* this fetches vulnerability data for plugins and themes. within the admin interface.
*/
private const SCAN_FILES_ACTION = 'vulnerabilities_scan_files';
/**
* Handles the 'rsssl_do_action' action for vulnerability dashboard data requests.
* this fetches summary data for the vulnerability dashboard within the admin interface.
*/
private const DASHBOARD_DATA_ACTION = 'vulnerability_dashboard_data';
/**
* Handles the 'rsssl_do_action' action for sending a test vulnerability notification.
* this triggers sending a test email to verify notification settings.
*/
private const TEST_NOTIFICATION_ACTION = 'vulnerabilities_test_notification';
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
private VulnerabilityConfig $vulnerabilityConfig;
private PluginRepository $pluginRepository;
private ThemeRepository $themeRepository;
private CoreRepository $coreRepository;
private VulnerabilityEmailService $vulnerabilityEmailService;
public function __construct(
VulnerabilityStorageRepository $vulnerabilityStorageRepository,
VulnerabilityConfig $vulnerabilityConfig,
PluginRepository $pluginRepository,
ThemeRepository $themeRepository,
CoreRepository $coreRepository,
VulnerabilityEmailService $vulnerabilityEmailService
)
{
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
$this->pluginRepository = $pluginRepository;
$this->themeRepository = $themeRepository;
$this->vulnerabilityConfig = $vulnerabilityConfig;
$this->vulnerabilityEmailService = $vulnerabilityEmailService;
$this->coreRepository = $coreRepository;
}
/**
* Only register the filter for general actions. Specific actions are
* handled in {@see rssslDoAction()} and {@see DoActionInterface}.
*
*/
public function register(): void
{
add_filter('rsssl_do_action', [$this, 'rssslDoAction'], 10, 3);
}
/**
* @inheritDoc
*/
public function rssslDoAction(array $response, string $action, $data): array
{
switch ($action) {
case self::SCAN_FILES_ACTION:
return $this->getVulnerabilityOverviewData();
case self::DASHBOARD_DATA_ACTION:
return $this->getVulnerabilityDashboardData();
case self::TEST_NOTIFICATION_ACTION:
return $this->sendTestNotification($data);
default:
return $response;
}
}
/**
* Build vulnerability overview data for the admin UI.
*
* For each installed plugin, theme, and WordPress core component, this collects the highest-severity
* vulnerability (if any) from storage, enriches it with runtime update availability, and adds
* derived fields for display (no HTML).
*
* @return array{
* request_success: bool,
* data: list<array<string, mixed>>,
* }
*/
private function getVulnerabilityOverviewData(): array
{
$highVulnerabilities = [];
$allPluginsInstalled = $this->pluginRepository->getInstalledComponents();
$allThemesInstalled = $this->themeRepository->getInstalledComponents();
$coreInstalled = $this->coreRepository->getInstalledComponents();
foreach ($allPluginsInstalled as $plugin) {
$foundPlugin = $this->vulnerabilityStorageRepository
->getHighestSeverityForPluginSlug($plugin->getSlug());
if ($foundPlugin !== null) {
$foundPluginArray = $foundPlugin->toArray();
$foundPluginArray['update_available'] = $plugin->hasUpdate();
$highVulnerabilities[] = $foundPluginArray;
}
}
foreach ($allThemesInstalled as $theme) {
$foundTheme = $this->vulnerabilityStorageRepository
->getHighestSeverityForThemeSlug($theme->getSlug());
if ($foundTheme !== null) {
$foundThemeArray = $foundTheme->toArray();
$foundThemeArray['update_available'] = $theme->hasUpdate();
$highVulnerabilities[] = $foundThemeArray;
}
}
foreach ($coreInstalled as $core) {
$foundCore = $this->vulnerabilityStorageRepository
->getHighestSeverityForCoreSlug($core->getSlug());
if ($foundCore !== null) {
$foundCoreArray = $foundCore->toArray();
$foundCoreArray['update_available'] = $core->hasUpdate();
$highVulnerabilities[] = $foundCoreArray;
}
}
$highVulnerabilities = $this->enrichVulnerabilityRows($highVulnerabilities);
return [
'request_success' => true,
'data' => $highVulnerabilities,
];
}
/**
* Build dashboard metrics for the admin UI.
*
* Metrics include:
* - updatableComponents: number of installed plugins/themes with an update available.
* - vulnerablePlugins: number of stored components that currently have vulnerabilities.
* - highestSeverity: highest severity across all stored components (or "none").
* - severityScore: numeric rank for the highest severity.
*
* @return array{
* request_success: bool,
* data: array{
* updatableComponents: int,
* vulnerablePlugins: int,
* highestSeverity: string,
* severityScore: int
* }
* }
*/
private function getVulnerabilityDashboardData(): array
{
$updatable = $this->getUpdatableComponentsCount();
$highestSeverity = $this->vulnerabilityStorageRepository->getHighestSeverity();
if (empty($highestSeverity)) {
$highestSeverity = 'none';
}
$data = [
'updatableComponents' => $updatable,
'vulnerablePlugins' => count($this->vulnerabilityStorageRepository->getAllVulnerableComponentsRaw()),
'highestSeverity' => $highestSeverity ?? '',
'severityScore' => $this->vulnerabilityStorageRepository->getSeverityScore($highestSeverity),
];
return [
'request_success' => true,
'data' => $data,
];
}
/**
* Prepare for sending a test vulnerability notification email.
*
* Sets a unique option to track the test email and clears existing admin notices.
*/
private function prepareTestNotification(): void
{
$randomString = md5((string)time());
update_option('test_vulnerability_tester', $randomString, false);
delete_option('rsssl_admin_notices');
}
/**
* Send a test vulnerability notification email.
*
* @param mixed $data Payload from the admin action request (currently unused).
*
* @return array<string, mixed> Response payload for the admin UI.
*/
private function sendTestNotification($data): array
{
$this->prepareTestNotification();
try {
$emailAddress = $this->vulnerabilityEmailService->getNotificationsEmail();
$this->vulnerabilityEmailService->setEmail($emailAddress);
return $this->vulnerabilityEmailService->sendTestEmail();
} catch (\Throwable $e) {
return [
'request_success' => false,
'message' => __('Unable to send test notification at this time.', 'rsp'),
'error' => $e->getMessage(),
];
}
}
/**
* Enrich vulnerability rows with derived fields for display (no HTML).
*
* Validates required keys before accessing them to avoid runtime notices
* and to keep the data contract explicit.
*
* @param list<array<string, mixed>> $rows
*
* @return list<array<string, mixed>>
*/
private function enrichVulnerabilityRows(array $rows): array
{
foreach ($rows as $index => $row) {
if (!isset(
$row['type'],
$row['slug'],
$row['vulnerability'],
$row['vulnerability']['lookup'],
$row['vulnerability']['published_at']
)) {
// Skip rows that do not match the expected structure
continue;
}
$publishedAt = $row['vulnerability']['published_at'];
$timestamp = strtotime((string)$publishedAt);
if ($timestamp !== false) {
$row['published_date_human'] = date('F d, Y', $timestamp);
}
$row['details_url'] = $this->getFrontendUrl(
(string)$row['type'],
(string)$row['slug'],
(string)$row['vulnerability']['lookup']
);
$rows[$index] = $row;
}
return $rows;
}
/**
* Count installed components (plugins and themes) that have an update available.
*
* Centralizes update availability logic to avoid duplicated loops
* and keep dashboard calculations consistent.
*/
private function getUpdatableComponentsCount(): int
{
$count = 0;
$plugins = $this->pluginRepository->getInstalledComponents();
foreach ($plugins as $plugin) {
if ($plugin->hasUpdate()) {
$count++;
}
}
$themes = $this->themeRepository->getInstalledComponents();
foreach ($themes as $theme) {
if ($theme->hasUpdate()) {
$count++;
}
}
return $count;
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Policies\ConfigurableSeverityPolicy;
/**
* Integrates vulnerability-related notices into the plugin notice system.
*
* This controller is responsible only for UI notice integration:
* - It hooks into the `rsssl_notices` filter and adds notice definitions.
* - It reads notification thresholds from plugin options.
* - It delegates vulnerability lookups to the storage repository.
*
* No scanning, persistence, or severity calculation is performed here.
* The repository is treated as the source of truth for stored vulnerabilities.
*/
final class VulnerabilityNoticeController implements ControllerInterface
{
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
/**
* Constructor.
*
* Injects the vulnerability storage repository dependency.
* No processing or data fetching occurs during construction.
*/
public function __construct(
VulnerabilityStorageRepository $vulnerabilityStorageRepository
) {
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
}
/**
* Registers the `rsssl_notices` hook to add vulnerability notices.
*
* This method is called once during plugin boot.
*/
public function register(): void
{
add_filter('rsssl_notices', [$this, 'showHelpNotices'], 10, 1);
}
/**
* Adds vulnerability notices based on stored vulnerabilities and configured thresholds.
*
* WordPress passes the current notice registry as an array. This method returns the
* modified registry with zero or more vulnerability notices appended.
*
* Threshold behavior:
* - Dashboard threshold controls whether the notice is shown on the plugin dashboard.
* - Sitewide threshold controls whether the notice is shown as a WordPress admin notice.
* - A threshold of `*` disables notices for that scope.
*
* @param array<string, mixed> $notices Existing notice registry.
*
* @return array<string, mixed> Updated notice registry.
*/
public function showHelpNotices(array $notices): array
{
$severityCounts = [
'low' => 0,
'medium' => 0,
'high' => 0,
'critical' => 0,
];
$dashboardThreshold = rsssl_get_option('vulnerability_notification_dashboard');
$sitewideThreshold = rsssl_get_option('vulnerability_notification_sitewide');
$severities = $this->vulnerabilityStorageRepository->getComponentCountPerHighestSeverity();
$timeStamp = time();
foreach ($severities as $severity => $count) {
$normalizedSeverity = strtolower($severity);
$uniqueCode = md5($severity . $timeStamp);
$title = $this->getWarningString($normalizedSeverity, $count);
if ($severity === '') {
continue;
}
$siteWide = false;
$normalizedSeverity = strtolower($severity);
if (!isset($severityCounts[$normalizedSeverity])) {
continue;
}
$dashboardNotice = false;
// Convert severity labels to comparable numeric scores.
$severityScore = ConfigurableSeverityPolicy::SEVERITY_SCORES[strtolower($severity)] ?? 0;
$dashboardScore = ConfigurableSeverityPolicy::SEVERITY_SCORES[strtolower((string) $dashboardThreshold)] ?? 0;
$sitewideScore = ConfigurableSeverityPolicy::SEVERITY_SCORES[strtolower((string) $sitewideThreshold)] ?? 0;
if ($dashboardThreshold && $dashboardThreshold !== '*' && $severityScore >= $dashboardScore) {
$dashboardNotice = true;
}
if ($sitewideThreshold && $sitewideThreshold !== '*' && $severityScore >= $sitewideScore) {
$siteWide = true;
}
if (!$dashboardNotice && !$siteWide) {
continue;
}
$notices['risk_level_' . $normalizedSeverity . $uniqueCode] = [
'callback' => '_true_',
'score' => 30,
'show_with_options' => ['enable_vulnerability_scanner'],
'output' => [
'true' => [
'title' => $title,
'msg' => $title . ' ' . __(
'Please take appropriate action.',
'really-simple-ssl'
),
'icon' => ($normalizedSeverity === 'critical' || $normalizedSeverity === 'high') ? 'warning' : 'open',
'type' => 'warning',
'dismissible' => true,
'admin_notice' => $siteWide,
'plusone' => true,
'highlight_field_id' => 'vulnerabilities-overview',
],
],
];
}
return $notices;
}
/**
* Builds the translated notice title for a given severity level and count.
*
* Uses WordPress pluralization (`_n`) to return a human-friendly message.
*
* @param non-empty-string $severity Normalized severity key (low|medium|high|critical).
* @param positive-int $count Number of components with that severity as highest.
*/
private function getWarningString(string $severity, int $count): string
{
switch ($severity) {
case 'critical':
$warning = sprintf(_n(
'You have %s critical vulnerability',
'You have %s critical vulnerabilities',
$count,
'really-simple-ssl'
), $count);
break;
case 'high':
$warning = sprintf(_n(
'You have %s high-risk vulnerability',
'You have %s high-risk vulnerabilities',
$count,
'really-simple-ssl'
), $count);
break;
case 'medium':
$warning = sprintf(_n(
'You have %s medium-risk vulnerability',
'You have %s medium-risk vulnerabilities',
$count,
'really-simple-ssl'
), $count);
break;
default:
$warning = sprintf(_n(
'You have %s low-risk vulnerability',
'You have %s low-risk vulnerabilities',
$count,
'really-simple-ssl'
), $count);
break;
}
return $warning;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\VulnerabilityAfterSyncService;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\VulnerabilityController;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
use ReallySimplePlugins\RSS\Core\Traits\HasScheduler;
/**
* Orchestrates vulnerability re-sync triggers and notification decisions.
*
* Constraints: Delegates syncing, snapshot building, and email delivery to services and repositories.
*/
final class VulnerabilityNotificationController implements ControllerInterface
{
use HasScheduler;
/**
* Debounce window (in seconds) for scheduling sync runs.
*/
private const SYNC_DEBOUNCE_SECONDS = 300; // 5 minutes
public const AFTER_SYNC_COMPLETED_ACTION = 'rsssl_vulnerability_after_sync_completed';
private const SCHEDULED_AFTER_SYNC_ACTION = 'rsssl_vulnerability_run_after_sync';
private VulnerabilityAfterSyncService $service;
public function __construct(VulnerabilityAfterSyncService $service)
{
$this->service = $service;
}
public function register(): void
{
add_action(VulnerabilityController::SYNC_COMPLETED_ACTION, [$this, 'schedule'], 10, 0);
add_action(self::SCHEDULED_AFTER_SYNC_ACTION, [$this, 'runAfterSync'], 10, 0);
add_action('rsssl_daily_cron', [$this, 'schedule']);
}
/**
* Schedule the after-sync notification decision process.
*
* @return void
*/
public function schedule(): void
{
$this->scheduleDebounced(
self::SCHEDULED_AFTER_SYNC_ACTION,
self::SYNC_DEBOUNCE_SECONDS,
[]
);
}
/**
* Run the after-sync notification decision process.
*
* @return void
*/
public function runAfterSync(): void
{
$this->service->run();
// Release the debounce lock after processing.
$this->releaseDebounceLock(self::SCHEDULED_AFTER_SYNC_ACTION);
// Signal that the after-sync process has completed.
do_action(self::AFTER_SYNC_COMPLETED_ACTION);
}
}

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Immutable-style data transfer object representing all vulnerabilities
* for a single installed component.
*
* A component can be a plugin, theme, or WordPress core. This DTO groups:
* - Component identity (name, slug, type).
* - Known vulnerability ranges affecting this component.
* - Runtime-enriched data such as installed version, update availability,
* and latest known version.
*
* It is primarily used to:
* - Transport vulnerability data between API, storage, and domain layers.
* - Enrich vulnerability data with local installation context.
*
* This object holds state only:
* - No persistence logic
* - No policy or decision logic
* - No scheduling or WordPress hook concerns
*/
final class ComponentVulnerabilitiesDto
{
/**
* Human-readable component name.
*/
private string $name;
/**
* Unique component identifier used for lookups.
*/
private string $slug;
/**
* Component type (`plugin`, `theme`, or `core`).
*/
private string $type;
/**
* Latest known available version (null if unknown).
*/
private ?string $latestVersion = null;
/**
* List of vulnerability ranges affecting this component.
*
* @var list<VulnerabilityRangeDto>
*/
public array $vulnerabilities = [];
/**
* Currently installed version on the site.
*/
public string $installedVersion = '';
/**
* Whether an update is available for the installed version.
*/
private ?bool $updateAvailable = null;
/**
* Constructor.
*
* @param string $name Component name from the API.
* @param string $slug Component slug from the API.
* @param string $type Component type from the API.
* @param list<VulnerabilityRangeDto> $vulnerabilities Vulnerability ranges from the API.
*
* Runtime fields like installed version and update availability
* are populated later via setters.
*/
public function __construct(
string $name,
string $slug,
string $type,
array $vulnerabilities
) {
$this->vulnerabilities = $vulnerabilities;
$this->type = $type;
$this->slug = $slug;
$this->name = $name;
}
/**
* Normalize a raw API component payload into a strongly typed DTO.
*
* Invalid or unexpected vulnerability entries are skipped defensively.
*
* @param array<string, mixed> $payload Raw API component data.
*
* @return self
*/
public static function fromApiComponentArray(array $payload): self
{
$storage = new Storage($payload);
$name = $storage->getString('name');
$slug = $storage->getString('slug');
$type = strtolower($storage->getString('type'));
$vulnerabilities = [];
$rawVulnerabilities = $payload['vulnerabilities'] ?? [];
if (is_array($rawVulnerabilities)) {
foreach ($rawVulnerabilities as $rawVulnerability) {
if (!is_array($rawVulnerability)) {
continue;
}
$vulnerabilities[] = VulnerabilityRangeDto::fromApiArray($rawVulnerability);
}
}
return new self(
$name,
$slug,
$type,
$vulnerabilities,
);
}
/**
* Return a storage/serialization-friendly representation of this component and its vulnerabilities.
*
* Nested vulnerabilities are converted using their own DTOs.
*
* @return array{
* name: string,
* slug: string,
* type: string,
* latestVersion: string|null,
* vulnerabilities: list<array<string, mixed>>
* }
*/
public function toArray(): array
{
$list = [];
foreach ($this->vulnerabilities as $vulnerability) {
$list[] = $vulnerability->toArray();
}
return [
'name' => $this->name,
'slug' => $this->slug,
'type' => $this->type,
'latestVersion' => $this->latestVersion,
'vulnerabilities' => $list,
];
}
/**
* Set the latest known available version for this component.
*
* Used to enrich the DTO with runtime update information.
*/
public function setLatestVersion(?string $latestVersion): void
{
$this->latestVersion = $latestVersion;
}
/**
* Get a unique storage key for this component.
*
* Combines type and slug for indexing.
*/
public function getStorageKey(): string
{
return $this->type . ':' . $this->slug;
}
/**
* Update the component type.
*
* Used to enrich the DTO with runtime data.
*/
public function setType(string $type): void
{
$this->type = $type;
}
/**
* Update the component slug.
*
* Used to enrich the DTO with runtime data.
*/
public function setSlug(string $installedSlug): void
{
$this->slug = $installedSlug;
}
/**
* Update the component name.
*
* Used to enrich the DTO with runtime data.
*/
public function setName(string $installedName): void
{
$this->name = $installedName;
}
/**
* Set the currently installed version on the site.
*
* Used to enrich the DTO with runtime installation data.
*/
public function setInstalledVersion(string $installedVersion): void
{
$this->installedVersion = $installedVersion;
}
/**
* Set whether an update is available for the installed version.
*
* Used to enrich the DTO with runtime update status.
*/
public function setUpdateAvailable(?bool $updateAvailable): void
{
$this->updateAvailable = $updateAvailable;
}
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos;
/**
* Immutable data transfer object representing the highest-severity vulnerability
* for a specific installed component.
*
* This DTO combines vulnerability data with local runtime context, such as:
* - Which component is affected (plugin, theme, or core).
* - The highest detected severity for that component.
* - Whether an update is available for the installed version.
*
* It is primarily used to:
* - Drive admin UI displays (tables, dashboards).
* - Provide structured input for notification and policy decisions.
*
* This object contains data only:
* - No persistence logic
* - No policy or decision logic
* - No WordPress hooks or scheduling concerns
*/
final class HighestSeverityContextDto
{
/**
* Normalized severity label (`low`, `medium`, `high`, `critical`).
*
* @var string
*/
public string $severity;
/**
* Vulnerability identifier used for correlation and lookups.
*
* @var string
*/
public string $lookup;
/**
* Component slug used to match installed components.
*
* @var string
*/
public string $slug;
/**
* Component type (`plugin`, `theme`, or `core`).
*
* @var string
*/
public string $type;
/**
* Human-readable component name.
*
* @var string
*/
public string $name;
/**
* Latest known available version for this component.
*
* @var string
*/
public string $latestVersion;
/**
* Whether an update is currently available.
*
* @var bool
*/
public bool $hasUpdate;
/**
* Raw vulnerability payload as received from storage/API.
*
* @var array<string, mixed>
*/
public array $vulnerability;
/**
* Constructor.
*
* @param string $severity Normalized severity label (`low`, `medium`, `high`, `critical`).
* @param string $lookup Vulnerability identifier used for correlation and lookups.
* @param string $slug Component slug used to match installed components.
* @param string $type Component type (`plugin`, `theme`, or `core`).
* @param string $name Human-readable component name.
* @param string $latestVersion Latest known available version for this component.
* @param bool $hasUpdate Whether an update is currently available.
* @param array<string, mixed> $vulnerability Raw vulnerability payload as received from storage/API.
*/
public function __construct(
string $severity,
string $lookup,
string $slug,
string $type,
string $name,
string $latestVersion,
bool $hasUpdate,
array $vulnerability
) {
$this->vulnerability = $vulnerability;
$this->hasUpdate = $hasUpdate;
$this->latestVersion = $latestVersion;
$this->name = $name;
$this->type = $type;
$this->slug = $slug;
$this->lookup = $lookup;
$this->severity = $severity;
}
/**
* Return a new instance with an updated hasUpdate flag.
*
* The original instance is not modified (immutability).
*
* @param bool $hasUpdate New hasUpdate flag value.
* @return self New instance with updated hasUpdate.
*/
public function withHasUpdate(bool $hasUpdate): self
{
return new self(
$this->severity,
$this->lookup,
$this->slug,
$this->type,
$this->name,
$this->latestVersion,
$hasUpdate,
$this->vulnerability
);
}
/**
* Convert this DTO to an array.
*
* The array shape is intended for UI rendering or serialization.
* The returned structure is stable and explicit.
*
* @return array{
* severity: string,
* lookup: string,
* slug: string,
* type: string,
* name: string,
* latestVersion: string,
* hasUpdate: bool,
* vulnerability: array<string, mixed>
* }
*/
public function toArray(): array
{
return [
'severity' => $this->severity,
'lookup' => $this->lookup,
'slug' => $this->slug,
'type' => $this->type,
'name' => $this->name,
'latestVersion' => $this->latestVersion,
'hasUpdate' => $this->hasUpdate,
'vulnerability' => $this->vulnerability,
];
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos;
/**
* Immutable data transfer object representing a locally installed WordPress component.
*
* A component can be a plugin, theme, or WordPress core. This DTO captures the
* *runtime installation state* of that component and is primarily used to:
* - Enrich vulnerability data with local context (installed version, update availability).
* - Decide whether actions such as updates or notifications are relevant.
*
* This object contains state only:
* - No persistence logic
* - No vulnerability or policy logic
* - No WordPress hook or scheduling concerns
*/
final class InstalledComponentDto
{
/**
* @var string Component type (`plugin`, `theme`, or `core`)
*/
private string $type;
/**
* @var string Unique identifier used to match vulnerabilities
*/
public string $slug;
/**
* @var string Human-readable component name
*/
private string $name;
/**
* @var string Currently installed version
*/
private string $installedVersion;
/**
* @var string|null Latest available version (null if unknown)
*/
private ?string $latestVersion;
/**
* @var string Plugin file path (empty for themes/core)
*/
private string $file;
/**
* @var bool Whether the component is currently active
*/
private bool $isActive;
/**
* InstalledComponentDto constructor.
*
* @param string $type The type of the component (plugin, theme, or core)
* @param string $slug Unique identifier for matching vulnerabilities
* @param string $name Human-readable name of the component
* @param string $version Currently installed version of the component
* @param string|null $pluginFile Plugin file path, or null if not applicable
* @param bool $isActive Whether the component is currently active
* @param string|null $latestVersion Latest available version, or null if unknown
*/
public function __construct(
string $type,
string $slug,
string $name,
string $version,
?string $pluginFile,
bool $isActive,
?string $latestVersion
) {
$this->type = $type;
$this->slug = $slug;
$this->name = $name;
$this->installedVersion = $version;
$this->file = $pluginFile ?? '';
$this->isActive = $isActive;
$this->latestVersion = $latestVersion;
}
/**
* Get the component type.
*/
public function getType(): string
{
return $this->type;
}
/**
* Get the unique slug identifier.
*/
public function getSlug(): string
{
return $this->slug;
}
/**
* Get the human-readable component name.
*/
public function getName(): string
{
return $this->name;
}
/**
* Get the installed version of the component.
*/
public function getInstalledVersion(): string
{
return $this->installedVersion;
}
/**
* Get the latest available version of the component, or null if unknown.
*/
public function getLatestVersion(): ?string
{
return $this->latestVersion;
}
/**
* Determine if an update is available by comparing installed and latest versions.
*/
public function hasUpdate(): bool
{
if ($this->latestVersion === null) {
return false;
}
return version_compare(
$this->installedVersion,
$this->latestVersion,
'<'
);
}
/**
* Get the plugin file path (empty string if not applicable).
*/
public function getFile(): string
{
return $this->file;
}
/**
* Check if the component is currently active.
*/
public function isActive(): bool
{
return $this->isActive;
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Immutable data transfer object representing a vulnerable version range.
*
* A vulnerability range describes *which versions* of a component are affected
* by a specific vulnerability and how that range should be interpreted.
*
* This DTO is used to:
* - Represent vulnerability range data received from external APIs.
* - Transport normalized range metadata through the application.
* - Serialize range data for storage or further processing.
*
* This object contains data only:
* - No business logic
* - No persistence logic
* - No version comparison logic
*/
final class VulnerabilityRangeDto
{
/**
* Identifier used to match this range to a specific component/version set
*/
private string $lookup;
/**
* Severity label associated with this range
*/
private string $severity;
/**
* Publication date of the vulnerability (raw API value)
*/
private string $publishedAt;
/**
* Whether the vulnerability is fixed within this range
*/
private bool $fixedIn;
/**
* Lower bound of the affected version range
*/
private string $versionFrom;
/**
* Upper bound of the affected version range
*/
private string $versionTo;
/**
* Whether the lower bound is inclusive
*/
private bool $fromInclusive;
/**
* Whether the upper bound is inclusive
*/
private bool $toInclusive;
public function __construct(
string $lookup,
string $severity,
string $publishedAt,
bool $fixedIn,
string $versionFrom,
string $versionTo,
bool $fromInclusive,
bool $toInclusive
) {
$this->toInclusive = $toInclusive;
$this->fromInclusive = $fromInclusive;
$this->versionTo = $versionTo;
$this->versionFrom = $versionFrom;
$this->fixedIn = $fixedIn;
$this->publishedAt = $publishedAt;
$this->severity = $severity;
$this->lookup = $lookup;
}
/**
* Builds a range DTO from a raw API payload.
*
* This method normalizes raw API payloads into a strongly typed DTO.
* Missing or malformed values are handled defensively via the Storage helper.
*
* @param array<string, mixed> $payload
*/
public static function fromApiArray(array $payload): self
{
$storage = new Storage($payload);
return new self(
$storage->getString('lookup'),
$storage->getString('severity'),
$storage->getString('published_at'),
$storage->getBoolean('fixed_in'),
$storage->getString('version_from'),
$storage->getString('version_to'),
$storage->getBoolean('from_inclusive'),
$storage->getBoolean('to_inclusive'),
);
}
/**
* @return array{
* lookup: string,
* severity: string,
* published_at: string,
* fixed_in: bool,
* version_from: string,
* version_to: string,
* from_inclusive: bool,
* to_inclusive: bool
* }
*/
public function toArray(): array
{
return [
'lookup' => $this->lookup,
'severity' => $this->severity,
'published_at' => $this->publishedAt,
'fixed_in' => $this->fixedIn,
'version_from' => $this->versionFrom,
'version_to' => $this->versionTo,
'from_inclusive' => $this->fromInclusive,
'to_inclusive' => $this->toInclusive,
];
}
/**
* Returns the severity label associated with this range.
*/
public function getSeverity(): string
{
return $this->severity;
}
/**
* Returns the identifier used to match this range to a specific component/version set.
*/
public function getLookup(): string
{
return $this->lookup;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Immutable data transfer object representing a vulnerability snapshot summary.
*
* A snapshot captures the aggregated vulnerability state of the system at a
* specific point in time. It is used to:
* - Compare the current state against a previously stored snapshot.
* - Decide whether notifications should be sent (via policy logic).
* - Persist historical state for idempotent scheduled runs.
*
* This DTO contains data only:
* - No business logic
* - No persistence logic
* - No scheduling concerns
*/
final class VulnerabilitySnapshotDto
{
/**
* Highest severity label present in the snapshot (or null if none).
*/
private ?string $highestSeverity;
/**
* Normalized numeric severity score used for comparisons.
*/
private int $severityScore;
/**
* Total number of vulnerable components.
*/
private int $vulnerableCount;
/**
* Number of vulnerable components with updates available.
*/
private int $updatableComponents;
/**
* UNIX timestamp when the snapshot was generated.
*/
private int $generatedAt;
public function __construct(
?string $highestSeverity,
int $severityScore,
int $vulnerableCount,
int $updatableComponents,
int $generatedAt
) {
$this->highestSeverity = $highestSeverity;
$this->severityScore = $severityScore;
$this->vulnerableCount = $vulnerableCount;
$this->updatableComponents = $updatableComponents;
$this->generatedAt = $generatedAt;
}
/**
* Returns the highest severity label present in the snapshot (or null if none).
*/
public function getHighestSeverity(): ?string
{
return $this->highestSeverity;
}
/**
* Returns the normalized numeric severity score used for comparisons.
*/
public function getSeverityScore(): int
{
return $this->severityScore;
}
/**
* Returns the total number of vulnerable components.
*/
public function getVulnerableCount(): int
{
return $this->vulnerableCount;
}
/**
* Returns the number of vulnerable components with updates available.
*/
public function getUpdatableComponents(): int
{
return $this->updatableComponents;
}
/**
* Returns the UNIX timestamp when the snapshot was generated.
*/
public function getGeneratedAt(): int
{
return $this->generatedAt;
}
/**
* Returns an array representation of the snapshot intended for persistence (options/storage).
*
* The array shape is stable and versioned implicitly by the DTO.
*
* @return array{
* highestSeverity: ?string,
* severityScore: int,
* vulnerableCount: int,
* updatableComponents: int,
* generatedAt: int
* }
*/
public function toArray(): array
{
return [
'highestSeverity' => $this->highestSeverity,
'severityScore' => $this->severityScore,
'vulnerableCount' => $this->vulnerableCount,
'updatableComponents' => $this->updatableComponents,
'generatedAt' => $this->generatedAt,
];
}
/**
* Rebuilds a snapshot DTO from persisted storage.
*
* Missing keys are handled defensively via the Storage helper.
*
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
$storage = new Storage($data);
return new self(
$storage->getString('highestSeverity'),
$storage->getInt('severityScore'),
$storage->getInt('vulnerableCount'),
$storage->getInt('updatableComponents'),
$storage->getInt('generatedAt'),
);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\ComponentVulnerabilitiesDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
/**
* Strategy contract for synchronizing vulnerability data for a specific component type.
*
* In Strategy terms: this interface defines *how* vulnerability data is synchronized
* for a given component type (enumeration, fetching, and mapping). Multiple concrete
* implementations are interchangeable and selected by a coordinating service based
* on the component type.
*
* This is explicitly not a Policy. Implementations do not decide *whether* or *when*
* a synchronization should occur, nor do they make notification or severity decisions.
* Those concerns are handled by higher-level orchestration or policy components.
*
* A component can be a plugin, theme, or WordPress core. Each implementation of this
* interface is responsible for handling exactly one component type.
*
* The typical lifecycle for a sync strategy is:
* 1. Enumerate installed components of its type.
* 2. Fetch remote vulnerability data for each installed component.
* 3. Map the API response into a ComponentVulnerabilitiesDto for storage.
*/
interface ComponentSyncStrategyInterface
{
/**
* Return the component type handled by this strategy.
*
* This value is used to:
* - Route API calls to the correct endpoint.
* - Tag stored vulnerability data with the correct component type.
* - Produce meaningful error and log messages.
*
* Examples: "plugin", "theme", "core".
*
* @return non-empty-string
*/
public function getType(): string;
/**
* Retrieve all installed components of the handled type.
*
* Each returned InstalledComponentDto represents a single installed
* component (for example, one plugin or one theme) including runtime
* information such as slug, name, and installed version.
*
* @return iterable<InstalledComponentDto>
*/
public function getInstalledComponents(): iterable;
/**
* Fetch raw vulnerability data for a single installed component.
*
* Implementations are expected to:
* - Extract the component identifier (such as slug and name).
* - Call the appropriate remote vulnerability API.
* - Return a standardized success/error payload that the sync service
* can process consistently.
*
* @return array{success: bool, data?: array<string, mixed>, message?: string}
*/
public function fetchComponent(InstalledComponentDto $installedComponentDto): array;
/**
* Convert a raw API payload into a ComponentVulnerabilitiesDto.
*
* This method maps external API data to the internal storage format and
* enriches it with runtime information from the installed component
* (such as slug, name, installed version, and update availability).
*
* @param array<string, mixed> $apiPayload Raw vulnerability API response.
*/
public function toComponentDto(array $apiPayload, InstalledComponentDto $installedComponentDto): ComponentVulnerabilitiesDto;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces;
/**
* Defines the read-only contract for an installed component representation.
*
* Constraints: Exposes component state only; no persistence or business logic.
*/
interface InstalledComponentInterface
{
/**
* @return non-empty-string
*/
public function getType(): string;
/**
* @return non-empty-string
*/
public function getSlug(): string;
/**
* @return non-empty-string
*/
public function getName(): string;
/**
* @return non-empty-string
*/
public function getInstalledVersion(): string;
/**
* @return non-empty-string|null
*/
public function getLatestVersion(): ?string;
public function hasUpdate(): bool;
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
/**
* Defines read-only access to locally installed components.
*
* Constraints: Exposes installed component state only; no persistence or business logic.
* Repositories return materialized collections intended for direct consumption by controllers and services.
*/
interface InstalledComponentRepositoryInterface
{
/**
* Return all installed plugins as a materialized list of InstalledComponentDto objects.
*
* Each DTO includes:
* - type: "plugin|theme|core"
* - slug and display name
* - installed version
* - plugin file (used by WP to identify a plugin)
* - whether the plugin is currently active (including network-active on multisite)
* - latest available version (when WordPress update information is available)
*
* Repositories may use generators internally, but the public contract always returns a materialized list.
*
* @return list<InstalledComponentDto>
*/
public function getInstalledComponents(): array;
}

View File

@@ -0,0 +1,30 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\VulnerabilitySnapshotDto;
/**
* Defines the decision contract for sending vulnerability notification emails.
*
* Implementations encapsulate notification policy rules without performing delivery.
*/
interface VulnerabilityNotificationPolicyInterface
{
/**
* Determines whether a notification should be sent for the current snapshot.
*/
public function shouldSend(VulnerabilitySnapshotDto $current, ?VulnerabilitySnapshotDto $previous): bool;
/**
* Determines whether notifications may be sent in the current execution context.
*/
public function canSend(): bool;
/**
* Returns the reason for the last send decision, intended for logging or debugging.
*
* @return non-empty-string
*/
public function getReason(): string;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\VulnerabilitySnapshotDto;
/**
* Defines persistence operations for vulnerability snapshot state.
*
* Constraints: Read/write access only; no business logic.
*/
interface VulnerabilitySnapshotRepositoryInterface
{
/**
* Returns the previously stored vulnerability snapshot, if any.
*/
public function getPrevious(): ?VulnerabilitySnapshotDto;
/**
* Persists the current vulnerability snapshot.
*/
public function saveCurrent(VulnerabilitySnapshotDto $vulnerabilitySnapshotDto): void;
/**
* Removes any stored snapshot data.
*/
public function clear(): void;
}

View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\InstalledComponentRepositoryInterface;
/**
* Read-only repository for WordPress core.
*
* Purpose:
* - Exposes the installed WordPress core as an InstalledComponentDto.
* - Provides the installed version and (when available) the latest version from
* WordPress core update information.
*
* This repository does not perform remote calls and does not persist anything.
* It only translates WordPress runtime state into a predictable DTO format.
*/
final class CoreRepository implements InstalledComponentRepositoryInterface
{
/**
* Return WordPress core as a list of InstalledComponentDto objects.
*
* The returned list contains a single DTO representing the installed WordPress core and includes:
* - type: "core"
* - slug: "wordpress"
* - name: "WordPress"
* - installed version
* - active flag: always true (core is always active)
* - latest available version (when WordPress update information is available)
*
* @return list<InstalledComponentDto>
*/
public function getInstalledComponents(): array
{
$core = $this->getNormalizedCore();
return [
new InstalledComponentDto(
'core',
$core['slug'],
$core['name'],
$core['version'],
'', // No "plugin file" equivalent for core.
true,
$core['latestVersion']
),
];
}
/**
* Build a normalized representation of the installed WordPress core.
*
* WordPress core update information is stored in a site transient. When it
* is present, we extract the latest available version from it.
*
* @return array{
* slug: string,
* name: string,
* version: string,
* latestVersion: ?string
* }
*/
private function getNormalizedCore(): array
{
$installedVersion = $this->getInstalledCoreVersion();
$latestVersion = $this->getLatestCoreVersionFromUpdates();
return [
'slug' => 'wordpress',
'name' => 'WordPress',
'version' => $installedVersion,
'latestVersion' => $latestVersion,
];
}
/**
* Get the installed WordPress version.
*
* @return string Non-empty version string when available, otherwise empty string.
*/
private function getInstalledCoreVersion(): string
{
if (function_exists('get_bloginfo')) {
$version = get_bloginfo('version');
return is_string($version) ? $version : '';
}
// Fallback for unusual execution contexts.
/** @var string|null $wp_version */
global $wp_version;
return is_string($wp_version) ? $wp_version : '';
}
/**
* Determine the latest available WordPress core version from update information.
*
* WordPress stores available core updates in a site transient. The transient
* contains an object with an `updates` list of update items.
*
* @return string|null Latest available version, or null when not available.
*/
private function getLatestCoreVersionFromUpdates(): ?string
{
if (!function_exists('get_core_updates')) {
require_once ABSPATH . 'wp-admin/includes/update.php';
}
$updates = get_core_updates();
if (!is_array($updates)) {
return null;
}
$latest = null;
foreach ($updates as $update) {
if (!is_object($update) || empty($update->current) || !is_string($update->current)) {
continue;
}
$candidate = $update->current;
if ($latest === null || version_compare($candidate, $latest, '>')) {
$latest = $candidate;
}
}
return $latest;
}
}

View File

@@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\InstalledComponentRepositoryInterface;
/**
* Read-only repository for installed WordPress plugins.
*
* Purpose:
* - Enumerates installed plugins via WordPress APIs.
* - Normalizes plugin metadata (slug, name, versions, active state).
* - Exposes each plugin as an InstalledComponentDto for the vulnerability sync layer.
*
* This repository does not perform remote calls and does not persist anything.
* It only translates WordPress runtime state into a predictable DTO format.
*/
final class PluginRepository implements InstalledComponentRepositoryInterface
{
/**
* Return all installed plugins as InstalledComponentDto objects.
*
* Each DTO in the returned list represents one installed plugin and includes:
* - type: "plugin"
* - slug and display name
* - installed version
* - plugin file path (WordPress identifier)
* - whether the plugin is active (including network-active on multisite)
* - latest available version (when WordPress update information is available)
*
* @return list<InstalledComponentDto>
*/
public function getInstalledComponents(): array
{
$components = [];
foreach ($this->getNormalizedPlugins() as $normalizedPlugin) {
$data = is_array($normalizedPlugin) ? $normalizedPlugin : (array) $normalizedPlugin;
$components[] = new InstalledComponentDto(
'plugin',
$data['slug'],
$data['name'],
$data['version'],
$data['pluginFile'],
$data['isActive'],
$data['latestVersion']
);
}
return $components;
}
/**
* Build a normalized list of installed plugins from WordPress plugin APIs.
*
* WordPress returns plugin information in a fairly loose array format.
* This method validates and normalizes the fields we need so downstream code
* can rely on consistent keys and types.
*
* @return iterable<array{
* pluginFile: string,
* slug: string,
* name: string,
* version: string,
* isActive: bool,
* latestVersion: ?string
* }>
*/
private function getNormalizedPlugins(): iterable
{
$this->ensurePluginsApiLoaded();
/** @var array<string, array<string, mixed>> $plugins */
$plugins = get_plugins();
/** @var list<string> $activePlugins */
$activePlugins = (array) get_option('active_plugins', []);
$updates = get_plugin_updates();
if (!is_array($updates)) {
$updates = [];
}
foreach ($plugins as $pluginFile => $pluginData) {
$slug = $this->deriveSlugFromPluginFile($pluginFile);
$name = isset($pluginData['Name']) && is_string($pluginData['Name'])
? $pluginData['Name']
: $slug;
$version = isset($pluginData['Version']) && is_string($pluginData['Version'])
? $pluginData['Version']
: '';
$latestVersion = null;
if (isset($updates[$pluginFile]) && is_object($updates[$pluginFile]) && isset($updates[$pluginFile]->update)) {
$update = $updates[$pluginFile]->update;
if (is_array($update) && isset($update['new_version']) && is_string($update['new_version'])) {
$latestVersion = $update['new_version'];
} elseif (is_object($update) && isset($update->new_version) && is_string($update->new_version)) {
$latestVersion = $update->new_version;
}
}
$isActive = in_array($pluginFile, $activePlugins, true);
// Also treat network-activated plugins as active.
if (function_exists('is_plugin_active_for_network') && is_multisite()) {
$isActive = $isActive || is_plugin_active_for_network($pluginFile);
}
yield [
'pluginFile' => $pluginFile,
'slug' => $slug,
'name' => $name,
'version' => $version,
'isActive' => $isActive,
'latestVersion' => $latestVersion,
];
}
}
/**
* Check whether WordPress currently offers an update for a plugin.
*
* We rely on the `update_plugins` site transient, which is refreshed by
* `wp_update_plugins()`.
*
* @param string $pluginFile Plugin basename (e.g. my-plugin/my-plugin.php).
*/
public function hasPluginUpdateAvailable(string $pluginFile): bool
{
$updates = get_site_transient('update_plugins');
if (! is_object($updates) || ! isset($updates->response) || ! is_array($updates->response)) {
return false;
}
return array_key_exists($pluginFile, $updates->response);
}
/**
* Read the currently installed plugin version from WordPress.
*
* Uses `get_plugins()` from wp-admin/includes/plugin.php.
*
* @param string $pluginFile Plugin basename.
*/
public function getPluginVersion(string $pluginFile): string
{
if (! function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
if (! is_array($plugins) || ! isset($plugins[$pluginFile]) || ! is_array($plugins[$pluginFile])) {
return '';
}
$version = $plugins[$pluginFile]['Version'] ?? '';
return is_string($version) ? $version : '';
}
/**
* Ensure the WordPress plugin API functions are available.
*
* Some execution paths may run before wp-admin includes are loaded.
* This makes get_plugins() available in a defensive way.
*/
private function ensurePluginsApiLoaded(): void
{
if (!function_exists('get_plugins')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
if (!function_exists('get_plugin_updates')) {
require_once ABSPATH . 'wp-admin/includes/update.php';
}
}
/**
* Derive a WordPress.org-style plugin slug from a plugin file path.
*
* Rules:
* - If the plugin lives in a directory, use that directory name as slug.
* - If the plugin is a single file in the plugins root, use the filename without ".php".
* - Always normalize to lowercase to keep slugs predictable.
*
* Example:
* - "contact-form-7/wp-contact-form-7.php" -> "contact-form-7"
*/
private function deriveSlugFromPluginFile(string $pluginFile): string
{
$directory = dirname($pluginFile);
$slug = $directory !== '.' && $directory !== '/' ? $directory : basename($pluginFile, '.php');
// Normalize to lowercase for WordPress.org-style slugs.
return strtolower($slug);
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\InstalledComponentRepositoryInterface;
/**
* Read-only repository for installed WordPress themes.
*
* Purpose:
* - Enumerates installed themes via WordPress APIs.
* - Normalizes theme metadata (slug, name, versions, active state).
* - Exposes each theme as an InstalledComponentDto for the vulnerability sync layer.
*
* This repository does not perform remote calls and does not persist anything.
* It only translates WordPress runtime state into a predictable DTO format.
*/
final class ThemeRepository implements InstalledComponentRepositoryInterface
{
/**
* Returns a materialized list of installed themes.
*
* @return list<InstalledComponentDto>
*/
public function getInstalledComponents(): array
{
$components = [];
foreach ($this->getNormalizedThemes() as $normalizedTheme) {
$components[] = new InstalledComponentDto(
'theme',
$normalizedTheme['slug'],
$normalizedTheme['name'],
$normalizedTheme['version'],
'',
$normalizedTheme['isActive'],
$normalizedTheme['latestVersion']
);
}
return $components;
}
/**
* Check whether WordPress currently offers an update for a theme.
*
* We rely on the `update_themes` site transient, which is refreshed by
* `wp_update_themes()`.
*
* @param string $stylesheet Theme stylesheet slug.
*/
public function hasThemeUpdateAvailable(string $stylesheet): bool
{
$updates = get_site_transient('update_themes');
if (! is_object($updates) || ! isset($updates->response) || ! is_array($updates->response)) {
return false;
}
return array_key_exists($stylesheet, $updates->response);
}
/**
* Read the currently installed theme version from WordPress.
*
* Uses `wp_get_theme($stylesheet)`.
*
* @param string $stylesheet Theme stylesheet slug.
*/
public function getThemeVersion(string $stylesheet): string
{
$theme = wp_get_theme($stylesheet);
if (! $theme->exists()) {
return '';
}
$version = $theme->get('Version');
return is_string($version) ? $version : '';
}
/**
* Build a normalized list of installed themes from WordPress theme APIs.
*
* Notes:
* - In WordPress, the stylesheet directory name acts as the theme identifier (slug).
* - A child theme is identified by stylesheet; the parent theme by template.
* - Update information is taken from the update_themes site transient when available.
*
* @return iterable<array{
* slug: string,
* name: string,
* version: string,
* isActive: bool,
* latestVersion: ?string
* }>
*/
private function getNormalizedThemes(): iterable
{
$this->ensureThemesApiLoaded();
/** @var array<string, WP_Theme> $themes */
$themes = wp_get_themes();
$activeTheme = wp_get_theme();
$activeStylesheet = (string) $activeTheme->get_stylesheet();
$activeTemplate = (string) $activeTheme->get_template();
foreach ($themes as $stylesheet => $theme) {
// In WordPress, the stylesheet directory name is the theme slug.
$slug = sanitize_key((string) $stylesheet);
$name = (string) $theme->get('Name');
$version = (string) $theme->get('Version');
// Child theme is identified by stylesheet; parent by template.
$isActive = $this->isThemeActive($stylesheet, $activeStylesheet, $activeTemplate);
$latestVersion = null;
$updates = get_theme_updates();
if (is_array($updates) && isset($updates[$stylesheet]) && is_object($updates[$stylesheet])) {
if (isset($updates[$stylesheet]->update['new_version']) && is_string($updates[$stylesheet]->update['new_version'])) {
$latestVersion = $updates[$stylesheet]->update['new_version'];
}
}
yield [
'slug' => $slug,
'name' => $name,
'version' => $version,
'isActive' => $isActive,
'latestVersion' => $latestVersion,
];
}
}
/**
* Ensure the WordPress theme API functions are available.
*
* Some execution paths may run before wp-admin includes are loaded.
* This makes wp_get_themes() available in a defensive way.
*/
private function ensureThemesApiLoaded(): void
{
// Defensive load for early/edge WP execution paths.
if (!function_exists('wp_get_themes')) {
require_once ABSPATH . 'wp-admin/includes/theme.php';
}
if (!function_exists('get_theme_updates')) {
require_once ABSPATH . 'wp-admin/includes/update.php';
}
}
/**
* Determine whether a theme is currently active.
*
* WordPress identifies the active child theme by stylesheet and the
* parent theme by template, so we check both.
*/
private function isThemeActive(string $stylesheet, string $activeStylesheet, string $activeTemplate): bool
{
return ($stylesheet === $activeStylesheet || $stylesheet === $activeTemplate);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\VulnerabilitySnapshotDto;
/**
* Persists and reads the latest vulnerability snapshot from WordPress option storage.
*
* Constraints: No external I/O and no business rules beyond basic shape validation.
*/
final class VulnerabilitySnapshotRepository
{
private const OPTION_KEY = 'rsssl_vulnerability_snapshot';
public function getLatest(): ?VulnerabilitySnapshotDto
{
$raw = get_option(self::OPTION_KEY, null);
if (!is_array($raw)) {
return null;
}
return VulnerabilitySnapshotDto::fromArray($raw);
}
public function storeLatest(VulnerabilitySnapshotDto $vulnerabilitySnapshotDto): void
{
$this->save($vulnerabilitySnapshotDto);
}
public function save(VulnerabilitySnapshotDto $vulnerabilitySnapshotDto): void
{
update_option(self::OPTION_KEY, $vulnerabilitySnapshotDto->toArray(), false);
}
public function clear(): void
{
delete_option(self::OPTION_KEY);
}
/**
* Builds a snapshot from the repository's aggregated snapshot data.
*/
public function buildCurrentSnapshot(VulnerabilityStorageRepository $vulnerabilityStorageRepository): VulnerabilitySnapshotDto
{
$data = $vulnerabilityStorageRepository->getSnapshotData();
$updatableComponents = (int)($data['updatableComponents'] ?? 0);
$vulnerableCount = (int)($data['vulnerablePlugins'] ?? 0);
$highestSeverity = isset($data['highestSeverity']) && is_string($data['highestSeverity'])
? $data['highestSeverity']
: null;
$severityScore = (int)($data['severityScore'] ?? 0);
return new VulnerabilitySnapshotDto(
$highestSeverity,
$severityScore,
$vulnerableCount,
$updatableComponents,
time()
);
}
public function buildFromStorage(
VulnerabilityStorageRepository $vulnerabilityStorageRepository
): VulnerabilitySnapshotDto {
return $this->buildCurrentSnapshot($vulnerabilityStorageRepository);
}
}

View File

@@ -0,0 +1,530 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\ComponentVulnerabilitiesDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\HighestSeverityContextDto;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Policies\ConfigurableSeverityPolicy;
/**
* Persist and read vulnerability data using the WordPress options API.
*
* Responsibilities:
* - Store and retrieve component vulnerability payloads keyed by a storage key
* (e.g. "plugin:contact-form-7" or "theme:twentytwenty").
* - Provide aggregation helpers (highest severity, counts, snapshot).
*
* Constraints:
* - No external I/O (network, files) beyond WordPress options.
* - No business rules beyond simple aggregation and shaping for consumers.
*
* Notes for maintainers:
* - Stored component format is an array with keys such as: `type`, `slug`,
* `name`, `latestVersion`, `hasUpdate`, and `vulnerabilities` \(an array\).
* - `vulnerabilities` is an array of associative arrays where each entry
* typically includes at minimum `severity` and `lookup` (UUID).
*/
final class VulnerabilityStorageRepository
{
private const OPTION_KEY = 'rss_vulnerabilities';
/**
* Store or replace vulnerabilities for a single component.
*
* The DTO is converted to an array and stored under the DTO's storage key.
* This replaces any existing entry for that key.
*
* Example storage key: "plugin:contact-form-7"
*
* @param ComponentVulnerabilitiesDto $componentVulnerabilitiesDto DTO providing `getStorageKey()` and `toArray()`
*
* @return void
*/
public function saveComponent(ComponentVulnerabilitiesDto $componentVulnerabilitiesDto): void
{
$all = $this->getAllRaw();
$all[$componentVulnerabilitiesDto->getStorageKey()] = $componentVulnerabilitiesDto->toArray();
update_option(self::OPTION_KEY, $all, false);
}
/**
* Return the raw stored vulnerability map from the WordPress option.
*
* Returned shape: array<string, array<string, mixed>>
* - Key is the storage key (e.g. "plugin:slug")
* - Value is the component array as stored by `saveComponent`
*
* If the option is malformed (not an array) an empty array is returned.
*
* @return array<string, array<string, mixed>> Map of storage keys to component arrays
*/
public function getAllRaw(): array
{
$stored = get_option(self::OPTION_KEY, []);
if (!is_array($stored)) {
return [];
}
return $stored;
}
/**
* Determine the highest severity vulnerability for a component.
*
* Behavior:
* - Looks up the component by its storage key (e.g. "plugin:{slug}").
* - Returns null when the component does not exist or has no vulnerabilities.
* - Selects the highest-ranked vulnerability based on severity ranking.
*
* The returned DTO contains both component context and the selected
* vulnerability payload.
*
* @param non-empty-string $type Component type (e.g. "plugin", "theme", "core").
* @param non-empty-string $slug Component slug.
*
* @return HighestSeverityContextDto|null
*/
public function getHighestSeverityForComponent(string $type, string $slug): ?HighestSeverityContextDto
{
$all = $this->getAllRaw();
$key = $this->buildStorageKey($type, $slug);
if (!isset($all[$key]) || !is_array($all[$key])) {
return null;
}
$component = $all[$key];
$vulnerabilities = $component['vulnerabilities'] ?? null;
if (!is_array($vulnerabilities) || $vulnerabilities === []) {
return null;
}
$highestVulnerability = $this->findHighestVulnerability($vulnerabilities);
if ($highestVulnerability === null) {
return null;
}
return $this->buildHighestSeverityContext($component, $highestVulnerability, $slug, $type);
}
/**
* Determine the highest severity vulnerability for a given plugin slug.
* Returns a structured array containing the component context and the
* highest severity vulnerability entry, including the UUID (lookup).
*
* @param string $slug
* @return HighestSeverityContextDto|null
*/
public function getHighestSeverityForPluginSlug(string $slug): ?HighestSeverityContextDto
{
return $this->getHighestSeverityForComponent('plugin', $slug);
}
/**
* Build the storage key for a component based on type and slug.
*
* @param string $type
* @param string $slug
* @return string
*/
private function buildStorageKey(string $type, string $slug): string
{
$type = strtolower(trim($type));
$slug = strtolower(trim($slug));
return $type . ':' . $slug;
}
/**
* Return the numeric rank for a given severity.
* Unknown severities return 0.
*
* @param $severity
* @return int
*/
private function severityRank($severity): int
{
if (!is_string($severity)) {
return 0;
}
$severity = strtolower(trim($severity));
return ConfigurableSeverityPolicy::SEVERITY_SCORES[$severity] ?? 0;
}
/**
* Determine the highest severity vulnerability for a given theme slug.
* Returns a structured array containing the component context and the
* highest severity vulnerability entry, including the UUID (lookup).
*
* @param string $slug
* @return HighestSeverityContextDto|null
*/
public function getHighestSeverityForThemeSlug(string $slug): ?HighestSeverityContextDto
{
return $this->getHighestSeverityForComponent('theme', $slug);
}
/**
* Determine the highest severity vulnerability for a given core slug.
*
* @param non-empty-string $slug Core slug (defaults to "wordpress").
*
* @return HighestSeverityContextDto|null
*/
public function getHighestSeverityForCoreSlug(string $slug = 'wordpress'): ?HighestSeverityContextDto
{
return $this->getHighestSeverityForComponent('core', $slug);
}
/**
* Remove vulnerabilities for a given component key.
*
* If the key does not exist this method is a no-op.
*
* @param string $storageKey For example "plugin:contact-form-7".
*
* @return void
*/
public function deleteComponent(string $storageKey): void
{
$all = $this->getAllRaw();
if (!array_key_exists($storageKey, $all)) {
return;
}
unset($all[$storageKey]);
update_option(self::OPTION_KEY, $all, false);
}
/**
* Remove all stored component vulnerabilities.
*
* This wipes the option to an empty array instead of deleting the option,
* which keeps the option present but empty.
*
* @return void
*/
public function deleteAllComponents(): void
{
update_option(self::OPTION_KEY, [], false);
}
/**
* Find the single highest severity vulnerability from the provided list.
*
* Behaviour:
* - Accepts an array of vulnerability items (associative arrays).
* - Uses `ConfigurableSeverityPolicy::SEVERITY_SCORES` to rank severities.
* - Returns the first encountered item of the highest rank.
* - Short-circuits and returns immediately if a `critical` is found.
*
* Expected vulnerability item shape (minimum):
* [
* 'severity' => 'high'|'critical'|...,
* 'lookup' => 'uuid',
* ...other fields...
* ]
*
* @param list<mixed> $vulnerabilities List of vulnerability items
*
* @return array<string, mixed>|null The selected vulnerability item, or null when none valid
*/
private function findHighestVulnerability(array $vulnerabilities): ?array
{
$highest = null;
$highestRank = 0;
foreach ($vulnerabilities as $vulnerability) {
if (!is_array($vulnerability)) {
continue;
}
$rank = $this->severityRank($vulnerability['severity'] ?? null);
if ($rank > $highestRank) {
$highestRank = $rank;
$highest = $vulnerability;
if ($highestRank === (ConfigurableSeverityPolicy::SEVERITY_SCORES['critical'] ?? 9)) {
return $highest;
}
}
}
return $highest;
}
/**
* Build a standardized context array for a highest-severity result.
*
* @param array<string, mixed> $component Raw stored component array
* @param array<string, mixed> $highestVulnerability The selected vulnerability item
* @param non-empty-string $fallbackSlug Fallback slug used if component has no `slug`
* @param non-empty-string $fallbackType Fallback type used if component has no `type`
*
* @return HighestSeverityContextDto The constructed DTO
*/
private function buildHighestSeverityContext(
array $component,
array $highestVulnerability,
string $fallbackSlug,
string $fallbackType
): HighestSeverityContextDto
{
$highestStorage = new Storage($highestVulnerability);
$componentStorage = new Storage($component);
return new HighestSeverityContextDto(
strtolower($highestStorage->getString('severity')),
$highestStorage->getString('lookup'),
$componentStorage->getString('slug', $fallbackSlug),
$componentStorage->getString('type', $fallbackType),
$componentStorage->getString('name'),
$componentStorage->getString('latestVersion'),
$componentStorage->getBoolean('hasUpdate'),
$highestVulnerability,
);
}
/**
* Return the numeric score for a textual severity.
*
* Unknown severities return 0.
*
* @param string $severity textual severity (case-insensitive)
*
* @return int numeric score from `ConfigurableSeverityPolicy::SEVERITY_SCORES` or 0
*/
public function getSeverityScore(string $severity): int
{
return $this->severityRank($severity);
}
/**
* Determine the highest severity level across all stored components.
*
* Behaviour:
* - Iterates all stored components and their vulnerabilities.
* - Uses `ConfigurableSeverityPolicy::SEVERITY_SCORES` to rank severities.
* - Returns the highest severity found as a string (low|medium|high|critical).
* - If no vulnerabilities exist, returns null.
*
* @return string|null Highest severity level found, or null when none exist
*/
public function getHighestSeverity(): ?string
{
$all = $this->getAllRaw();
if ($all === []) {
return null;
}
$highest = 0;
$highestSeverity = null;
foreach ($all as $component) {
if (!is_array($component)) {
continue;
}
$vulnerabilities = $component['vulnerabilities'] ?? null;
if (!is_array($vulnerabilities) || $vulnerabilities === []) {
continue;
}
$highestVulnerability = $this->findHighestVulnerability($vulnerabilities);
if ($highestVulnerability === null) {
continue;
}
$severity = strtolower((string)($highestVulnerability['severity'] ?? ''));
$rank = $this->severityRank($severity);
if ($rank > $highest) {
$highest = $rank;
$highestSeverity = $severity;
if ($highest === ConfigurableSeverityPolicy::SEVERITY_SCORES['critical']) {
return 'critical';
}
}
}
return $highestSeverity;
}
/**
* Return snapshot data useful for dashboards or metrics.
*
* Returned array shape:
* - highestSeverity: ?string (highest severity across components or null)
* - severityScore: int (numeric score for highestSeverity or 0)
* - vulnerablePlugins: int (count of components with non-empty `vulnerabilities`)
* - updatableComponents: int (count where `hasUpdate` is truthy)
*
* @return array{
* highestSeverity: ?string,
* severityScore: int,
* vulnerablePlugins: int,
* updatableComponents: int
* }
*/
public function getSnapshotData(): array
{
$all = $this->getAllRaw();
$highestSeverity = $this->getHighestSeverity();
$severityScore = $highestSeverity ? $this->getSeverityScore($highestSeverity) : 0;
$vulnerablePlugins = 0;
$updatableComponents = 0;
foreach ($all as $component) {
if (!is_array($component)) {
continue;
}
$vulnerabilities = $component['vulnerabilities'] ?? [];
if (is_array($vulnerabilities) && $vulnerabilities !== []) {
$vulnerablePlugins++;
}
if (($component['hasUpdate'] ?? false) === true) {
$updatableComponents++;
}
}
return [
'highestSeverity' => $highestSeverity,
'severityScore' => $severityScore,
'vulnerablePlugins' => $vulnerablePlugins,
'updatableComponents' => $updatableComponents,
];
}
/**
* Count vulnerabilities grouped by textual severity across all components.
*
* Returned shape: array<string, int> where key is lowercased severity.
*
* Example: ['critical' => 2, 'high' => 5]
*
* @return array<string, int>
*/
public function getVulnerabilityCountPerSeverity(): array
{
$components = $this->getAllRaw();
$counts = [];
foreach ($components as $component) {
$vulnerabilities = $component['vulnerabilities'] ?? null;
if (!is_array($vulnerabilities) || $vulnerabilities === []) {
continue;
}
foreach ($vulnerabilities as $vulnerability) {
if (!is_array($vulnerability)) {
continue;
}
$severity = strtolower((string)($vulnerability['severity'] ?? ''));
if ($severity === '') {
continue;
}
if (!isset($counts[$severity])) {
$counts[$severity] = 0;
}
$counts[$severity]++;
}
}
return $counts;
}
/**
* Count components grouped by their highest textual severity.
*
* Unlike {@see getVulnerabilityCountPerSeverity()}, this counts each component
* at most once, based on the single highest-severity vulnerability within that
* component.
*
* Returned shape: array<string, int> where key is lowercased severity.
*
* Example: ['critical' => 2, 'high' => 5]
*
* @return array<string, int>
*/
public function getComponentCountPerHighestSeverity(): array
{
$components = $this->getAllRaw();
$counts = [];
foreach ($components as $component) {
if (!is_array($component)) {
continue;
}
$vulnerabilities = $component['vulnerabilities'] ?? null;
if (!is_array($vulnerabilities) || $vulnerabilities === []) {
continue;
}
$highest = $this->findHighestVulnerability($vulnerabilities);
if ($highest === null) {
continue;
}
$severity = strtolower((string)($highest['severity'] ?? ''));
if ($severity === '') {
continue;
}
if (!isset($counts[$severity])) {
$counts[$severity] = 0;
}
$counts[$severity]++;
}
return $counts;
}
/**
* Fetch all components that have non-empty `vulnerabilities`.
*
* Returned map is the subset of `getAllRaw()` where `vulnerabilities` is present.
*
* @return array<string, array<string, mixed>> Map of storageKey => component array
*/
public function getAllVulnerableComponentsRaw(): array
{
$components = $this->getAllRaw();
$vulnerable = [];
foreach ($components as $key => $component) {
if (!is_array($component)) {
continue;
}
$vulnerabilities = $component['vulnerabilities'] ?? null;
if (!is_array($vulnerabilities) || $vulnerabilities === []) {
continue;
}
$vulnerable[$key] = $component;
}
return $vulnerable;
}
}

View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Policies;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\VulnerabilitySnapshotDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\VulnerabilityNotificationPolicyInterface;
use ReallySimplePlugins\RSS\Core\Services\EmailService;
/**
* Policy that decides whether a vulnerability notification email should be sent
* based on a configurable severity threshold.
*
* This class represents a *Policy* in the Policy/Strategy sense: it encapsulates
* a single, well-defined decision rule that can be evaluated independently of
* when or how notifications are sent.
*
* The decision is based on:
* - The highest severity present in the current vulnerability snapshot
* - A user-configured severity threshold
* - Changes compared to the previous snapshot (to avoid redundant notifications)
*
* Responsibilities:
* - Evaluate whether the current state meets or exceeds the configured threshold
* - Detect meaningful changes between snapshots that justify a new notification
* - Provide a human-readable reason explaining the decision
*
* This policy is intended to be composed by a higher-level notification or
* scheduling mechanism, not to perform any dispatching itself.
*/
final class ConfigurableSeverityPolicy implements VulnerabilityNotificationPolicyInterface
{
private EmailService $emailService;
/**
* Mapping of severity levels to numeric scores for comparison.
* @var array<string, int>
*/
public const SEVERITY_SCORES = [
'low' => 1,
'medium' => 2,
'high' => 3,
'critical' => 4,
];
/**
* @var string Reason for the last decision made by shouldSend().
*/
private string $reason = 'No decision yet';
/**
* Reason prefix for searching in error logs.
* @var string Prefix for the reason message.
*/
private string $reasonPrefix = 'RSS policy: ';
public function __construct(EmailService $emailService)
{
$this->emailService = $emailService;
}
/**
* Determine if a notification should be sent based on the current and previous snapshots.
*
* @param VulnerabilitySnapshotDto $current
* @param VulnerabilitySnapshotDto|null $previous
* @return bool Whether a notification should be sent based on the current and previous snapshots.
*/
public function shouldSend(VulnerabilitySnapshotDto $current, ?VulnerabilitySnapshotDto $previous): bool
{
$threshold = '';
$userWantsNotificationEmail = $this->emailService->isNotificationsEnabled();
if (!$userWantsNotificationEmail) {
$this->reason = 'User setting disables emails.';
return false;
}
if (function_exists('rsssl_get_option')) {
$threshold = (string) rsssl_get_option('vulnerability_notification_email_admin');
}
$threshold = strtolower($threshold);
if ($threshold === '*' || $threshold === '') {
$this->reason = 'User setting disables emails.';
return false;
}
if ($current->getHighestSeverity() === null) {
$this->reason = 'No vulnerabilities present.';
return false;
}
$thresholdScore = self::SEVERITY_SCORES[$threshold] ?? 0;
$currentScore = self::SEVERITY_SCORES[strtolower($current->getHighestSeverity())] ?? 0;
if ($currentScore < $thresholdScore) {
$this->reason = 'Highest severity below user threshold.';
return false;
}
if ($previous === null) {
$this->reason = 'First snapshot and highest severity meets/exceeds threshold.';
return true;
}
$previousGeneratedAt = $previous->getGeneratedAt();
$currentGeneratedAt = $current->getGeneratedAt();
$previousSnapshotOlderThanOneDay = (($currentGeneratedAt - $previousGeneratedAt) >= DAY_IN_SECONDS);
// Vulnerabilities still exist and no changes were made for one day, send notification
if ($previousSnapshotOlderThanOneDay) {
$this->reason = 'Previous snapshot is older than one day.';
return true;
}
$previousScore = 0;
if ($previous->getHighestSeverity() !== null) {
$previousScore = self::SEVERITY_SCORES[strtolower($previous->getHighestSeverity())] ?? 0;
}
if ($previousScore < $thresholdScore) {
$this->reason = 'Crossed threshold since previous snapshot.';
return true;
}
if ($this->hasMeaningfulChange($current, $previous)) {
$this->reason = 'Snapshot changed while still meeting/exceeding threshold.';
return true;
}
$this->reason = 'No meaningful change since previous snapshot.';
return false;
}
/**
* Determine if the notification can be sent based on admin login status.
* @return bool Whether the notification can be sent (e.g., admin is logged in).
*
* TODO: this is a temporary measure to prevent email sending until we have a more robust solution in place
* for handling email notifications. In the future, this should be replaced with a more flexible and testable
* approach that doesn't rely on admin login status.
*
* This differs because in specifically this case an admin needs to be logged in.
*/
public function canSend(): bool
{
if (!function_exists('rsssl_admin_logged_in')) {
return false;
}
$emailVerified = $this->emailService->isEmailVerified();
if (!$emailVerified) {
$this->reason = 'Email address not verified.';
return false;
}
return (bool) rsssl_admin_logged_in();
}
/**
* Get the reason for the last decision made by shouldSend().
*/
public function getReason(): string
{
return $this->reasonPrefix . $this->reason;
}
/**
* Check if there are meaningful changes between the current and previous snapshots.
* A meaningful change is defined as a change in highest severity, severity score,
* number of updatable components, or vulnerable count.
*
* @param VulnerabilitySnapshotDto $current
* @param VulnerabilitySnapshotDto $previous
* @return bool Whether there are meaningful changes.
*/
private function hasMeaningfulChange(VulnerabilitySnapshotDto $current, VulnerabilitySnapshotDto $previous): bool
{
return (
$current->getHighestSeverity() !== $previous->getHighestSeverity()
|| $current->getSeverityScore() !== $previous->getSeverityScore()
|| $current->getUpdatableComponents() !== $previous->getUpdatableComponents()
|| $current->getVulnerableCount() !== $previous->getVulnerableCount()
);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\ComponentVulnerabilitiesDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\ComponentSyncStrategyInterface;
/**
* Abstract base for component sync *Strategies*.
*
* This class provides shared mechanics for concrete sync strategies (plugin, theme, core),
* such as validation, API delegation, and DTO mapping.
*
* Strategy context:
* - Defines *how* vulnerability data is synchronized for a component type.
* - Concrete subclasses are interchangeable and selected by an orchestrator.
*
* Not a Policy:
* - This class does not decide *whether* or *when* a sync should run.
* - It does not apply severity thresholds or notification rules.
* - Those concerns belong to policies and higher-level scheduling/orchestration.
*
* Responsibilities:
* - Validate installed component identifiers
* - Delegate component-specific API fetching
* - Map API payloads into ComponentVulnerabilitiesDto
*/
abstract class AbstractComponentSyncStrategy implements ComponentSyncStrategyInterface
{
/**
* {@inheritDoc}
*/
public function fetchComponent(InstalledComponentDto $installedComponentDto): array
{
$slug = $installedComponentDto->getSlug();
$name = $installedComponentDto->getName();
$version = $installedComponentDto->getInstalledVersion();
if ($slug === '') {
return [
'success' => false,
'message' => sprintf('Missing %s slug for installed component.', $this->getType()),
];
}
return $this->fetchFromApi($slug, $name, $version);
}
/**
* {@inheritDoc}
*/
public function toComponentDto(array $apiPayload, InstalledComponentDto $installedComponentDto): ComponentVulnerabilitiesDto
{
$installedSlug = $installedComponentDto->getSlug();
$installedName = $installedComponentDto->getName();
$installedVersion = $installedComponentDto->getInstalledVersion();
$dto = ComponentVulnerabilitiesDto::FromApiComponentArray($apiPayload);
$dto->setType($this->getType());
$dto->setSlug($installedSlug);
$dto->setName($installedName);
$dto->setInstalledVersion($installedVersion);
$latestRaw = $apiPayload['latestVersion'] ?? $apiPayload['latest_version'] ?? null;
$latestVersion = is_string($latestRaw) ? $latestRaw : '';
$dto->setLatestVersion($latestVersion !== '' ? $latestVersion : null);
$dto->setUpdateAvailable(
$latestVersion !== '' && $installedVersion !== '' && version_compare($installedVersion, $latestVersion, '<')
);
return $dto;
}
/**
* Perform the component-specific API fetch.
*
* Implementations must return the standardized client response shape.
*
* @param string $slug The name of the component slug.
* @param string $name The name of the component.
* @param string $version The installed version of the component. Only used by core.
*
* @return array{success: bool, data?: array<string, mixed>, message?: string}
*/
abstract protected function fetchFromApi(string $slug, string $name, string $version = ''): array;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Clients\VulnerabilityClient;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\CoreRepository;
final class CoreSyncStrategy extends AbstractComponentSyncStrategy
{
private CoreRepository $coreRepository;
private VulnerabilityClient $vulnerabilityClient;
public function __construct(
CoreRepository $coreRepository,
VulnerabilityClient $vulnerabilityClient
) {
$this->vulnerabilityClient = $vulnerabilityClient;
$this->coreRepository = $coreRepository;
}
/**
* @inheritDoc
*/
public function getType(): string
{
return 'core';
}
/**
* @inheritDoc
*/
public function getInstalledComponents(): iterable
{
return $this->coreRepository->getInstalledComponents();
}
/**
* @inheritDoc
*/
protected function fetchFromApi(string $slug, string $name, string $version = ''): array
{
return $this->vulnerabilityClient->fetchCore($version);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Clients\VulnerabilityClient;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\PluginRepository;
final class PluginSyncStrategy extends AbstractComponentSyncStrategy
{
private PluginRepository $pluginRepository;
private VulnerabilityClient $vulnerabilityClient;
public function __construct(
PluginRepository $pluginRepository,
VulnerabilityClient $vulnerabilityClient
)
{
$this->vulnerabilityClient = $vulnerabilityClient;
$this->pluginRepository = $pluginRepository;
}
/**
* @inheritDoc
*/
public function getType(): string
{
return 'plugin';
}
/**
* @inheritDoc
*/
public function getInstalledComponents(): iterable
{
return $this->pluginRepository->getInstalledComponents();
}
/**
* @inheritDoc
*/
protected function fetchFromApi(string $slug, string $name, string $version = ''): array
{
return $this->vulnerabilityClient->fetchPlugin($slug, $name);
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Clients\VulnerabilityClient;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\ThemeRepository;
final class ThemeSyncStrategy extends AbstractComponentSyncStrategy
{
private ThemeRepository $themeRepository;
private VulnerabilityClient $vulnerabilityClient;
public function __construct(
ThemeRepository $themeRepository,
VulnerabilityClient $vulnerabilityClient
) {
$this->vulnerabilityClient = $vulnerabilityClient;
$this->themeRepository = $themeRepository;
}
/**
* @inheritDoc
*/
public function getType(): string
{
return 'theme';
}
/**
* @inheritDoc
*/
public function getInstalledComponents(): iterable
{
return $this->themeRepository->getInstalledComponents();
}
/**
* @inheritDoc
*/
protected function fetchFromApi(string $slug, string $name, string $version = ''): array
{
return $this->vulnerabilityClient->fetchTheme($slug, $name);
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilitySnapshotRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Policies\ConfigurableSeverityPolicy;
/**
* Executes post-sync vulnerability notification logic.
*
* This service is scheduled and executed by the core scheduler after a
* vulnerability sync has completed, and may also be triggered by a
* daily cron safety net.
*
* Responsibilities:
* - Build a snapshot of current vulnerabilities from storage.
* - Compare it with the previously stored snapshot.
* - Decide whether a notification should be sent using the configured policy.
* - Send the vulnerability notification email when required.
* - Persist the current snapshot for future comparisons (always).
*
* This service contains no scheduling logic and no sync logic.
* It is safe to run multiple times and is designed to be idempotent.
*/
final class VulnerabilityAfterSyncService
{
/**
* Source of persisted vulnerability data
*/
private VulnerabilityStorageRepository $storage;
/**
* Manages snapshot persistence and retrieval
*/
private VulnerabilitySnapshotRepository $snapshots;
/**
* Decides if/when notifications may be sent
*/
private ConfigurableSeverityPolicy $policy;
/**
* Responsible for composing and sending emails
*/
private VulnerabilityEmailService $email;
/**
* VulnerabilityAfterSyncService constructor.
*
* Dependencies are injected here; no work is performed during construction.
*
* @param VulnerabilityStorageRepository $storage Source of persisted vulnerability data
* @param VulnerabilitySnapshotRepository $snapshots Manages snapshot persistence and retrieval
* @param ConfigurableSeverityPolicy $policy Decides if/when notifications may be sent
* @param VulnerabilityEmailService $email Responsible for composing and sending emails
*/
public function __construct(
VulnerabilityStorageRepository $storage,
VulnerabilitySnapshotRepository $snapshots,
ConfigurableSeverityPolicy $policy,
VulnerabilityEmailService $email
) {
$this->storage = $storage;
$this->snapshots = $snapshots;
$this->policy = $policy;
$this->email = $email;
}
/**
* Runs the post-sync notification decision flow.
*
* This method is invoked by the scheduler once the vulnerability
* sync process has completed (or via a scheduled safety-net run).
*
* The current vulnerability snapshot is always persisted, even if
* notification sending fails, to ensure idempotent behavior on
* subsequent runs.
*
* Any exceptions during notification sending are caught and logged
* to prevent breaking admin or cron execution flows.
*/
public function run(): void
{
$previous = $this->snapshots->getLatest();
$current = $this->snapshots->buildFromStorage($this->storage);
try {
if ($this->policy->canSend() && $this->policy->shouldSend($current, $previous)) {
$this->email->sendVulnerabilityNotification($current);
}
} catch (\Throwable $e) {
if (function_exists('error_log')) {
error_log('RSS Vulnerability notification failed: ' . $e->getMessage());
}
} finally {
$this->snapshots->save($current);
}
/**
* Log the reason for the decision in debug mode.
*/
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log($this->policy->getReason());
}
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\VulnerabilitySnapshotDto;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\UriConfig;
use ReallySimplePlugins\RSS\Core\Services\EmailService;
/**
* Builds and sends vulnerability notification emails using the legacy RSSSL mailer.
*
* Responsibilities:
* - Shapes the email content (subject/title/message + per-severity blocks).
* - Reads vulnerability aggregates from the storage repository.
*
* Delivery is delegated to {@see EmailService} which lazy-loads the legacy mailer
* and provides common helpers (site URL, admin URLs, recipient handling).
*
* This service intentionally does not perform scanning or persistence.
*/
final class VulnerabilityEmailService extends EmailService
{
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
/**
* Constructs the VulnerabilityEmailService.
*
* We extend EmailService to reuse legacy mailer initialization and common helpers.
* Injected dependencies include environment and URI configs plus the vulnerability storage repository.
*/
public function __construct(
EnvironmentConfig $environmentConfig,
UriConfig $uriConfig,
VulnerabilityStorageRepository $vulnerabilityStorageRepository
) {
parent::__construct($environmentConfig, $uriConfig);
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
}
/**
* Sends the vulnerability summary notification email.
*
* This composes an email similar to the legacy `send_vulnerability_mail()` flow:
* - Uses the provided snapshot for overall counts (e.g. total vulnerable components).
* - Uses the storage repository for severity breakdown (used to build blocks).
*
* The legacy mailer expects specific public properties to be set (subject/title/message,
* button text, and `warning_blocks`). After composition we call `send_mail()`.
*
* @return array{success: bool, title?: string, message?: string}
*/
public function sendVulnerabilityNotification(
VulnerabilitySnapshotDto $vulnerabilitySnapshotDto
): array {
$total = $vulnerabilitySnapshotDto->getVulnerableCount();
$blocks = [];
$rssslmailer = $this->getMailer();
$rssslmailer->subject = sprintf(
/* translators: %s is the site url */
__('Vulnerability Alert: %s', 'really-simple-ssl'),
$this->siteUrl()
);
$rssslmailer->title = sprintf(
_n(
'%s: %s vulnerability found',
'%s: %s vulnerabilities found',
$total,
'really-simple-ssl'
),
$this->date(),
$total
);
$rssslmailer->message = sprintf(
/* translators: %s is a clickable domain */
__('This is a vulnerability alert from Really Simple Security for %s.', 'really-simple-ssl'),
$this->domain()
);
$rssslmailer->button_text = __('Learn more', 'really-simple-ssl');
$vulnerabilityCountBySeverity = $this->vulnerabilityStorageRepository->getComponentCountPerHighestSeverity();
foreach ($vulnerabilityCountBySeverity as $severity => $count) {
$blocks[] = $this->createBlock($severity, $count);
}
$rssslmailer->warning_blocks = $blocks;
return $rssslmailer->send_mail();
}
/**
* Creates a "warning block" for a single severity bucket.
*
* The legacy mailer renders these blocks in the email body.
*
* @return array{title: string, message: string, url: string}
*/
private function createBlock(string $severity, int $count): array
{
$title = $this->getWarningString($severity, $count);
$riskLabel = strtolower(trim($severity));
$message = $count === 1
? sprintf(
/* translators: 1: severity label */
__('A %s vulnerability has been found.', 'really-simple-ssl'),
$riskLabel
)
: sprintf(
/* translators: 1: severity label */
__('Multiple %s vulnerabilities have been found.', 'really-simple-ssl'),
$riskLabel
);
$message .= ' ' . __('Based on your settings, Really Simple Security will take appropriate action, or you will need to solve it manually.', 'really-simple-ssl');
$message .= ' ' . sprintf(
/* translators: %s is a clickable domain */
__('Get more information from the Really Simple Security dashboard on %s', 'really-simple-ssl'),
$this->domain()
);
return [
'title' => $title,
'message' => $message,
'url' => $this->getVulnerabilitiesSettingsUrl(),
];
}
/**
* Returns a translated, pluralized title line for a severity and count.
*/
private function getWarningString(string $severity, int $count): string
{
switch (strtolower(trim($severity))) {
case 'critical':
return sprintf(
_n('You have %s critical-risk vulnerability', 'You have %s critical-risk vulnerabilities', $count, 'really-simple-ssl'),
$count
);
case 'high':
return sprintf(
_n('You have %s high-risk vulnerability', 'You have %s high-risk vulnerabilities', $count, 'really-simple-ssl'),
$count
);
case 'medium':
return sprintf(
_n('You have %s medium-risk vulnerability', 'You have %s medium-risk vulnerabilities', $count, 'really-simple-ssl'),
$count
);
case 'low':
default:
return sprintf(
_n('You have %s low-risk vulnerability', 'You have %s low-risk vulnerabilities', $count, 'really-simple-ssl'),
$count
);
}
}
private function date(): string
{
return (string) date_i18n(get_option('date_format'));
}
/**
* Returns an HTML anchor tag with the site URL, used by the legacy mailer.
*/
private function domain(): string
{
$url = $this->siteUrl();
return '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer">' . esc_html($url) . '</a>';
}
private function siteUrl(): string
{
$scheme = (function_exists('is_ssl') && is_ssl()) ? 'https' : 'http';
return (string) get_site_url(null, '', $scheme);
}
/**
* Returns the admin settings URL (hash route) used in email buttons/blocks.
*/
public function getVulnerabilitiesSettingsUrl(): string
{
if (function_exists('rsssl_admin_url')) {
return (string) rsssl_admin_url([], '#settings/vulnerabilities');
}
return (string) admin_url('admin.php?page=really-simple-security#settings/vulnerabilities');
}
/**
* Sends a generic test email via the legacy mailer.
*
* Used for diagnostics, delegates to the legacy mailer's test method.
*/
public function sendTestEmail(): array
{
return $this->getMailer()->send_test_mail();
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services;
/**
* Maps vulnerability-related values to human-readable presentation labels.
*
* Constraints: Contains presentation logic only; no persistence or business rules.
*/
final class VulnerabilityPresentationService
{
/**
* Returns a translated label for a vulnerability severity value.
*/
public function getLabelForSeverity(string $severity): string
{
switch (strtolower($severity)) {
case 'critical':
return __('Critical', 'really-simple-ssl');
case 'high':
return __('High', 'really-simple-ssl');
case 'medium':
return __('Medium', 'really-simple-ssl');
case 'low':
default:
return __('Low', 'really-simple-ssl');
}
}
}

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Interfaces\ComponentSyncStrategyInterface;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilitySnapshotRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Repositories\VulnerabilityStorageRepository;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies\CoreSyncStrategy;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies\PluginSyncStrategy;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Services\Strategies\ThemeSyncStrategy;
/**
* Synchronize vulnerability data for installed components.
*
* High-level flow:
* 1) Discover installed components via strategy classes (plugin/theme/core).
* 2) Fetch vulnerability payloads from the API (via the strategies).
* 3) Convert payloads into DTOs and filter vulnerabilities by installed version.
* 4) Persist results using repositories.
*
* Notes:
* - This service does not talk to the database directly; it delegates that work to repositories.
* - Network/API calls are performed by the strategies.
*/
final class VulnerabilitySyncService
{
/**
* Strategies used to sync vulnerabilities per component type.
*
* @var list<ComponentSyncStrategyInterface>
*/
private array $strategies = [];
/** Persists component vulnerability data into storage. */
private VulnerabilityStorageRepository $vulnerabilityStorageRepository;
public function __construct(
VulnerabilityStorageRepository $vulnerabilityStorageRepository
) {
$this->vulnerabilityStorageRepository = $vulnerabilityStorageRepository;
$this->strategies = $this->buildDefaultStrategies();
}
public function setStrategies(iterable $strategies): void
{
$this->strategies = $this->filterStrategies($strategies);
}
/**
* @param iterable<ComponentSyncStrategyInterface> $strategies
*
* @return list<ComponentSyncStrategyInterface>
*/
private function filterStrategies(iterable $strategies): array
{
$resolved = [];
foreach ($strategies as $strategy) {
if ($strategy instanceof ComponentSyncStrategyInterface) {
$resolved[] = $strategy;
}
}
return $resolved;
}
private function buildDefaultStrategies(): array
{
$app = App::getInstance();
$pluginStrategy = $app->get(PluginSyncStrategy::class);
$themeStrategy = $app->get(ThemeSyncStrategy::class);
$coreStrategy = $app->get(CoreSyncStrategy::class);
return [
$pluginStrategy,
$themeStrategy,
$coreStrategy,
];
}
private function syncInstalledComponentsForStrategy(ComponentSyncStrategyInterface $componentSyncStrategy): void
{
foreach ($componentSyncStrategy->getInstalledComponents() as $installedComponentDto) {
$componentPayload = $this->fetchComponentPayloadForInstalledComponent($componentSyncStrategy, $installedComponentDto);
if ($componentPayload === null) {
continue;
}
$dto = $componentSyncStrategy->toComponentDto($componentPayload, $installedComponentDto);
if (!is_array($dto->vulnerabilities)) {
$dto->vulnerabilities = [];
}
$installedVersion = (string) ($dto->installedVersion ?? '');
if ($installedVersion !== '' && $dto->vulnerabilities !== []) {
$dto->vulnerabilities = $this->filterVulnerabilitiesForInstalledVersion($installedVersion, $dto->vulnerabilities);
}
$this->vulnerabilityStorageRepository->saveComponent($dto);
}
}
/**
* Fetch the raw component payload for a specific installed component.
*
* Returns null when the API call fails or the response is not successful.
*
* @return array<string, mixed>|null
*/
private function fetchComponentPayloadForInstalledComponent(ComponentSyncStrategyInterface $componentSyncStrategy, \ReallySimplePlugins\RSS\Core\Features\Vulnerability\Dtos\InstalledComponentDto $installedComponentDto): ?array
{
try {
$result = $componentSyncStrategy->fetchComponent($installedComponentDto);
} catch (\Throwable $e) {
$slug = $installedComponentDto->slug ?? '';
$context = $slug !== '' ? (' (' . $slug . ')') : '';
error_log(
'Vulnerability API request failed for ' . $componentSyncStrategy->getType() . ' component' . $context . ': ' . $e->getMessage()
);
return null;
}
if (!isset($result['success']) || !$result['success']) {
return null;
}
return $this->extractFirstComponentPayload($result['data'] ?? null);
}
/**
* Extract the component payload from the API response.
*
* The API may return either:
* - a single component array, or
* - a list of components (we use the first item).
*
* @param mixed $data The raw `data` field from the API response.
* @return array<string, mixed>|null The component payload or null when missing/invalid.
*/
private function extractFirstComponentPayload($data): ?array
{
if (!is_array($data) || $data === []) {
return null;
}
// Support both shapes:
// 1) Single component array in $data
// 2) List of components in $data[0]
$payload = isset($data['slug']) ? $data : ($data[0] ?? null);
if (!is_array($payload)) {
return null;
}
return $payload;
}
/**
* Filters vulnerability ranges to only those that match the installed version.
*
* The API provides version_from/version_to with inclusive flags. A "*" boundary
* is treated as open-ended. Internally, version_compare() is used to evaluate
* whether the installed version falls within each vulnerability's range.
*
* @param string $installedVersion Currently installed component version.
* @param array $vulnerabilities List of VulnerabilityRangeDto objects.
*
* @return array List of matching VulnerabilityRangeDto objects.
*/
private function filterVulnerabilitiesForInstalledVersion(string $installedVersion, array $vulnerabilities): array
{
$matches = [];
foreach ($vulnerabilities as $vulnerability) {
if (!is_object($vulnerability) || !method_exists($vulnerability, 'toArray')) {
continue;
}
/** @var array<string, mixed> $data */
$data = $vulnerability->toArray();
$versionFrom = $data['version_from'];
$versionTo = $data['version_to'];
$fromInclusive = $data['from_inclusive'];
$toInclusive = $data['to_inclusive'];
if ($versionFrom !== '*') {
$cmpFrom = version_compare($installedVersion, $versionFrom);
if ($fromInclusive && $cmpFrom < 0) {
continue;
}
if (!$fromInclusive && $cmpFrom <= 0) {
continue;
}
}
if ($versionTo !== '*') {
$cmpTo = version_compare($installedVersion, $versionTo);
if ($toInclusive && $cmpTo > 0) {
continue;
}
if (!$toInclusive && $cmpTo >= 0) {
continue;
}
}
$matches[] = $vulnerability;
}
return $matches;
}
/**
* Sync vulnerabilities for all supported component types.
*
* This clears existing stored components and writes a fresh snapshot.
*/
public function syncAllComponents(string $trigger = ''): void
{
$this->vulnerabilityStorageRepository->deleteAllComponents();
$this->strategies = $this->filterStrategies($this->strategies);
if ($this->strategies === []) {
return;
}
foreach ($this->strategies as $strategy) {
$this->syncInstalledComponentsForStrategy($strategy);
}
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Support\Helpers;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
class VulnerabilityConfig extends Storage
{
public function __construct()
{
parent::__construct(
require dirname(__FILE__, 6) . '/config/vulnerability.php'
);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability\Traits;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Support\Helpers\VulnerabilityConfig;
trait HasFrontendUrl
{
/**
* Returns the public URL for a vulnerability detail page.
*/
public function getFrontendUrl(
string $type,
string $slug,
string $vulnerabilityUuid
): string {
if ($vulnerabilityUuid === '' || $vulnerabilityUuid === '0') {
return '';
}
$baseUrl = App::getInstance()->get(VulnerabilityConfig::class)->getUrl('client.base_uri');
return sprintf(
$baseUrl . '/%s/%s/%s',
$type,
$slug,
$vulnerabilityUuid
);
}
}

View File

@@ -0,0 +1,175 @@
<?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;
/**
* Registers WordPress hooks and coordinates the vulnerability sync lifecycle.
*
* Lifecycle (high level):
* - Schedule a sync when components change (plugin/theme/core updates/activation)
* and once per day as a safety net.
* - Debounce multiple triggers into a single scheduled event.
* - Run the scheduled job with a lock to avoid overlapping syncs.
* - Fire a completion action after a successful run.
*
*/
final class VulnerabilityController implements FeatureInterface
{
use HasViews;
use HasScheduler;
/**
* Action hook name used to trigger a scheduled vulnerability sync.
*
* This hook is scheduled via the VulnerabilityScheduleManager and ultimately
* results in `scheduleSync()` being executed.
*/
private const SYNC_EVENT = 'rsssl_vulnerability_run_scheduled_sync';
/**
* Action hook fired after a vulnerability sync has completed.
*
* Can be used by other parts of the system to react to a finished sync
* (e.g. logging, notices, follow-up processing).
*/
public const SYNC_COMPLETED_ACTION = 'rsssl_vulnerability_sync_completed';
/**
* Debounce window (in seconds) for scheduling vulnerability syncs.
*
* Multiple triggers within this time window will result in a single
* scheduled sync execution.
*/
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;
}
/**
* Registers WordPress hooks for the Vulnerability feature.
*
* Keeps the feature wiring in one place: enqueueing assets, registering
* controllers, and scheduling/running vulnerability syncs on relevant events.
*/
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, 'scheduleSync'], 10, 1);
add_action('upgrader_process_complete', [$this, 'scheduleSync'], 10, 2);
add_action('activate_plugin', [$this, 'scheduleSync'], 10, 2);
add_action('after_switch_theme', [$this, 'scheduleSync'], 10, 0);
add_action('_core_updated_successfully', [$this, 'scheduleSync'], 10, 1);
add_action('rsssl_daily_cron', [$this, 'scheduleSync']);
}
/**
* 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.
*/
public function enqueueStyles(string $hook): void
{
if (!$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);
}
/**
* Schedule a debounced vulnerability sync.
*
* Uses the DebouncedScheduler trait to prevent multiple rapidly fired
* triggers from scheduling duplicate jobs.
*/
public function scheduleSync(): void
{
$this->scheduleDebounced(
self::SYNC_EVENT,
self::SYNC_DEBOUNCE,
[]
);
}
/**
* Execute the vulnerability sync and release the scheduling lock.
*
* After the sync completes, the completion action is fired to allow other
* components to react.
*/
public function runSync(): void
{
// Run the sync logic (example):
$this->vulnerabilitySyncService->syncAllComponents();
// 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);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Features\Vulnerability;
use ReallySimplePlugins\RSS\Core\Features\AbstractLoader;
use ReallySimplePlugins\RSS\Core\Managers\FeatureManager;
use ReallySimplePlugins\RSS\Core\Traits\HasAllowlistControl;
/**
* Determines whether the Vulnerabilities feature should be loaded.
*
* @see FeatureManager
*/
class VulnerabilityLoader extends AbstractLoader
{
use HasAllowlistControl;
/**
* @inheritDoc
*/
public function isEnabled(): bool
{
return rsssl_get_option('enable_vulnerability_scanner', false);
}
/**
* @inheritDoc
*/
public function inScope(): bool
{
return $this->adminAccessAllowed();
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
/**
* This interface can be used to register a controller. Controllers will only
* be accepted and registered by {@see ControllerManager} when they implement
* this interface.
*/
interface ControllerInterface
{
/**
* This method should be used to register all hooks and filters. The
* {@see ControllerManager} will make sure the method is called in the boot
* process of the plugin.
*/
public function register(): void;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
use ReallySimplePlugins\RSS\Core\Features\Vulnerability\Controllers\VulnerabilityDataController;
interface DoActionInterface
{
/**
* Implement this method to handle custom actions triggered via the
* existing `rsssl_do_action` mechanism.
*
* This interface allows new code to hook into the same action-dispatching
* flow that is already used elsewhere in the plugin, without duplicating
* or reimplementing that logic.
*
* The method is responsible for inspecting the given `$action` and `$data`,
* performing the appropriate operation, and returning a modified `$response`
* array.
*
* @param array $response The response data that should be returned to the caller.
* @param string $action The action identifier that determines what logic to execute.
* @param mixed $data Additional payload associated with the action.
*
* @return array The updated response array after the action has been handled.
*/
public function rssslDoAction(array $response, string $action, $data): array;
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
/**
* This interface can be used to register a feature. Features will only
* be accepted and registered by {@see FeatureManager} when they implement
* this interface.
*/
interface FeatureInterface
{
/**
* This method should be used to register all hooks and filters. The
* {@see FeatureManager} will make sure the method is called in the boot
* process of the plugin.
*/
public function register(): void;
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
/**
* This interface can be used instead of {@see SingleEndpointInterface} to register
* multiple routes at once. This is useful when a single route has multiple
* endpoints.
*/
interface MultiEndpointInterface
{
/**
* The routes to register. For each array in the array, the key is the route
* and the value is an array of arguments to pass to the register_rest_route
* function: {@see EndpointManager::registerWordPressRestRoutes}.
*
* Arguments you can use are documented with filter: rss_core_rest_routes
* in method: {@see EndpointManager::getPluginRoutes}
*/
public function registerRoutes(): array;
/**
* This method should return true if the endpoint is enabled, false
* otherwise. Endpoint will not be registered if this method returns false:
* {@see EndpointManager::registerEndpoints}
*/
public function enabled(): bool;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
interface ProviderInterface
{
/**
* The method that gets called by the ProviderManager to serve the provided
* functionality.
*/
public function provide(): void;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Interfaces;
interface SingleEndpointInterface
{
/**
* The route name to register. Will be used as the array key for routes
* array in: {@see EndpointManager::registerWordPressRestRoutes}
*/
public function registerRoute(): string;
/**
* Arguments you can use are documented wih filter: rss_core_rest_routes
* in method: {@see EndpointManager::getPluginRoutes}
*/
public function registerArguments(): array;
/**
* This method should return true if the endpoint is enabled, false
* otherwise. Endpoint will not be registered if this method returns false:
* {@see EndpointManager::registerEndpoints}
*/
public function enabled(): bool;
}

View File

@@ -0,0 +1,84 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Managers;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Services\LicenseService;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
abstract class AbstractManager
{
protected EnvironmentConfig $env;
protected LicenseService $license;
/**
* Overwrite this property to true when the entries that the child Manager
* registers should be added to the container registry. For details see:
* {@see App::make}
*/
protected bool $useRegistry = false;
/**
* Overwrite this property to true when the dependencies of the entries that
* the child Manager registers should be added to the container registry.
* For details see: {@see App::make}
*/
protected bool $useRegistryForDependencies = true;
/**
* Bind the container
*/
public function __construct(
EnvironmentConfig $environmentConfig,
LicenseService $licence
)
{
$this->env = $environmentConfig;
$this->license = $licence;
}
/**
* Child class should check if the given class can be registered. For
* example by checking if it implements an interface to know the logic in
* the {@see registerClass} method can be executed.
*/
abstract public function isRegistrable(object $class): bool;
/**
* Logic to register the given class. If this method can be executed is
* checked by the {@see isRegistrable} method.
*/
abstract public function registerClass(object $class): void;
/**
* Method called after all classes given to the manager are registered.
*/
abstract public function afterRegister(): void;
/**
* Register the given class as long as the entries are registrable according
* to the child managers. Class are autowired, but not registered via
* {@see App::make}
*
* @throws \LogicException When a developer is doing it wrong.
* @throws \ReflectionException When the controller cannot be loaded.
*/
public function register(array $classes): void
{
foreach ($classes as $fullyClassifiedName) {
if (is_string($fullyClassifiedName) === false) {
throw new \LogicException("Class must be a fully qualified name: " . esc_html($fullyClassifiedName));
}
$class = App::getInstance()->make($fullyClassifiedName, $this->useRegistry, $this->useRegistryForDependencies);
if ($this->isRegistrable($class) === false) {
throw new \LogicException("Class is not registrable: " . $fullyClassifiedName);
}
$this->registerClass($class);
}
$this->afterRegister();
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Managers;
use ReallySimplePlugins\RSS\Core\Interfaces\ControllerInterface;
final class ControllerManager extends AbstractManager
{
/**
* @inheritDoc
*/
public function isRegistrable(object $class): bool
{
return $class instanceof ControllerInterface;
}
/**
* @inheritDoc
*/
public function registerClass(object $class): void
{
$class->register();
}
/**
* @inheritDoc
*/
public function afterRegister(): void
{
do_action('rss_core_controllers_loaded');
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Managers;
use ReallySimplePlugins\RSS\Core\Interfaces\MultiEndpointInterface;
use ReallySimplePlugins\RSS\Core\Interfaces\SingleEndpointInterface;
use ReallySimplePlugins\RSS\Core\Traits\HasAllowlistControl;
use ReallySimplePlugins\RSS\Core\Traits\HasNonces;
final class EndpointManager extends AbstractManager
{
use HasNonces;
use HasAllowlistControl;
private string $version;
private string $namespace;
private array $routes = [];
/**
* @inheritDoc
*/
public function isRegistrable(object $class): bool
{
return ($class instanceof SingleEndpointInterface
|| $class instanceof MultiEndpointInterface
);
}
/**
* @inheritDoc
*/
public function registerClass(object $class): void
{
if ($class instanceof SingleEndpointInterface) {
$this->registerSingleEndpointRoute($class);
}
$this->registerMultiEndpointRoute($class);
}
/**
* @inheritDoc
*/
public function afterRegister(): void
{
$this->registerWordPressRestRoutes();
do_action('rss_core_endpoints_loaded');
}
/**
* Register a plugin route for and endpoint instance that implements the
* {@see SingleEndpointInterface}
*/
private function registerSingleEndpointRoute(SingleEndpointInterface $endpoint): void
{
if ($endpoint->enabled() === false) {
return;
}
$this->routes[$endpoint->registerRoute()] = $endpoint->registerArguments();
}
/**
* Register plugin routes for an endpoint instance that implements the
* {@see MultiEndpointInterface}
*/
private function registerMultiEndpointRoute(MultiEndpointInterface $endpoint): void
{
if ($endpoint->enabled() === false) {
return;
}
$routeEndpoints = $endpoint->registerRoutes();
foreach ($routeEndpoints as $route => $arguments) {
$this->routes[$route] = $arguments;
}
}
/**
* This method provides a way to register custom REST routes via the
* rss_core_rest_routes filter. A controller of feature should be
* instantiated before this manager is called and the controller should
* hook into the rss_core_rest_routes filter to add its own routes.
* @uses apply_filters rss_core_rest_routes
*/
public function registerWordPressRestRoutes(): void
{
$routes = $this->getPluginRestRoutes();
foreach ($routes as $route => $data) {
$version = ($data['version'] ?? $this->env->getString('http.version'));
$callback = ($data['callback'] ?? null);
$middleware = ($data['middleware'] ?? null);
$arguments = [
'methods' => $this->normalizeMethods($data['methods'] ?? ''),
'callback' => $this->callbackMiddleware($callback, $middleware),
'permission_callback' => ($data['permission_callback'] ?? [$this, 'defaultPermissionCallback']),
];
register_rest_route($this->env->getUrl('http.namespace') . '/' . $version, $route, $arguments);
}
}
/**
* Get the plugins REST routes
* @uses apply_filters rss_core_rest_routes
*/
private function getPluginRestRoutes(): array
{
/**
* Filter: rss_core_rest_routes
* Can be used to add or modify the REST routes
*
* @param array $routes
* @return array
* @example [
* 'route' => [ // key is the route name
* 'methods' => 'GET', // required
* 'callback' => 'callback_function', // required
* 'permission_callback' => 'permission_callback_function', // optional to override the default permission callback
* 'version' => 'v1' // optional to override the default version
* ]
* ]
*/
return apply_filters('rss_core_rest_routes', $this->routes);
}
/**
* This method is used to add middleware to the callback function. The
* middleware should be a callable function that takes a request as an
* argument and returns a response. The default middleware is to switch
* the user locale to the current user locale.
*/
public function callbackMiddleware(?callable $callback, ?callable $middleware): callable
{
return function ($request) use ($callback, $middleware) {
if (is_callable($middleware)) {
$middleware($request);
} else {
$this->defaultMiddlewareCallback();
}
return $callback($request);
};
}
/**
* This method is used to switch the user locale to the current user locale.
* This is important because we will otherwise show the default site
* language to the user for the Tasks and Notifications. Those
* translations are created in PHP and not in JS.
*/
private function defaultMiddlewareCallback(): void
{
switch_to_user_locale(get_current_user_id());
}
/**
* The default permission callback, will check if the nonce is valid and if
* the user has the required permissions to do a request.
* @return bool|\WP_Error
*/
public function defaultPermissionCallback(\WP_REST_Request $request)
{
$method = $request->get_method();
$nonce = $request->get_param('nonce');
if (($method === 'POST') && ($this->verifyNonce($nonce) === false)) {
return new \WP_Error(
'rest_forbidden',
esc_html__('Forbidden.', 'really-simple-ssl'),
['status' => 403]
);
}
return true;
}
/**
* Process the given methods and compare them to the allowed
* {@see \WP_REST_Server::ALLMETHODS} methods. Remove unwanted entries and
* cleanup method usage from, for example, "get " to "GET".
*
* @return string From "get, POSt, fake" to "GET,POST"
*/
private function normalizeMethods(string $methods): string
{
// Split into array, trim whitespace and uppercase entries
$methodsArray = array_map('trim', explode(',', $methods));
$methodsArray = array_map('strtoupper', $methodsArray);
// Split allowed entries into array and trim whitespaces
$allowedMethodsArray = array_map('trim', explode(',', \WP_REST_Server::ALLMETHODS));
// Keep only allowed methods
$methodsArray = array_intersect($methodsArray, $allowedMethodsArray);
$methodsArray = array_values(array_unique($methodsArray));
// Convert back to CSV format for register_rest_route usage
return implode(',', $methodsArray);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Managers;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Interfaces\FeatureInterface;
/**
* This manager dynamically fetches the features of the plugin. It differs from
* other manager classes due to this nature. By preventing any class usage of
* features we prevent composer from loading the feature file entirely until
* first use. This prevents overhead from loading features that are no longer
* needed. We prevent loading feature files by utilizing the
* {@see AbstractLoader} class at {@see FeatureManager:92}
*/
final class FeatureManager extends AbstractManager
{
private const PRO_FEATURE_HANDLE = 'Pro:';
/**
* @inheritDoc
*/
public function isRegistrable(object $class): bool
{
return $class instanceof FeatureInterface;
}
/**
* @inheritDoc
*/
public function registerClass(object $class): void
{
$class->register();
}
/**
* @inheritDoc
*/
public function afterRegister(): void
{
do_action('rss_core_features_loaded');
}
/**
* Register and load all features from the src/features directory. This
* method automatically loads all classes from the features directory and
* injects the dependency classes into the Controller class if they exist.
* @uses do_action rss_core_features_loaded
*/
public function registerFeatures(): void
{
$featureClasses = $this->getFeatureClasses();
$this->register($featureClasses);
}
/**
* Dynamically build and then return an array of feature classes that are
* saved in the features path of the plugin.
*/
public function getFeatureClasses(): array
{
$features = $this->getFeatures();
$featureClasses = [];
foreach ($features as $featureName) {
$needsPro = strpos($featureName, self::PRO_FEATURE_HANDLE) !== false;
if ($needsPro && !$this->env->getBoolean('plugin.pro')) {
continue; // Pro not installed, don't register pro features
}
if ($needsPro) {
$featureName = substr($featureName, strlen(self::PRO_FEATURE_HANDLE));
}
// Check if the feature directory exists
$featuresPath = $this->getFeaturePath($featureName, $needsPro);
if (!is_dir($featuresPath)) {
continue;
}
// Get the feature namespace
$prefix = $this->getFeatureNamespace($featureName, $needsPro) . $featureName;
// Get the {FeatureName}Loader class for the feature
if (class_exists($prefix . 'Loader') === false) {
continue;
}
$loader = App::getInstance()->make($prefix . 'Loader', false, false);
if (!$loader->isEnabled() || !$loader->inScope()) {
continue;
}
// The controller is the backbone of a feature
$featureClasses[] = $prefix . 'Controller';
};
return $featureClasses;
}
/**
* Get all feature directory names. Includes "Pro" features prefixed
* with {@see PRO_FEATURE_HANDLE}.
*/
private function getFeatures(): array
{
$featuresPath = $this->env->getString('plugin.feature_path');
$features = [];
foreach (new \DirectoryIterator($featuresPath) as $fileInfo) {
if ($fileInfo->isDot() || !$fileInfo->isDir()) {
continue;
}
$isProFeature = ($fileInfo->getFilename() === 'Pro');
if (!$isProFeature) {
$features[] = $fileInfo->getFilename();
continue;
}
$proIsNotActive = ($this->env->getBoolean('plugin.pro') !== true);
if ($proIsNotActive || $this->license->isValid() !== true) {
continue;
}
foreach (new \DirectoryIterator($fileInfo->getPathname()) as $proInfo) {
if ($proInfo->isDot() || !$proInfo->isDir()) {
continue;
}
$features[] = self::PRO_FEATURE_HANDLE . $proInfo->getFilename();
}
}
return $features;
}
/**
* Get the feature path based on the feature name and if it needs the Pro
* version.
*/
private function getFeaturePath(string $featureName, bool $needsPro): string
{
return $this->env->getString('plugin.feature_path') . ($needsPro ? 'Pro/' : '') . $featureName . '/';
}
/**
* Get the feature namespace.
*/
private function getFeatureNamespace(string $featureName, bool $needsPro = false): string
{
return 'ReallySimplePlugins\RSS\Core\Features\\' . ($needsPro ? 'Pro\\' : '') . $featureName . '\\';
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Managers;
use ReallySimplePlugins\RSS\Core\Interfaces\ProviderInterface;
final class ProviderManager extends AbstractManager
{
/**
* @inheritDoc
*/
public function isRegistrable(object $class): bool
{
return $class instanceof ProviderInterface;
}
/**
* @inheritDoc
*/
public function registerClass(object $class): void
{
$class->provide();
}
/**
* @inheritDoc
*/
public function afterRegister(): void
{
do_action('rss_core_providers_loaded');
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Providers;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Interfaces\ProviderInterface;
use ReallySimplePlugins\RSS\Core\Support\Utility\StringUtility;
/**
* Providers are classes that provide functionality to the container. Child
* classes should never use the container instance themselves to prevent
* recursion in the container registry. Therefor child Providers should
* always return the provided functionality directly in the
* provide{Function} method instead of setting it in the
* container {@see App}
*/
class Provider implements ProviderInterface
{
/**
* Register the provided services. Will be used to find and call the
* provide{Service} methods. You can use lowercase for the service name.
* @var string[]
*/
protected array $provides = [];
/**
* Register the provided singleton services. The key is the name of the
* service and is used to find and call the provide{Service}Singleton
* method. The value is the class string that will be used to register
* and retrieve the singleton in the container.
* @var array<string, class-string>
*/
protected array $singletons = [];
/**
* Method will be called by the ProviderManager to serve the provided
* services.
*/
final public function provide(): void
{
foreach ($this->provides as $provide) {
$method = 'provide' . StringUtility::snakeToPascalCase($provide);
if (method_exists($this, $method) === false) {
continue;
}
App::getInstance()->set($provide, static function() use ($method) {
return static::$method();
});
}
foreach ($this->singletons as $key => $classString) {
$method = 'provide' . StringUtility::snakeToPascalCase($key) . 'Singleton';
if (method_exists($this, $method) === false) {
continue;
}
App::getInstance()->set($classString, static function() use ($method) {
return static::$method();
});
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Services;
/**
* Business logic related to the site certificate
* @todo Move RSSSL()->certificate methods here after full refactor.
*/
final class CertificateService
{
/**
* Method returns true if the site certificate is valid. False otherwise.
*/
public function isValid(): bool
{
return RSSSL()->certificate->is_valid();
}
/**
* Method returns true if the certificate detection failed prior to calling
* this method. It uses the transient 'rsssl_certinfo' for the detection.
*/
public function detectionFailed(): bool
{
return RSSSL()->certificate->detection_failed();
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Services;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\UriConfig;
use ReallySimplePlugins\RSS\Core\Traits\HasEncryption;
/**
* todo: move mailer methods to this service after full refactor.
*/
class EmailService
{
use HasEncryption;
private ?\rsssl_mailer $mailer = null;
protected EnvironmentConfig $env;
protected UriConfig $uriConfig;
public function __construct(EnvironmentConfig $environmentConfig, UriConfig $uriConfig)
{
$this->env = $environmentConfig;
$this->uriConfig = $uriConfig;
}
/**
* Method is used to lazyload the mailer property. This prevents overhead
* but most importantly prevents _load_textdomain_just_in_time error
*/
protected function getMailer(): \rsssl_mailer
{
if ($this->mailer instanceof \rsssl_mailer) {
return $this->mailer;
}
require_once $this->env->getString('plugin.path') . '/mailer/class-mail.php';
$this->mailer = new \rsssl_mailer();
return $this->mailer;
}
/**
* Set the email of the recipient.
* @throws \InvalidArgumentException if email address is not valid
*/
public function setEmail(string $email): void
{
$sanitizedEmail = sanitize_email($email);
if (empty($sanitizedEmail)) {
throw new \InvalidArgumentException("Email address \"$email\" not valid in " . __METHOD__);
}
$this->getMailer()->set_to($sanitizedEmail);
}
/**
* Trigger the verification mail
*/
public function sendVerificationMail(): array
{
return $this->getMailer()->send_verification_mail();
}
/**
* Signup for Tips & Tricks from Really Simple Security
* @return array|\WP_Error
* @throws \InvalidArgumentException if email address is not valid
*/
public function addEmailToMailingList(string $email)
{
$sanitizedEmail = sanitize_email($email);
if (empty($sanitizedEmail)) {
throw new \InvalidArgumentException("Email address \"$email\" not valid in " . __METHOD__);
}
$license = '';
$hasPremium = defined('rsssl_pro');
if ($hasPremium) {
$license = RSSSL()->licensing->license_key();
$license = $this->maybeDecryptPrefixed($license, 'really_simple_ssl_');
}
$payload = [
'has_premium' => $hasPremium,
'license' => $license,
'email' => $sanitizedEmail,
'domain' => esc_url_raw(site_url()),
];
return wp_remote_post($this->uriConfig->getUrl('rsp.mailinglist'), [
'timeout' => 15,
'sslverify' => true,
'body' => $payload
]);
}
/**
* Get the email address to which notifications should be sent, based on user configuration.
*/
public function getNotificationsEmail(): string
{
if (!function_exists('rsssl_get_option')) {
return '';
}
return (string) rsssl_get_option('notifications_email_address', get_bloginfo('admin_email'));
}
/**
* Check if the user has enabled email notifications in their settings.
* @return bool True if email notifications are enabled, false otherwise.
*/
public function isNotificationsEnabled(): bool
{
if (!function_exists('rsssl_get_option')) {
return false;
}
return (bool) rsssl_get_option('send_notifications_email', false);
}
/**
* Check if the email verification flow has been completed, based on the stored option.
*
* @return bool True if the email verification flow has been completed, false otherwise.
*/
public function isEmailVerified(): bool
{
if (!\function_exists('get_option')) {
return false;
}
$status = (string) \get_option('rsssl_email_verification_status', '');
if ($status === 'completed') {
return true;
}
if (\function_exists('is_multisite') && \is_multisite() && \function_exists('get_site_option')) {
$networkStatus = (string) \get_site_option('rsssl_email_verification_status', '');
if ($networkStatus === 'completed') {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Services;
/**
* Global onboarding service for managing onboarding state and visibility.
* This service provides globally accessible onboarding functionality that can
* be used throughout the plugin.
*/
class GlobalOnboardingService
{
/**
* Reset the onboarding to allow the onboarding modal to be shown again.
* This called when:
* - The license is deactivated
* - The free plugin is deactivated after Pro installation
* - The user clicks "Activate SSL" after previously dismissing onboarding
*
* @return void
*/
public function resetOnboarding(): void
{
update_option('rsssl_show_onboarding', true, false);
update_option('rsssl_onboarding_dismissed', false, false);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Services;
/**
* Business logic related to the plugin licensing.
* @todo Move RSSSL()->licensing methods here after full refactor.
*/
final class LicenseService
{
/**
* Method returns true if the license is valid. False otherwise.
*/
public function isValid(): bool
{
$pluginInstance = RSSSL();
if (! isset($pluginInstance->licensing) || ! is_object($pluginInstance->licensing)) {
return false;
}
return $pluginInstance->licensing->license_is_valid();
}
}

View File

@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Services;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\RelatedConfig;
final class RelatedPluginService
{
/**
* Should be a Storage object based on one entry in the related config
*/
private Storage $pluginConfig;
private RelatedConfig $relatedConfig;
public function __construct(RelatedConfig $relatedConfig)
{
$this->relatedConfig = $relatedConfig;
}
public function setPluginConfigBySlug(string $slug): void
{
$plugins = $this->relatedConfig->get('plugins', []);
$plugins = array_filter($plugins, static function($plugin) use ($slug) {
return isset($plugin['slug']) && ($plugin['slug'] === $slug);
});
$plugin = reset($plugins);
$this->setPluginConfig($plugin);
}
/**
* Use this method as the default way to set the plugin config.
*/
public function setPluginConfig(array $pluginConfig): void
{
$this->pluginConfig = new Storage($pluginConfig);
}
/**
* Get the list of recommended plugins for the onboarding process.
*
* This function prepares plugin data for display in the onboarding wizard.
* It handles plugin status, actions, and checkbox initialization based on
* configuration.
*
* @return array List of plugin items with their status, actions and UI properties
*
* @todo: Plugins that are already installed and activated are still listed
* in the onboarding.
*/
public function getOnboardingConfig(): array
{
$checkboxes = [];
$relatedPlugins = $this->relatedConfig->get('plugins', []);
foreach ($relatedPlugins as $config) {
if (!isset($config['slug'], $config['title'])) {
continue;
}
$this->setPluginConfig($config);
$activated = $this->pluginConfig->getBoolean('pre_checked');
$checkboxes[] = [
'id' => $config['slug'],
'title' => $config['title'],
'action' => ($activated ? $this->getAvailablePluginAction() : 'none'),
'activated' => $activated,
'current_action' => 'none',
'default_action' => ($activated ? null : $this->getAvailablePluginAction()),
];
}
return $checkboxes;
}
/**
* Method returns the url fitting for the context. If a plugin is
* upgradable, the upgrade_url is returned, otherwise the url entry.
*/
public function getPluginUrl(): string
{
if ($this->pluginCanBeUpgraded()) {
return $this->pluginConfig->getUrl('upgrade_url');
}
return $this->pluginConfig->getUrl('url');
}
/**
* Method returns the action fitting for the context of the plugin.
*/
public function getAvailablePluginAction(): string
{
if ($this->premiumPluginIsInstalled()) {
return 'installed';
}
if ($this->pluginIsDownloadable()) {
return 'download';
}
if ($this->pluginCanBeActivated()) {
return 'activate';
}
if ($this->pluginCanBeUpgraded()) {
return 'upgrade-to-premium';
}
return 'installed';
}
/**
* Execute action for a related plugin
*/
public function executeAction(string $action): bool
{
ob_start();
switch ($action) {
case 'download':
$success = $this->downloadCurrentPlugin();
break;
case 'activate':
$success = $this->activateCurrentPlugin();
break;
default:
$success = false;
}
ob_get_clean();
return $success;
}
/**
* Download the related plugin currently stored in the plugin config
* property.
*/
protected function downloadCurrentPlugin(): bool
{
$transientName = 'rsp_plugin_download_active';
if (get_transient($transientName) === $this->pluginConfig->getString('slug')) {
return true;
}
set_transient($transientName, $this->pluginConfig->getString('slug'), MINUTE_IN_SECONDS);
try {
$pluginInfo = $this->getCurrentPluginInfo();
} catch (\Exception $e) {
return false;
}
$downloadLink = esc_url_raw($pluginInfo->versions['trunk']);
require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
include_once ABSPATH . 'wp-admin/includes/plugin-install.php';
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$skin = new \WP_Ajax_Upgrader_Skin();
$upgrader = new \Plugin_Upgrader($skin);
$result = $upgrader->install($downloadLink);
if (is_wp_error($result)) {
return false;
}
delete_transient($transientName);
return true;
}
/**
* Activate the related plugin currently stored in the plugin config
* property.
*/
protected function activateCurrentPlugin(): bool
{
$slug = $this->pluginConfig->getString('activation_slug');
//when activated from the network admin, we assume the user wants network activated
$networkwide = is_multisite() && is_network_admin();
if (!defined('DOING_CRON')) {
define('DOING_CRON', true);
}
if (!function_exists('activate_plugin')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$result = activate_plugin($slug, '', $networkwide);
if (is_wp_error($result)) {
return false;
}
return true;
}
/**
* Helper method to check if the current plugin is a premium plugin and if
* it is active.
*/
protected function premiumPluginIsInstalled(): bool
{
return $this->pluginConfig->has('constant_premium') && defined($this->pluginConfig->getString('constant_premium'));
}
/**
* Helper method to check if the current plugin is downloadable.
*/
protected function pluginIsDownloadable(): bool
{
return $this->pluginFileExists() === false;
}
/**
* Helper method to check if the current plugin can be activated.
*/
protected function pluginCanBeActivated(): bool
{
return $this->pluginFileExists() && ($this->pluginIsActive() === false);
}
/**
* Helper method to check if the current plugin can be upgraded. This means
* the premium version is downloaded, but not yet activated.
*/
protected function pluginCanBeUpgraded(): bool
{
return $this->pluginConfig->has('constant_premium') && !defined($this->pluginConfig->getString('constant_premium'));
}
/**
* Helper method to check if the current plugin file exists.
*/
protected function pluginFileExists(): bool
{
return file_exists(trailingslashit(WP_PLUGIN_DIR) . $this->pluginConfig->getString('activation_slug'));
}
/**
* Helper method to check if the current plugin is active.
*/
public function pluginIsActive(): bool
{
if (!function_exists('is_plugin_active')) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
return is_plugin_active($this->pluginConfig->getString('activation_slug'));
}
/**
* Method returns the plugin info for the current plugin. Because we pass
* the action 'plugin_information' to the plugins_api function, an object is
* returned if the plugin is found, otherwise a WP_Error.
* @throws \Exception If the plugin info could not be retrieved
*/
protected function getCurrentPluginInfo(): object
{
$transientName = 'rsp_' . $this->pluginConfig->getString('slug') . '_plugin_info';
$pluginInfo = get_transient($transientName);
if (!empty($pluginInfo)) {
return $pluginInfo;
}
if (function_exists('plugins_api') === false) {
require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
}
$pluginInfo = plugins_api('plugin_information', [
'slug' => $this->pluginConfig->getString('slug'),
]);
if (is_wp_error($pluginInfo)) {
throw new \Exception('Unable to get plugin info');
}
set_transient($transientName, $pluginInfo, WEEK_IN_SECONDS);
return $pluginInfo;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Services;
final class SecureSocketsService
{
/**
* Method to activate SSL for the current site.
* @return array|bool Array when the current request is a REST request
*
* @todo Move admin method here after full refactor.
*/
public function activateSSL(array $data = [])
{
return RSSSL()->admin->activate_ssl($data);
}
}

View File

@@ -0,0 +1,176 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Services;
/**
* This Settings-service class does NOT do any CRUD actions on the settings. It
* is only responsible for doing business logic based on the fields config of
* the settings. Like returning recommended settings.
*/
class SettingsConfigService
{
/**
* Returns recommended settings. Also includes Pro features when enabled.
* @param bool $includeProFeatures To add/exclude recommended pro settings
*/
public function getRecommendedSettings(bool $includeProFeatures = false): array
{
$features = [
[
'title' => esc_html__('Vulnerability scan', 'really-simple-ssl'),
'id' => 'vulnerability_detection',
'options' => ['enable_vulnerability_scanner'],
'activated' => true,
],
[
'title' => esc_html__('Essential WordPress hardening', 'really-simple-ssl'),
'id' => 'hardening',
'options' => $this->getRecommendedHardeningSettings(),
'activated' => true,
],
[
'title' => esc_html__('E-mail login', 'really-simple-ssl'),
'id' => 'two_fa',
'options' => ['login_protection_enabled'],
'activated' => true,
],
[
'title' => esc_html__('Mixed Content Fixer', 'really-simple-ssl'),
'id' => 'mixed_content_fixer',
'options' => ['mixed_content_fixer'],
'activated' => true,
],
];
if ($includeProFeatures === false) {
return $features;
}
$proFeatures = [
[
'title' => esc_html__('Firewall', 'really-simple-ssl'),
'id' => 'firewall',
'premium' => true,
'options' => ['enable_firewall'],
'activated' => true,
],
[
'title' => esc_html__('Two-Factor Authentication', 'really-simple-ssl'),
'id' => 'two_fa',
'premium' => true,
'options' => ['login_protection_enabled'],
'activated' => true,
],
[
'title' => esc_html__('Limit Login Attempts', 'really-simple-ssl'),
'id' => 'limit_login_attempts',
'premium' => true,
'options' => ['enable_limited_login_attempts', 'enable_limited_password_reset_attempts'],
'activated' => true,
],
[
'title' => esc_html__('Security Headers', 'really-simple-ssl'),
'id' => 'advanced_headers',
'premium' => true,
'options' => [],
'activated' => true,
],
];
return array_merge($features, $proFeatures);
}
/**
* Method returns all recommended setting id's in array format.
* @example [disable_anyone_can_register, disable_file_editing]
*
* Currently, the only settings
* with the 'recommended' key are basic hardening settings:
* {@see /settings/config/fields/hardening-basic.php}
*
* @todo Kept business logic the same, but it needs a refactor to actually
* get the hardening settings.
*/
public function getRecommendedHardeningSettings(): array
{
$fields = rsssl_fields(false);
$recommended = array_filter($fields, static function($field) {
return isset($field['recommended']) && $field['recommended'];
});
return array_map(static function($field) {
return $field['id'];
}, $recommended);
}
/**
* Method returns grouped settings per premium feature. Each item is an
* array containing the related settings listed in the options key.
*
* @todo: Kept business logic the same, but shouldn't we add these to the
* getRecommendedSettings method when $includeProFeatures equals true?
*/
public function getRecommendedProSettings(): array
{
return [
[
'title' => esc_html__('Firewall', 'really-simple-ssl'),
'id' => 'firewall',
'premium' => true,
'options' => ['enable_firewall'],
'activated' => true,
],
[
'title' => esc_html__('Two-Factor Authentication', 'really-simple-ssl'),
'id' => 'two_fa',
'premium' => true,
'options' => ['two_fa_enabled_roles_totp'],
'value' => ['administrator'],
'activated' => true,
],
[
'title' => esc_html__('Limit Login Attempts', 'really-simple-ssl'),
'id' => 'limit_login_attempts',
'premium' => true,
'options' => ['enable_limited_login_attempts', 'enable_limited_password_reset_attempts'],
'activated' => true,
],
[
'title' => esc_html__('Security Headers', 'really-simple-ssl'),
'id' => 'advanced_headers',
'premium' => true,
'options' => [
'upgrade_insecure_requests',
'x_content_type_options',
'hsts',
['x_xss_protection' => 'zero'],
'x_content_type_options',
['x_frame_options' => 'SAMEORIGIN'],
['referrer_policy' => 'strict-origin-when-cross-origin'],
['csp_frame_ancestors' => 'self'],
],
'activated' => true,
],
[
'title' => esc_html__('Vulnerability Measures', 'really-simple-ssl'),
'id' => 'vulnerability_measures',
'options' => ['enable_vulnerability_scanner', 'measures_enabled'],
'activated' => true,
],
[
'title' => esc_html__('Advanced WordPress Hardening', 'really-simple-ssl'),
'id' => 'advanced_hardening',
'premium' => true,
'options' => ['change_debug_log_location', 'disable_http_methods'],
'activated' => true,
],
[
'title' => esc_html__('Strong Password policy', 'really-simple-ssl'),
'id' => 'password_security',
'options' => ['enforce_password_security_enabled', 'enable_hibp_check'],
'activated' => true,
],
];
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Support\Helpers;
use Adbar\Dot;
/**
* Wrapper for easy access to storage data. Create a new instance with an array
* of data in the constructor. Now all data can be accessed using Dot notation.
*
* @usage $storage = new Storage(['key' => 'value']);
* @usage $storage->get('key', 'default');
*/
class Storage extends Dot
{
/**
* Returns the parameter keys.
*/
public function keys(): array
{
return array_keys($this->items);
}
/**
* Returns the sanitized string of the parameter value.
* @uses sanitize_text_field()
*/
public function getString(string $key, string $default = '', bool $trim = false): string
{
$value = sanitize_text_field($this->get($key, $default));
return $trim ? trim($value) : $value;
}
/**
* Returns the sanitized string of the parameter value.
* @uses sanitize_textarea_field()
*/
public function getTextarea(string $key, string $default = '', bool $trim = false): string
{
$value = sanitize_textarea_field($this->get($key, $default));
return $trim ? trim($value) : $value;
}
/**
* Strips out all characters that are not allowable in an email and returns
* the value.
* @uses sanitize_email()
*/
public function getEmail(string $key, string $default = ''): string
{
return sanitize_email($this->get($key, $default));
}
/**
* Returns the parameter value as a slug.
* @uses sanitize_title
*/
public function getTitle(string $key, string $default = ''): string
{
return sanitize_title($this->get($key, $default));
}
/**
* Sanitizes content for allowed HTML tags for post content.
* @uses wp_kses_post()
*/
public function getPost(string $key, string $default = ''): string
{
return wp_kses_post($this->get($key, $default));
}
/**
* Returns a sanitized URL.
* @uses sanitize_url()
*/
public function getUrl(string $key, string $default = ''): string
{
return sanitize_url($this->get($key, $default));
}
/**
* Keys are used as internal identifiers. Lowercase alphanumeric characters,
* dashes, and underscores are allowed.
* @uses sanitize_key()
*/
public function getKey(string $key, string $default = ''): string
{
return sanitize_key($this->get($key, $default));
}
/**
* Returns the alphabetic characters of the parameter value.
*/
public function getAlpha(string $key, string $default = ''): string
{
return preg_replace('/[^[:alpha:]]/', '', $this->get($key, $default));
}
/**
* Returns the alphabetic characters of the parameter value. With spaces.
*/
public function getAlphaSpace(string $key, string $default = ''): string
{
return preg_replace('/[^[:alpha:] ]/', '', $this->get($key, $default));
}
/**
* Returns the alphabetic characters and digits of the parameter value.
*/
public function getAlnum(string $key, string $default = ''): string
{
return preg_replace('/[^[:alnum:]]/', '', $this->get($key, $default));
}
/**
* Returns the digits of the parameter value.
*
* @param string $default The default value runs through
* FILTER_SANITIZE_NUMBER_INT as well
*/
public function getDigits(string $key, string $default = ''): string
{
// we need to remove - and + because they're still allowed by the filter
return str_replace(['-', '+'], '', $this->filter($key, $default, FILTER_SANITIZE_NUMBER_INT));
}
/**
* Returns the parameter value typecast as integer.
*/
public function getInt(string $key, int $default = 0): int
{
return (int) $this->get($key, $default);
}
/**
* Returns the parameter value typecast as float.
*/
public function getFloat(string $key, $default = 0): float
{
return (float) $this->get($key, $default);
}
/**
* Returns the parameter value filtered as a boolean. Uses flag:
* FILTER_VALIDATE_BOOLEAN
*/
public function getBoolean(string $key, $default = false): bool
{
return $this->filter($key, $default, FILTER_VALIDATE_BOOLEAN);
}
/**
* Returns the parameter value validated as a 2 character country code. If
* the preg_match for exactly two alphabetic characters fails, the default
* value is returned.
*/
public function getCountryCode(string $key, string $default = ''): string
{
$country = strtoupper(trim($this->get($key, $default)));
if (preg_match('/^[a-z]{2}$/i', $country)) {
return $country;
}
return $default;
}
/**
* Returns a boolean if the value is considered not empty.
* @param array<TKey>|int|string|null $keys
*/
public function isNotEmpty($keys = null): bool
{
return $this->isEmpty($keys) === false;
}
/**
* Returns a boolean if the value of one of the keys is considered empty.
* @param array<TKey>|int|string|null $keys
*/
public function isOneEmpty($keys = []): bool
{
foreach ($keys as $key) {
if ($this->isEmpty($key)) {
return true;
}
}
return false;
}
/**
* Filter key.
* @return mixed
* @see http://php.net/manual/en/function.filter-var.php
*/
public function filter(string $key, $default = null, int $filter = FILTER_DEFAULT, $options = [])
{
$value = $this->get($key, $default);
// Always turn $options into an array - this allows filter_var option shortcuts.
if (!\is_array($options) && $options) {
$options = ['flags' => $options];
}
// Add a convenience check for arrays.
if (\is_array($value) && !isset($options['flags'])) {
$options['flags'] = FILTER_REQUIRE_ARRAY;
}
return filter_var($value, $filter, $options);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Support\Helpers\Storages;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Environment configuration helper used in DI container.
*/
final class EnvironmentConfig extends Storage
{
public function __construct()
{
parent::__construct(
require dirname(__FILE__, 5) . '/config/env.php'
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Support\Helpers\Storages;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Typed configuration wrapper for config/related.php
*/
final class RelatedConfig extends Storage
{
public function __construct()
{
parent::__construct(
require dirname(__FILE__, 5) . '/config/related.php'
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Support\Helpers\Storages;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Request storage helper used in DI container.
*/
final class RequestStorage extends Storage
{
public function __construct()
{
$body = $this->getRequestBody();
parent::__construct([
'global' => $_REQUEST,
'files' => $_FILES,
'body' => $body,
]);
}
private function getRequestBody(): array
{
$body = [];
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
$input = file_get_contents('php://input');
$decoded = json_decode($input, true);
if (is_array($decoded)) {
$body = $decoded;
}
}
return $body;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Support\Helpers\Storages;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storage;
/**
* Typed configuration wrapper for config/uri.php
*/
final class UriConfig extends Storage
{
public function __construct()
{
parent::__construct(
require dirname(__FILE__, 5) . '/config/uri.php'
);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Support\Utility;
/**
* Utility class for String manipulation.
*/
class StringUtility
{
/**
* Convert a URL to a title.
*
* Strips the site URL from the given URL, replaces dashes with spaces,
* and capitalizes the first letter.
*/
public static function convertUrlToTitle(string $url): string
{
// Strip off the page url from the page name
$site_url = trailingslashit(get_site_url());
$title = str_replace($site_url, '', $url);
$title = str_replace('-', ' ', $title);
// Enforce first letter uppercase
return ucfirst($title);
}
/**
* Convert a string from snake_case to PascalCase.
*/
public static function snakeToPascalCase(string $string): string
{
return str_replace('_', '', ucwords($string, '_'));
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Traits;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
trait HasAllowlistControl
{
/**
* Check if the current code execution allows access to the admin area.
* This is the case when:
* - user is logged in and has manage_security capability
* - this is a REST API request and user is logged in
* - this is a WPCLI request
* - this is a cron request
*
* This ensures that auto updates can run, and cron jobs can complete.
*/
public function adminAccessAllowed(): bool
{
$wpcli = defined('WP_CLI') && WP_CLI;
$currentUserCanVisitAdmin = ((is_admin() || is_network_admin()) && current_user_can('manage_security'));
return $currentUserCanVisitAdmin || $this->restRequestIsAllowed() || wp_doing_cron() || $wpcli;
}
/**
* Check if the current request is authenticated, for a REST API request.
* This is the case when:
* - The request URI is set and contains the plugin namespace
* AND
* - The callback URL is still active, and the request URI contains the callback URL
* OR
* - The user is logged in and has the 'manage_security' capability
*
* @internal Ignore the phpcs errors for this method, as they are false
* positives. We do not actually use the $_GET or $_SERVER variables
* directly, but we need to check if they are set and contain the
* expected values.
*
* @todo Name of this method is not entirely accurate, consider renaming
*/
public function restRequestIsAllowed(): bool
{
$env = App::getInstance()->get(EnvironmentConfig::class);
$pluginNamespace = $env->getString('http.namespace');
$validWpJsonRequest = (
isset($_SERVER['REQUEST_URI'])
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
&& (strpos($_SERVER['REQUEST_URI'], $pluginNamespace) !== false)
);
$validPlainPermalinksRequest = (
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
isset($_GET['rest_route'])
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
&& (strpos($_GET['rest_route'], $pluginNamespace) !== false)
);
if ($validWpJsonRequest === false && $validPlainPermalinksRequest === false) {
return false;
}
return is_user_logged_in() && current_user_can('manage_security');
}
/**
* Check if the current user has the capability to manage the plugin.
* This is the case when:
* - The user is logged in and has the 'manage_security' capability
* - This is a REST API request and the user is logged in
* - This is a WPCLI request
*
* @internal This replaces Helper::user_can_manage()
*/
public function userCanManage(): bool
{
// During activation, we need to allow access
if (get_option('rss_core_activation_flag')) {
return true;
}
if (defined('WP_CLI') && WP_CLI) {
return true;
}
if ($this->restRequestIsAllowed()) {
return true;
}
return current_user_can('manage_security');
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace ReallySimplePlugins\RSS\Core\Traits;
trait HasEncryption
{
/**
* Encrypt a string with a prefix. If the prefix is already there, it's
* already encrypted.
*/
public function maybeEncryptPrefixed(string $data, string $prefix = 'rsssl_'): string
{
if (strpos($data, $prefix) === 0) {
return $data;
}
$data = $this->encrypt($data);
return $prefix . $data;
}
/**
* Decrypt data if prefixed. If not prefixed, return the data, as it is
* already decrypted.
*/
public function maybeDecryptPrefixed(string $data, string $prefix = 'rsssl_', string $deprecatedKey = ''): string
{
if (strpos($data, $prefix) !== 0) {
return $data;
}
$data = substr($data, strlen($prefix));
return $this->decrypt($data, 'string', $deprecatedKey);
}
/**
* Encrypt the given data
*
* @param array|string $data
* @param string $type The $data type that was given ('string' or 'array')
*/
public function encrypt($data, string $type = 'string'): string
{
$key = $this->getEncryptionKey();
if ('array' === strtolower($type)) {
$data = serialize($data);
}
$dataIsEmpty = (strlen(trim($data)) === 0);
$functionsDoNotExists = (
function_exists('openssl_random_pseudo_bytes') === false
|| function_exists('openssl_cipher_iv_length') === false
|| function_exists('openssl_encrypt') === false
);
if ($dataIsEmpty || $functionsDoNotExists) {
return '';
}
$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
$encrypted = openssl_encrypt($data, 'aes-256-cbc', $key, 0, $iv);
return base64_encode($encrypted . '::' . $iv);
}
/**
* Decrypt the given data
*
* @param mixed $data The data to decrypt
* @param string $type The type of data to return ('string' or 'array')
* @return array|string Either array or string, based on the $type
*/
public function decrypt($data, string $type = 'string', string $deprecatedKey = '')
{
$fallbackValue = (strtolower($type) === 'string' ? '' : []);
$key = !empty($deprecatedKey) ? $deprecatedKey : $this->getEncryptionKey();
// If $data is empty, return appropriate empty value based on type
if (empty($data)) {
return $fallbackValue;
}
// If $data is not a string (i.e., it's already an array), return as is
if (!is_string($data)) {
return $data;
}
if (!function_exists('openssl_decrypt')) {
return $fallbackValue;
}
$decoded = base64_decode($data);
if (false === $decoded) {
return $fallbackValue;
}
if (strpos($decoded, '::') !== false) {
[$encrypted_data, $iv] = explode('::', $decoded, 2);
} else {
// Deprecated method, for backwards compatibility (license decryption)
$ivlength = openssl_cipher_iv_length('aes-256-cbc');
$iv = substr($decoded, 0, $ivlength);
$encrypted_data = substr($decoded, $ivlength);
}
$decrypted_data = openssl_decrypt($encrypted_data, 'aes-256-cbc', $key, 0, $iv);
if ('array' === strtolower($type)) {
$unserialized_data = @unserialize($decrypted_data);
return (is_array($unserialized_data)) ? $unserialized_data : [];
}
return $decrypted_data;
}
/**
* Method is used to fetch the encryption key. Used in the encryption and
* decryption processes. The key is a constant stored in the wp-config
* or a key stored in the database.
*/
private function getEncryptionKey(): string
{
return defined('RSSSL_KEY') ? RSSSL_KEY : get_site_option('rsssl_main_key', '');
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Traits;
trait HasNonces
{
/**
* Method for verifying the nonce
* @param mixed $nonce Preferably string, not type-casted to prevent errors
*/
protected function verifyNonce($nonce, string $action = 'rss_core_nonce'): bool
{
if (is_string($nonce) === false) {
return false;
}
return (bool) wp_verify_nonce(sanitize_text_field(wp_unslash($nonce)), $action);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(ticks=1);
namespace ReallySimplePlugins\RSS\Core\Traits;
/**
* Trait DebouncedScheduler
*
* Provides helpers to schedule debounced single cron events using an option-based
* lock to prevent duplicate scheduling within the debounce window.
*/
trait HasScheduler
{
/**
* Schedule a single cron event without debounce.
*
* By default, the event is scheduled for "now". You can optionally pass a
* Unix timestamp to schedule it for a specific moment.
*
* @todo - no use-case yet, test thoroughly before using widely.
*
* @param string $hook Cron hook name.
* @param array $args Optional arguments for the scheduled event.
* @param int|null $timestamp Unix timestamp when the event should run. Defaults to now.
*/
protected function schedule(string $hook, array $args = [], ?int $timestamp = null): void
{
$runAt = $timestamp ?? time();
$existingScheduledTimestamp = wp_next_scheduled($hook, $args);
if ($existingScheduledTimestamp === $runAt) {
return; // An identical event is already scheduled at the same time.
}
wp_schedule_single_event($runAt, $hook, $args);
}
/**
* Schedule a single cron event only if no active lock exists and no identical
* event is already scheduled within the debounce period.
*
* @param string $hook Cron hook name.
* @param int $secondsUntilExecution Debounce period in seconds.
* @param array $args Optional arguments for the scheduled event.
*
* @return void
*/
protected function scheduleDebounced(string $hook, int $secondsUntilExecution, array $args = []): void
{
$now = time();
$lockOption = $hook . '_debounce_lock';
$lockUntil = (int) get_option($lockOption, 0);
// If a lock is still active, nothing to do.
if ($lockUntil > $now) {
return;
}
// Set lock to expire after the debounce period (no autoload).
update_option($lockOption, $now + $secondsUntilExecution, false);
// Schedule only if there is no identical event already queued.
if (wp_next_scheduled($hook, $args) === false) {
wp_schedule_single_event($now + $secondsUntilExecution, $hook, $args);
}
}
/**
* Release the debounce lock when the task completes.
*
* @param string $scheduledEventName Event name where the hook used to store the lock expiry timestamp.
*
* @return void
*/
protected function releaseDebounceLock(string $scheduledEventName): void
{
delete_option($scheduledEventName . '_debounce_lock');
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Traits;
use ReallySimplePlugins\RSS\Core\Bootstrap\App;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
trait HasViews
{
/**
* Method for returning the desired view as a string
* @throws \LogicException
*/
public function view(string $path, array $variables = [], string $extension = 'php'): string
{
$env = App::getInstance()->get(EnvironmentConfig::class);
$basePath = $env->getString('plugin.view_path');
$filePath = realpath($basePath . $path . '.' . $extension);
// Someone is doing something dirty
if (($filePath === false) || (strpos($filePath, $basePath) !== 0)) {
throw new \LogicException('Given path is not valid: ' . esc_html($filePath));
}
if (empty($filePath) || (file_exists($filePath) === false) || (is_readable($filePath) === false)) {
return '';
}
extract($variables);
ob_start();
require $filePath;
return ob_get_clean();
}
/**
* Method for outputting the desired view.
*
* @internal we can ignore the phpcs error because we validate in
* {@see view} that the executed path is in our plugin. And we
* escape all variables in our views, so we have full control.
*/
public function render(string $path, array $variables = [], string $extension = 'php'): void
{
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $this->view($path, $variables, $extension);
}
}

View File

@@ -0,0 +1,15 @@
<?php
/**wp-en
* @var string $severity
* @var string $label
* @var string $frontendUrl
*/
?>
<a href="<?php echo esc_url($frontendUrl); ?>"
target="_blank"
rel="noopener noreferrer">
<span class="rsssl-btn-vulnerable rsssl-<?php echo esc_attr($severity); ?>">
<?php echo esc_html($label); ?>
</span>
</a>

View File

@@ -0,0 +1,9 @@
<?php
/**
* @var string $label
* @var string $class
*/
?>
<a class="rsssl-badge-large <?php echo esc_attr($class); ?>"><?php echo esc_attr($label); ?></a>

View File

@@ -0,0 +1,97 @@
function rssslRenderThemeVulnerabilities() {
if (
typeof window.rssslVulnerabilities === 'undefined' ||
!Array.isArray(window.rssslVulnerabilities.themes)
) {
return;
}
const vulnerableThemes = window.rssslVulnerabilities.themes;
vulnerableThemes.forEach((theme) => {
if (
!theme ||
typeof theme.slug !== 'string' ||
typeof theme.url !== 'string' ||
typeof theme.label !== 'string' ||
typeof theme.info !== 'string' ||
typeof theme.severity !== 'string'
) {
return;
}
const selectors = [
".theme[data-slug='" + theme.slug + "']",
".theme[data-theme='" + theme.slug + "']"
];
let themeElement = null;
for (const selector of selectors) {
themeElement = document.querySelector(selector);
if (themeElement) {
break;
}
}
// console.log('[rsssl] found theme element', themeElement);
if (!themeElement) {
return;
}
themeElement.classList.add('rsssl-vulnerable');
const actionsContainer =
themeElement.querySelector('.theme-id-container') ||
themeElement;
if (actionsContainer.querySelector('.rsssl-theme-vuln-block')) {
return;
}
const block = document.createElement('div');
block.classList.add('rsssl-theme-vuln-block', 'rsssl-' + theme.severity);
const link = document.createElement('a');
link.href = theme.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.classList.add('rsssl-btn-vulnerable', 'rsssl-' + theme.severity);
link.textContent = theme.label;
link.title = theme.label;
const text = document.createElement('span');
text.classList.add('rsssl-theme-vuln-text');
text.textContent = theme.info;
block.appendChild(link);
block.appendChild(text);
actionsContainer.appendChild(block);
});
}
// Try multiple times in case the themes grid is rendered asynchronously.
(function attachRssslThemeVulnerabilities() {
let attempts = 0;
const maxAttempts = 10;
const delay = 400;
function tryRender() {
attempts += 1;
rssslRenderThemeVulnerabilities();
if (attempts < maxAttempts) {
setTimeout(tryRender, delay);
}
}
// Prefer WordPress' themes events when available.
if (window.wp?.themes?.bind) {
window.wp.themes.bind('ready', tryRender);
window.wp.themes.bind('render', tryRender);
} else {
// Fallback for cases where wp.themes is not present.
document.addEventListener('DOMContentLoaded', function () {
tryRender();
});
}
})();

View File

@@ -0,0 +1,227 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Bootstrap;
/**
* Container class that provides dependency injection capabilities to manage
* object creation and resolution. Class is used for retrieving and injecting
* dependencies in a structured and reusable way. This is important because it
* decouples classes from concrete implementations (new..) and makes the
* codebase easier to test and maintain.
*/
class App
{
/**
* Singleton instance holder. Ensures a single container is shared across
* the plugin without globals.
*/
private static ?App $instance = null;
/**
* Registry of service factories indexed by identifier. Allows registering
* lazy factory closures for services.
*
* @var array<string, \Closure>
*/
private array $registry = [];
/**
* Instances of resolved services, indexed by identifier. Prevents
* duplicate instantiation when services are requested multiple times.
*
* @var array<string, object>
*/
private array $instances = [];
/**
* Context-aware bindings: when building a specific class, resolve an interface
* to the configured concrete implementation.
*
* @var array<class-string, array<class-string, class-string>>
*/
private array $contextualBindings = [];
/**
* Private constructor to enforce the singleton pattern. Prevents direct
* instantiation; use getInstance() instead.
*/
private function __construct()
{
}
/**
* Retrieve the shared container instance. Provides a central access point
* to the container for the plugin.
*/
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register a service factory. Defers service creation until first use. The
* provided Closure will be called with no arguments when the service is
* resolved.
*/
public function set(string $name, \Closure $value): void
{
$this->registry[$name] ??= $value;
}
/**
* Register a contextual binding for constructor autowiring.
*
* When the container is building $when and encounters a dependency on $needs,
* it will instead resolve $give.
*
* @param class-string $when When building this class
* @param class-string $needs The interface or class needed
* @param class-string $give The concrete class to provide
*/
public function bindContextual(string $when, string $needs, string $give): void
{
$this->contextualBindings[$when][$needs] = $give;
}
/**
* Resolve an identifier to an object instance. It first checks the
* instances cache, then the registry for a factory. If none is found, it
* calls {@see make} to perform constructor autowiring.
*
* @throws \Exception If the target is not instantiable or cannot resolve a dependency.
* @throws \ReflectionException If reflection fails.
*/
public function get(string $class): object
{
if (array_key_exists($class, $this->instances)) {
return $this->instances[$class];
}
if (array_key_exists($class, $this->registry)) {
$instance = ($this->registry[$class])();
$this->instances[$class] = $instance;
return $instance;
}
return $this->make($class);
}
/**
* Method is used for on-demand construction of an object using constructor
* autowiring without touching the registry. When a constructor parameter
* asks for the Container itself, the current Container instance is injected
* instead of creating a new Container. Class-typed dependencies are
* resolved via {@see get} so factories from the registry are honored, while
* scalars or unresolved parameters require defaults or will result in an
* exception. This keeps "make" safe for ad-hoc instances you may later
* choose to register manually.
*
* @param string $class The class to make. Dependencies are injected.
* @param bool $register Made classes are registered in the container on
* true. Useful for optimization on multi-used classes.
* @param bool $registerDependencies Made dependency classes are registered
* in the container on true. Useful for optimization on multi-used classes.
*
* @throws \Exception If the target is not instantiable or a dependency cannot be resolved.
* @throws \ReflectionException If reflection fails.
*/
public function make(string $class, bool $register = true, bool $registerDependencies = true): object
{
$reflector = new \ReflectionClass($class);
if ($reflector->isInstantiable() === false) {
throw new \Exception("Target [{$class}] is not instantiable.");
}
$constructor = $reflector->getConstructor();
if ($constructor === null) {
return new $class();
}
$arguments = [];
$parameters = $constructor->getParameters();
foreach ($parameters as $parameter) {
$type = $parameter->getType();
// No type hinted: allow default value, otherwise we cannot resolve.
if ($type === null) {
if ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw new \Exception(sprintf(
'Cannot resolve untyped parameter $%s for [%s] without a default value.',
$parameter->getName(),
$class
));
}
// For PHP 7.4 only ReflectionNamedType exists (no unions).
if ($type instanceof \ReflectionNamedType === false) {
throw new \Exception(sprintf(
'Unsupported parameter type for $%s in [%s].',
$parameter->getName(),
$class
));
}
// If nullable and no default, we still must supply something;
if ($type->isBuiltin()) {
if ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw new \Exception(sprintf(
'Cannot autowire builtin parameter $%s (%s) for [%s]. Provide a default or register a factory.',
$parameter->getName(),
$type->getName(),
$class
));
}
$dependencyClass = $type->getName();
if (interface_exists($dependencyClass) && isset($this->contextualBindings[$class][$dependencyClass])) {
$dependencyClass = $this->contextualBindings[$class][$dependencyClass];
}
// Inject the current container, never a new one.
if ($dependencyClass === self::class) {
throw new \Exception(sprintf(
'Cannot resolve App container dependency for $%s in [%s] to prevent circular dependencies.',
$parameter->getName(),
$class
));
}
// Using get() will also resolve dependencies of dependencies
$dependency = $this->get($dependencyClass);
if ($registerDependencies === true) {
$this->instances[$dependencyClass] = $dependency;
}
$arguments[] = $dependency;
}
$made = new $class(...$arguments);
if ($register) {
$this->instances[$class] = $made;
}
return $made;
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
namespace ReallySimplePlugins\RSS\Core\Bootstrap;
use ReallySimplePlugins\RSS\Core\Controllers\DashboardController;
use ReallySimplePlugins\RSS\Core\Controllers\ScheduledTasksController;
use ReallySimplePlugins\RSS\Core\Managers\ControllerManager;
use ReallySimplePlugins\RSS\Core\Managers\EndpointManager;
use ReallySimplePlugins\RSS\Core\Managers\FeatureManager;
use ReallySimplePlugins\RSS\Core\Managers\ProviderManager;
use ReallySimplePlugins\RSS\Core\Support\Helpers\Storages\EnvironmentConfig;
class Plugin
{
private App $app;
private EnvironmentConfig $env;
private FeatureManager $featureManager;
private ProviderManager $providerManager;
private EndpointManager $endpointManager;
private ControllerManager $controllerManager;
/**
* Plugin constructor
*/
public function __construct()
{
$this->app = App::getInstance();
$this->env = $this->app->make(EnvironmentConfig::class);
$this->featureManager = $this->app->make(FeatureManager::class);
$this->providerManager = $this->app->make(ProviderManager::class);
$this->controllerManager = $this->app->make(ControllerManager::class);
$this->endpointManager = $this->app->make(EndpointManager::class);
}
/**
* Boot the plugin
*/
public function boot(): void
{
$this->registerEnvironment();
$pluginBaseFile = (defined('rsssl_file') && !empty(rsssl_file) ? rsssl_file : '');
if (empty($pluginBaseFile)) {
$pluginBaseFile = (dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . plugin_basename(dirname(__DIR__, 2)) . '.php');
}
register_activation_hook($pluginBaseFile, [$this, 'activation']);
register_deactivation_hook($pluginBaseFile, [$this, 'deactivation']);
register_uninstall_hook($pluginBaseFile, 'ReallySimplePlugins\RSS\Core\Bootstrap\Plugin::uninstall');
// Priority BEFORE main plugin to be able to hook into actions/filters
add_action('plugins_loaded', [$this, 'registerProviders'], 5);
add_action('plugins_loaded', [$this, 'loadPluginTextDomain'], 10); // Config must be provided by registerProviders
add_action('rss_core_providers_loaded', [$this->featureManager, 'registerFeatures']); // Makes sure features exist when Controllers need them
add_action('rss_core_features_loaded', [$this, 'registerControllers']); // Control the functionality of the plugin
add_action('rss_core_controllers_loaded', [$this, 'checkForUpgrades']); // Makes sure Controllers can hook into the upgrade process
add_action('rest_api_init', [$this, 'registerEndpoints']);
add_action('admin_init', [$this, 'fireActivationHook']);
}
/**
* Register the plugin environment. The value of the environment will
* determine which domain and app_key are used for the API calls. The
* default value is production and can be [production|development].
* See {@see config/environment.php} for the actual values.
*/
public function registerEnvironment(): void
{
if (!defined('RSS_CORE_ENV')) {
define('RSS_CORE_ENV', 'development');
}
}
/**
* Load the plugin text domain for translations
*/
public function loadPluginTextDomain(): void
{
load_plugin_textdomain('really-simple-ssl', false, $this->env->getString('plugin.lang_path'));
}
/**
* Method that fires on activation. It creates a flag in the database
* options table to indicate that the plugin is being activated. Flag is
* used by {@see fireActivationHook} to run the activation hook only once.
*/
public function activation(): void
{
global $pagenow;
// Set the flag on activation
update_option('rss_core_activation_flag', true, false);
update_option('rss_core_activation_source_page', sanitize_text_field($pagenow), false);
// Flush rewrite rules to ensure the new routes are available
// add_action('shutdown', 'flush_rewrite_rules'); - todo: not yet handled by core
}
/**
* Method fires the activation hook. But only if the plugin is being
* activated. The flag is set in the database options table
* {@see activation} and is used to determine if the plugin is being
* activated. This method removes the flag after it has been used.
*/
public function fireActivationHook(): void
{
if (get_option('rss_core_activation_flag', false) === false) {
return;
}
// Get the source page where the activation was triggered from
$source = get_option('rss_core_activation_source_page', 'unknown');
// Remove the activation flag so the action doesn't run again. Do it
// before the action so its deleted before anything can go wrong.
delete_option('rss_core_activation_flag');
delete_option('rss_core_activation_source_page');
// Gives possibility to hook into the activation process
do_action('rss_core_activation', $source); // !important
}
/**
* Method that fires on deactivation
*/
public function deactivation(): void
{
// Silence is golden
}
/**
* Method that fires on uninstall
*/
public static function uninstall(): void
{
// todo - uninstall not yet handled by core.
}
/**
* Register Plugin providers. Providers will add functionality to the
* container. These functionalities are lazy loaded for initialization
* until its first use. Therefor it is not hooked into an action.
* @uses do_action rss_core_providers_loaded
*/
public function registerProviders(): void
{
$this->providerManager->register([
]);
}
/**
* Register Controllers. Hooked into rss_core_features_loaded to make sure
* features are available to the Controllers.
* @uses do_action rss_core_controllers_loaded
* @uses apply_filters rss_core_controller_classes
*/
public function registerControllers(): void
{
$controllers = apply_filters('rss_core_controller_classes', [
DashboardController::class,
]);
$this->controllerManager->register($controllers);
}
/**
* Register the plugins REST API endpoint instances. Hooked into
* rest_api_init to make sure the REST API is available.
* @uses do_action rss_core_endpoints_loaded
*/
public function registerEndpoints(): void
{
$this->endpointManager->register([
]);
}
/**
* Fire an action when the plugin is upgraded from one version to another.
* Hooked into rss_core_controllers_loaded to make sure Controllers can
* hook into rss_core_plugin_version_upgrade.
*
* @internal Note the starting underscore in the option name. This is to
* prevent the option from being deleted when a user logs out. As if
* it is a private Really Simple Security Core option.
*
* @uses do_action rss_core_plugin_version_upgrade
*/
public function checkForUpgrades(): void
{
// todo - upgrades not yet handled by core.
}
}

View File

@@ -0,0 +1,50 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
$pluginRootPath = dirname(__DIR__, 2);
$pluginBaseFile = $pluginRootPath . DIRECTORY_SEPARATOR . plugin_basename($pluginRootPath) . '.php';
// The environment config can be used BEFORE the 'init' hook.
return [
'plugin' => [
'name' => 'Really Simple Security',
'version' => '9.5.9',
'pro' => false,
'path' => $pluginRootPath,
'base_path' => $pluginBaseFile,
'assets_path' => $pluginRootPath . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR,
'lang_path' => $pluginRootPath . DIRECTORY_SEPARATOR . 'languages' . DIRECTORY_SEPARATOR,
'view_path' => dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'views' . DIRECTORY_SEPARATOR,
'feature_path' => dirname(__DIR__) . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Features' . DIRECTORY_SEPARATOR,
'react_path' => dirname(__DIR__) . DIRECTORY_SEPARATOR . 'react',
'dir' => plugin_basename(dirname(__DIR__, 2)),
'base_file' => plugin_basename(dirname(__DIR__, 2)) . DIRECTORY_SEPARATOR . plugin_basename(dirname(__DIR__, 2)) . '.php',
'lang' => plugin_basename(dirname(__DIR__, 2)) . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'languages',
'url' => plugin_dir_url($pluginBaseFile),
'assets_url' => plugins_url('assets/', $pluginBaseFile),
'plugin_url' => plugin_dir_url($pluginBaseFile),
'dashboard_url' => is_multisite()
? add_query_arg(['page' => 'really-simple-security'], network_admin_url('settings.php'))
: add_query_arg(['page' => 'really-simple-security'], admin_url('admin.php')),
],
'core' => [
'path' => $pluginRootPath . DIRECTORY_SEPARATOR . 'core',
'url' => plugins_url('core/', $pluginBaseFile),
'assets_path' => $pluginRootPath . DIRECTORY_SEPARATOR . 'core/assets' . DIRECTORY_SEPARATOR,
'assets_url' => plugins_url('core/assets/', $pluginBaseFile),
'views_url' => plugins_url('core/app/views/', $pluginBaseFile),
'react_url' => plugins_url('core/react/', $pluginBaseFile),
],
'http' => [
'version' => 'v1',
'namespace' => 'really-simple-security',
],
// Since we don't have enums yet:
'onboarding' => [
'queue_option' => 'rsssl_onboarding_actions_queue',
'queue_event' => 'rsssl_process_onboarding_actions_queue',
]
];

View File

@@ -0,0 +1,52 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
/**
* The related config can only be used AFTER or ON the 'init' hook.
*
* Config documentation:
* pre_checked: The plugin will be pre checked for installation in the onboarding
*/
return [
'plugins' => [
'complianz-gdpr' => [
'slug' => 'complianz-gdpr',
'options_prefix' => 'cmplz',
'activation_slug' => 'complianz-gdpr/complianz-gpdr.php',
'constant_free' => 'cmplz_version',
'constant_premium' => 'cmplz_premium',
'create' => admin_url('admin.php?page=complianz'),
'wordpress_url' => 'https://wordpress.org/plugins/complianz-gdpr/',
'upgrade_url' => 'https://complianz.io?src=rsssl-plugin',
'title' => 'Complianz - ' . (did_action('init') ? esc_html__('Consent Management as it should be', 'really-simple-ssl') : 'Consent Management as it should be'),
'color' => '#009fff',
"pre_checked" => true,
],
'complianz-terms-conditions' => [
'slug' => 'complianz-terms-conditions',
'options_prefix' => 'cmplz_tc',
'activation_slug' => 'complianz-terms-conditions/complianz-terms-conditions.php',
'constant_free' => 'cmplz_tc_version',
'create' => admin_url('admin.php?page=terms-conditions'),
'wordpress_url' => 'https://wordpress.org/plugins/complianz-terms-conditions/',
'upgrade_url' => 'https://complianz.io?src=rsssl-plugin',
'title' => 'Complianz - ' . (did_action('init') ? esc_html__('Terms & Conditions', 'really-simple-ssl') : 'Terms & Conditions'),
'color' => '#000000',
"pre_checked" => true,
],
'simplybook' => [
'slug' => 'simplybook',
'options_prefix' => 'simplybook',
'activation_slug' => 'simplybook/simplybook.php',
'create' => admin_url('admin.php?page=simplybook-integration'),
'wordpress_url' => 'https://wordpress.org/plugins/simplybook/',
'upgrade_url' => 'https://simplybook.me/en/pricing',
'title' => 'SimplyBook.me - ' . (did_action('init') ? esc_html__('Online Booking System', 'really-simple-ssl') : 'Online Booking System'),
'color' => '#06ADEF',
"pre_checked" => false,
],
],
];

View File

@@ -0,0 +1,14 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
return [
'rsp' => [
'mailinglist' => 'https://mailinglist.really-simple-ssl.com',
'upgrade_from_free' => 'https://really-simple-ssl.com/pro?mtm_campaign=security&mtm_source=free&mtm_content=upgrade',
'vul_updates_manual' => 'https://really-simple-ssl.com/manual/vulnerabilities#updates',
'vul_quarantine_manual' => 'https://really-simple-ssl.com/manual/vulnerabilities#quarantine',
]
];

View File

@@ -0,0 +1,14 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
return [
'client' => [
'base_uri' => 'https://vulnerabilities.really-simple-security.com',
'namespace' => 'rsp-api',
'version' => 'v2',
'endpoint' => 'vulnerabilities',
],
];

View File

@@ -0,0 +1 @@
<?php

View File

@@ -0,0 +1,31 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
/**
* Find the Jetpack packages autoloader.
* @see https://packagist.org/packages/automattic/jetpack-autoloader
*/
$autoloaderFilePath = __DIR__ . '/vendor/autoload_packages.php';
if (file_exists($autoloaderFilePath) === false) {
error_log('Really Simple Security: Core could not be booted, run `composer install` first.');
return;
}
// When it exists we require the Jetpack packages autoloader.
require_once $autoloaderFilePath;
// Prevent boot when the core Plugin file is missing.
if (class_exists(\ReallySimplePlugins\RSS\Core\Bootstrap\Plugin::class) === false) {
error_log('Really Simple Security: Core could not be booted, main `Plugin` class could not be found.');
return;
}
// Boot.
$corePlugin = new \ReallySimplePlugins\RSS\Core\Bootstrap\Plugin();
$corePlugin->boot();
// Cleanup.
unset($corePlugin);

View File

@@ -0,0 +1,21 @@
# The MIT License (MIT)
Copyright (c) 2016-2022 Riku Särkinen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,675 @@
<?php
/**
* Dot - PHP dot notation access to arrays
*
* @author Riku Särkinen <riku@adbar.io>
* @link https://github.com/adbario/php-dot-notation
* @license https://github.com/adbario/php-dot-notation/blob/3.x/LICENSE.md (MIT License)
*/
namespace Adbar;
use Countable;
use ArrayAccess;
use ArrayIterator;
use JsonSerializable;
use IteratorAggregate;
use Traversable;
/**
* Dot
*
* This class provides a dot notation access and helper functions for
* working with arrays of data. Inspired by Laravel Collection.
*
* @template TKey of array-key
* @template TValue mixed
*
* @implements \ArrayAccess<TKey, TValue>
* @implements \IteratorAggregate<TKey, TValue>
*/
class Dot implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable
{
/**
* The stored items
*
* @var array<TKey, TValue>
*/
protected $items = [];
/**
* The character to use as a delimiter, defaults to dot (.)
*
* @var non-empty-string
*/
protected $delimiter = ".";
/**
* Create a new Dot instance
*
* @param mixed $items
* @param bool $parse
* @param non-empty-string $delimiter
* @return void
*/
public function __construct($items = [], $parse = false, $delimiter = ".")
{
$items = $this->getArrayItems($items);
$this->delimiter = $delimiter ?: ".";
if ($parse) {
$this->set($items);
} else {
$this->items = $items;
}
}
/**
* Set a given key / value pair or pairs
* if the key doesn't exist already
*
* @param array<TKey, TValue>|int|string $keys
* @param mixed $value
* @return $this
*/
public function add($keys, $value = null)
{
if (is_array($keys)) {
foreach ($keys as $key => $value) {
$this->add($key, $value);
}
} elseif ($this->get($keys) === null) {
$this->set($keys, $value);
}
return $this;
}
/**
* Return all the stored items
*
* @return array<TKey, TValue>
*/
public function all()
{
return $this->items;
}
/**
* Delete the contents of a given key or keys
*
* @param array<TKey>|int|string|null $keys
* @return $this
*/
public function clear($keys = null)
{
if ($keys === null) {
$this->items = [];
return $this;
}
$keys = (array) $keys;
foreach ($keys as $key) {
$this->set($key, []);
}
return $this;
}
/**
* Delete the given key or keys
*
* @param array<TKey>|array<TKey, TValue>|int|string $keys
* @return $this
*/
public function delete($keys)
{
$keys = (array) $keys;
foreach ($keys as $key) {
if ($this->exists($this->items, $key)) {
unset($this->items[$key]);
continue;
}
$items = &$this->items;
$segments = explode($this->delimiter, $key);
$lastSegment = array_pop($segments);
foreach ($segments as $segment) {
if (!isset($items[$segment]) || !is_array($items[$segment])) {
continue 2;
}
$items = &$items[$segment];
}
unset($items[$lastSegment]);
}
return $this;
}
/**
* Checks if the given key exists in the provided array.
*
* @param array<TKey, TValue> $array Array to validate
* @param int|string $key The key to look for
* @return bool
*/
protected function exists($array, $key)
{
return array_key_exists($key, $array);
}
/**
* Flatten an array with the given character as a key delimiter
*
* @param string $delimiter
* @param mixed $items
* @param string $prepend
* @return array<TKey, TValue>
*/
public function flatten($delimiter = '.', $items = null, $prepend = '')
{
$flatten = [];
if ($items === null) {
$items = $this->items;
}
foreach ($items as $key => $value) {
if (is_array($value) && !empty($value)) {
$flatten[] = $this->flatten($delimiter, $value, $prepend . $key . $delimiter);
} else {
$flatten[] = [$prepend . $key => $value];
}
}
return array_merge(...$flatten);
}
/**
* Return the value of a given key
*
* @param int|string|null $key
* @param mixed $default
* @return mixed
*/
public function get($key = null, $default = null)
{
if ($key === null) {
return $this->items;
}
if ($this->exists($this->items, $key)) {
return $this->items[$key];
}
if (!is_string($key) || strpos($key, $this->delimiter) === false) {
return $default;
}
$items = $this->items;
foreach (explode($this->delimiter, $key) as $segment) {
if (!is_array($items) || !$this->exists($items, $segment)) {
return $default;
}
$items = &$items[$segment];
}
return $items;
}
/**
* Return the given items as an array
*
* @param array<TKey, TValue>|self<TKey, TValue>|object|string $items
* @return array<TKey, TValue>
*/
protected function getArrayItems($items)
{
if (is_array($items)) {
return $items;
}
if ($items instanceof self) {
return $items->all();
}
return (array) $items;
}
/**
* Check if a given key or keys exists
*
* @param array<TKey>|int|string $keys
* @return bool
*/
public function has($keys)
{
$keys = (array) $keys;
if (!$this->items || $keys === []) {
return false;
}
foreach ($keys as $key) {
$items = $this->items;
if ($this->exists($items, $key)) {
continue;
}
foreach (explode($this->delimiter, $key) as $segment) {
if (!is_array($items) || !$this->exists($items, $segment)) {
return false;
}
$items = $items[$segment];
}
}
return true;
}
/**
* Check if a given key or keys are empty
*
* @param array<TKey>|int|string|null $keys
* @return bool
*/
public function isEmpty($keys = null)
{
if ($keys === null) {
return empty($this->items);
}
$keys = (array) $keys;
foreach ($keys as $key) {
if (!empty($this->get($key))) {
return false;
}
}
return true;
}
/**
* Merge a given array or a Dot object with the given key
* or with the whole Dot object
*
* @param array<TKey, TValue>|self<TKey, TValue>|string $key
* @param array<TKey, TValue>|self<TKey, TValue> $value
* @return $this
*/
public function merge($key, $value = [])
{
if (is_array($key)) {
$this->items = array_merge($this->items, $key);
} elseif (is_string($key)) {
$items = (array) $this->get($key);
$value = array_merge($items, $this->getArrayItems($value));
$this->set($key, $value);
} elseif ($key instanceof self) {
$this->items = array_merge($this->items, $key->all());
}
return $this;
}
/**
* Recursively merge a given array or a Dot object with the given key
* or with the whole Dot object.
*
* Duplicate keys are converted to arrays.
*
* @param array<TKey, TValue>|self<TKey, TValue>|string $key
* @param array<TKey, TValue>|self<TKey, TValue> $value
* @return $this
*/
public function mergeRecursive($key, $value = [])
{
if (is_array($key)) {
$this->items = array_merge_recursive($this->items, $key);
} elseif (is_string($key)) {
$items = (array) $this->get($key);
$value = array_merge_recursive($items, $this->getArrayItems($value));
$this->set($key, $value);
} elseif ($key instanceof self) {
$this->items = array_merge_recursive($this->items, $key->all());
}
return $this;
}
/**
* Recursively merge a given array or a Dot object with the given key
* or with the whole Dot object.
*
* Instead of converting duplicate keys to arrays, the value from
* given array will replace the value in Dot object.
*
* @param array<TKey, TValue>|self<TKey, TValue>|string $key
* @param array<TKey, TValue>|self<TKey, TValue> $value
* @return $this
*/
public function mergeRecursiveDistinct($key, $value = [])
{
if (is_array($key)) {
$this->items = $this->arrayMergeRecursiveDistinct($this->items, $key);
} elseif (is_string($key)) {
$items = (array) $this->get($key);
$value = $this->arrayMergeRecursiveDistinct($items, $this->getArrayItems($value));
$this->set($key, $value);
} elseif ($key instanceof self) {
$this->items = $this->arrayMergeRecursiveDistinct($this->items, $key->all());
}
return $this;
}
/**
* Merges two arrays recursively. In contrast to array_merge_recursive,
* duplicate keys are not converted to arrays but rather overwrite the
* value in the first array with the duplicate value in the second array.
*
* @param array<TKey, TValue>|array<TKey, array<TKey, TValue>> $array1 Initial array to merge
* @param array<TKey, TValue>|array<TKey, array<TKey, TValue>> $array2 Array to recursively merge
* @return array<TKey, TValue>|array<TKey, array<TKey, TValue>>
*/
protected function arrayMergeRecursiveDistinct(array $array1, array $array2)
{
$merged = &$array1;
foreach ($array2 as $key => $value) {
if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])) {
$merged[$key] = $this->arrayMergeRecursiveDistinct($merged[$key], $value);
} else {
$merged[$key] = $value;
}
}
return $merged;
}
/**
* Return the value of a given key and
* delete the key
*
* @param int|string|null $key
* @param mixed $default
* @return mixed
*/
public function pull($key = null, $default = null)
{
if ($key === null) {
$value = $this->all();
$this->clear();
return $value;
}
$value = $this->get($key, $default);
$this->delete($key);
return $value;
}
/**
* Push a given value to the end of the array
* in a given key
*
* @param mixed $key
* @param mixed $value
* @return $this
*/
public function push($key, $value = null)
{
if ($value === null) {
$this->items[] = $key;
return $this;
}
$items = $this->get($key);
if (is_array($items) || $items === null) {
$items[] = $value;
$this->set($key, $items);
}
return $this;
}
/**
* Replace all values or values within the given key
* with an array or Dot object
*
* @param array<TKey, TValue>|self<TKey, TValue>|string $key
* @param array<TKey, TValue>|self<TKey, TValue> $value
* @return $this
*/
public function replace($key, $value = [])
{
if (is_array($key)) {
$this->items = array_replace($this->items, $key);
} elseif (is_string($key)) {
$items = (array) $this->get($key);
$value = array_replace($items, $this->getArrayItems($value));
$this->set($key, $value);
} elseif ($key instanceof self) {
$this->items = array_replace($this->items, $key->all());
}
return $this;
}
/**
* Set a given key / value pair or pairs
*
* @param array<TKey, TValue>|int|string $keys
* @param mixed $value
* @return $this
*/
public function set($keys, $value = null)
{
if (is_array($keys)) {
foreach ($keys as $key => $value) {
$this->set($key, $value);
}
return $this;
}
$items = &$this->items;
if (is_string($keys)) {
foreach (explode($this->delimiter, $keys) as $key) {
if (!isset($items[$key]) || !is_array($items[$key])) {
$items[$key] = [];
}
$items = &$items[$key];
}
}
$items = $value;
return $this;
}
/**
* Replace all items with a given array
*
* @param mixed $items
* @return $this
*/
public function setArray($items)
{
$this->items = $this->getArrayItems($items);
return $this;
}
/**
* Replace all items with a given array as a reference
*
* @param array<TKey, TValue> $items
* @return $this
*/
public function setReference(array &$items)
{
$this->items = &$items;
return $this;
}
/**
* Return the value of a given key or all the values as JSON
*
* @param mixed $key
* @param int $options
* @return string|false
*/
public function toJson($key = null, $options = 0)
{
if (is_string($key)) {
return json_encode($this->get($key), $options);
}
$options = $key === null ? 0 : $key;
return json_encode($this->items, $options);
}
/**
* Output or return a parsable string representation of the
* given array when exported by var_export()
*
* @param array<TKey, TValue> $items
* @return object
*/
public static function __set_state(array $items): object
{
return (object) $items;
}
/*
* --------------------------------------------------------------
* ArrayAccess interface
* --------------------------------------------------------------
*/
/**
* Check if a given key exists
*
* @param int|string $key
* @return bool
*/
public function offsetExists($key): bool
{
return $this->has($key);
}
/**
* Return the value of a given key
*
* @param int|string $key
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($key)
{
return $this->get($key);
}
/**
* Set a given value to the given key
*
* @param int|string|null $key
* @param mixed $value
*/
public function offsetSet($key, $value): void
{
if ($key === null) {
$this->items[] = $value;
return;
}
$this->set($key, $value);
}
/**
* Delete the given key
*
* @param int|string $key
* @return void
*/
public function offsetUnset($key): void
{
$this->delete($key);
}
/*
* --------------------------------------------------------------
* Countable interface
* --------------------------------------------------------------
*/
/**
* Return the number of items in a given key
*
* @param int|string|null $key
* @return int
*/
public function count($key = null): int
{
return count($this->get($key));
}
/*
* --------------------------------------------------------------
* IteratorAggregate interface
* --------------------------------------------------------------
*/
/**
* Get an iterator for the stored items
*
* @return \ArrayIterator<TKey, TValue>
*/
public function getIterator(): Traversable
{
return new ArrayIterator($this->items);
}
/*
* --------------------------------------------------------------
* JsonSerializable interface
* --------------------------------------------------------------
*/
/**
* Return items for JSON serialization
*
* @return array<TKey, TValue>
*/
public function jsonSerialize(): array
{
return $this->items;
}
}

View File

@@ -0,0 +1,26 @@
<?php
/**
* Dot - PHP dot notation access to arrays
*
* @author Riku Särkinen <riku@adbar.io>
* @link https://github.com/adbario/php-dot-notation
* @license https://github.com/adbario/php-dot-notation/blob/3.x/LICENSE.md (MIT License)
*/
use Adbar\Dot;
if (! function_exists('dot')) {
/**
* Create a new Dot object with the given items
*
* @param mixed $items
* @param bool $parse
* @param non-empty-string $delimiter
* @return \Adbar\Dot<array-key, mixed>
*/
function dot($items, $parse = false, $delimiter = ".")
{
return new Dot($items, $parse, $delimiter);
}
}

View File

@@ -0,0 +1,22 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitb362f03b12b29b02131a59d9d1231943::getLoader();

View File

@@ -0,0 +1,13 @@
<?php
/**
* This file was automatically generated by automattic/jetpack-autoloader.
*
* @package automattic/jetpack-autoloader
*/
namespace Automattic\Jetpack\Autoloader\jpb362f03b12b29b02131a59d9d1231943\al5_0_15;
// phpcs:ignore
require_once __DIR__ . '/jetpack-autoloader/class-autoloader.php';
Autoloader::init();

View File

@@ -0,0 +1,562 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [5.0.15] - 2025-12-15
### Changed
- Internal updates.
## [5.0.14] - 2025-12-08
### Fixed
- Ensure proper flags are used with `json_encode()`. [#46092]
## [5.0.13] - 2025-11-12
### Changed
- Internal updates.
## [5.0.12] - 2025-11-10
### Fixed
- Tests: Improve compatibility with PHP 8.5. [#45771]
## [5.0.11] - 2025-10-06
### Fixed
- Tests: Replace deprecated `RunClassInSeparateProcess` attribute with `RunTestsInSeparateProcesses`. [#45370]
## [5.0.10] - 2025-09-15
### Changed
- Internal updates.
## [5.0.9] - 2025-07-28
### Changed
- Exclude development files from production build of the package. [#44456]
## [5.0.8] - 2025-06-23
### Fixed
- Autoloader: Prevent double slash in autoloader path. [#44030]
## [5.0.7] - 2025-04-28
### Changed
- Internal updates.
## [5.0.6] - 2025-03-31
### Changed
- Internal updates.
## [5.0.5] - 2025-03-21
### Changed
- Internal updates.
## [5.0.4] - 2025-03-17
### Changed
- Internal updates.
## [5.0.3] - 2025-03-12
### Changed
- Internal updates.
## [5.0.2] - 2025-02-24
### Changed
- Internal updates.
## [5.0.1] - 2025-01-20
### Changed
- Code: Use function-style exit() and die() with a default status code of 0. [#41167]
## [5.0.0] - 2024-11-25
### Removed
- Drop support for Composer <2.2. [#40297]
- Remove support for WordPress 6.5 and earlier. [#40200]
## [4.0.0] - 2024-11-14
### Removed
- General: Update minimum PHP version to 7.2. [#40147]
## [3.1.3] - 2024-11-04
### Added
- Enable test coverage. [#39961]
## [3.1.2] - 2024-10-15
### Changed
- Internal updates.
## [3.1.1] - 2024-10-10
### Changed
- Internal updates.
## [3.1.0] - 2024-09-06
### Added
- Add logic for debugging issues caused by conflicting Composer autoloaders, enabled by setting the `JETPACK_AUTOLOAD_DEBUG_CONFLICTING_LOADERS` constant. [#38995]
- Add logic for debugging issues caused by early class loads, enabled by setting the `JETPACK_AUTOLOAD_DEBUG_EARLY_LOADS` constant. [#38995]
## [3.0.10] - 2024-08-26
### Changed
- Updated package dependencies. [#39004]
## [3.0.9] - 2024-07-10
### Fixed
- Avoid a deprecation notice in `Autoloader_Locator::find_latest_autoloader()`. [#38245]
## [3.0.8] - 2024-05-29
### Fixed
- `AutoloadGenerator::__construct` no longer pretends `$io` is nullable. That never worked. [#37608]
## [3.0.7] - 2024-05-06
### Fixed
- Avoid deprecation notices when plugin path is null. [#37174]
## [3.0.6] - 2024-04-22
### Changed
- Internal updates.
## [3.0.5] - 2024-04-11
### Changed
- Internal updates.
## [3.0.4] - 2024-03-18
### Changed
- Internal updates.
## [3.0.3] - 2024-03-14
### Changed
- Internal updates.
## [3.0.2] - 2023-11-21
## [3.0.1] - 2023-11-21
## [3.0.0] - 2023-11-20
### Changed
- Updated required PHP version to >= 7.0. [#34192]
## [2.12.0] - 2023-09-28
### Added
- Add an `AutoloadGenerator::VERSION` constant, and use that for the autoloader's version in preference to whatever Composer has. [#33156]
## [2.11.23] - 2023-09-19
- Minor internal updates.
## [2.11.22] - 2023-08-23
### Changed
- Updated package dependencies. [#32605]
## [2.11.21] - 2023-05-22
### Added
- Set keywords in `composer.json`. [#30756]
## [2.11.20] - 2023-05-11
- Updated package dependencies
## [2.11.19] - 2023-04-25
### Fixed
- Fix example in README [#30225]
## [2.11.18] - 2023-03-28
### Changed
- Minor internal updates.
## [2.11.17] - 2023-03-27
### Fixed
- Don't error when processing packages specifying missing PSR paths. [#29669]
## [2.11.16] - 2023-02-20
### Changed
- Minor internal updates.
## [2.11.15] - 2023-01-11
### Changed
- Updated package dependencies.
## [2.11.14] - 2022-12-19
### Changed
- Use `Composer\ClassMapGenerator\ClassMapGenerator` when available (i.e. with composer 2.4). [#27812]
### Fixed
- Declare fields for PHP 8.2 compatibility. [#27949]
## [2.11.13] - 2022-12-02
### Changed
- Updated package dependencies. [#27688]
## [2.11.12] - 2022-11-22
### Changed
- Updated package dependencies. [#27043]
## [2.11.11] - 2022-10-25
### Changed
- Sort data in generated `vendor/composer/jetpack_autoload_classmap.php` to avoid spurious diffs. [#26929]
## [2.11.10] - 2022-10-05
- Tests: Clear `COMPOSER_AUTH` environment variable when running Composer for tests. [#26404]
## [2.11.9] - 2022-09-27
### Fixed
- Tests: Clear `COMPOSER_AUTH` environment variable when running Composer for tests. [#26404]
## [2.11.8] - 2022-09-20
### Fixed
- Tests: skip test if it requires a version of Composer not compatible with the running version of PHP. [#26143]
## [2.11.7] - 2022-07-26
### Changed
- Updated package dependencies. [#25158]
## [2.11.6] - 2022-06-21
### Changed
- Renaming `master` to `trunk`.
## [2.11.5] - 2022-05-18
### Fixed
- Fix new PHPCS sniffs. [#24366]
## [2.11.4] - 2022-04-26
### Changed
- Updated package dependencies.
## [2.11.3] - 2022-04-19
### Changed
- PHPCS: Fix `WordPress.Security.ValidatedSanitizedInput`
## [2.11.2] - 2022-03-29
### Changed
- Microperformance: Use === null instead of is_null
## [2.11.1] - 2022-03-08
### Removed
- Removed the Upgrade Handler.
## [2.11.0] - 2022-03-08
### Added
- On plugin update, pre-load all (non-PSR-4) classes from the plugin to avoid mid-upgrade fatals.
## [2.10.13] - 2022-03-01
### Fixed
- Fix tests for upstream phpunit change.
## [2.10.12] - 2022-01-25
### Changed
- Updated package dependencies.
## [2.10.11] - 2022-01-04
### Changed
- Switch to pcov for code coverage.
- Updated package dependencies
## [2.10.10] - 2021-11-16
### Added
- Soft return if autoloader chain is not available.
## [2.10.9] - 2021-11-02
### Changed
- Set `convertDeprecationsToExceptions` true in PHPUnit config.
## [2.10.8] - 2021-10-13
### Changed
- Updated package dependencies.
## [2.10.7] - 2021-10-07
### Changed
- Updated package dependencies
## [2.10.6] - 2021-09-28
### Changed
- Updated package dependencies.
## [2.10.5] - 2021-08-31
### Changed
- Run composer update on test-php command instead of phpunit
- Tests: update PHPUnit polyfills dependency (yoast/phpunit-polyfills).
## [2.10.4] - 2021-08-10
### Changed
- Updated package dependencies.
## [2.10.3] - 2021-05-25
### Changed
- Updated package dependencies.
## [2.10.2] - 2021-04-27
### Changed
- Updated package dependencies.
## [2.10.1] - 2021-03-30
### Added
- Composer alias for dev-master, to improve dependencies
- Tests: Added code coverage transformation
### Changed
- Update package dependencies.
### Fixed
- Fix coverage test
- Fix uninstallation fatal
- Update tests for changed composer 2.0.9 hash.
- Use `composer update` rather than `install` in scripts, as composer.lock isn't checked in.
## [2.10.0] - 2021-02-09
- Autoloader: test suite refactor
## [2.9.1] - 2021-02-05
- CI: Make tests more generic
- Autoloader: stricter type-checking on WP functions
- Autoloader: prevent transitive plugin execution
## [2.9.0] - 2021-01-25
- Autoloader: revised latest autoloader inclusion semantics
- Add mirror-repo information to all current composer packages
- Monorepo: Reorganize all projects
- Autoloader: Don't cache deactivating plugins
## [2.8.0] - 2020-12-18
## [2.7.1] - 2020-12-18
- Autoloader: Added realpath resolution to plugin paths
## [2.7.0] - 2020-12-08
- Autoloader: Preemptively load unknown plugins from cache
- Removed unwanted dot
- Pin dependencies
- Packages: Update for PHP 8 testing
## [2.6.0] - 2020-11-19
- Autoloader: AutoloadGenerator no longer extends Composer's AutoloadGenerator class
- Autoloader: Reuse an existing autoloader suffix if available
- Updated PHPCS: Packages and Debugger
## [2.5.0] - 2020-10-08
- Autoloader: remove the defined('JETPACK_AUTOLOAD_DEV') checks from the tests
## [2.4.0] - 2020-09-28
- Autoloader: remove the plugins_loaded bullet point from the README
- Packages: avoid PHPCS warnings
- Autoloader: add PSR-0 support
- Autoloader: Detect filtering of active_plugins
- Autoloader: Support unoptimized PSR-4
## [2.3.0] - 2020-08-21
- Autoloader: remove the plugin update hook
## [2.2.0] - 2020-08-14
- Autoloader: don't reset the autoloader version during plugin update
- CI: Try collect js coverage
## [2.1.0] - 2020-07-27
- Autoloader: convert '\' directory separators to '/' in plugin paths
- Autoloader: Avoid a PHP warning when an empty string is passed to `is_directory_plugin()`.
- Autoloader: Tests: Use a string with define
## [2.0.2] - 2020-07-09
- Autoloader: Avoid a PHP warning when an empty string is passed to `is_directory_plugin()`.
## [2.0.1] - 2020-07-02
- Autoloader: Tests: Use a string with define
## [2.0.0] - 2020-06-29
## [2.0.0-beta] - 2020-06-29
- Autoloader: Support Composer v2.0
- Autoloader: use paths to identify plugins instead of the directories
- Autoloader: fix the fatal that occurs during plugin update
- Autoloader: add fallback check for plugin path in mu-plugins
- Autoloader: use JETPACK__PLUGIN_DIR when looking for the jetpack plugin directory.
- Feature Branch: Update the Autoloader
- PHPCS: Clean up the packages
- PHPCS Updates after WPCS 2.3
## [1.7.0] - 2020-04-23
- Jetpack: Move comment notification override back to the constructor
## [1.6.0] - 2020-03-26
- Autoloader: Remove file check to improve performance.
## [1.5.0] - 2020-02-25
- Jetpack: instantiate manager object if it's null
## [1.4.1] - 2020-02-14
- Autoloader: Load only latest version of autoload files to avoid conflicts.
## [1.4.0] - 2020-01-23
- Autoloader: Remove the ignored classes
## [1.3.8] - 2020-01-14
- Trying to add deterministic initialization.
- Autoloader: Remove Manager_Interface and Plugin\Tracking from ignored list
- Autoloader: Remove Jetpack_IXR_Client from ignore list
## [1.3.7] - 2019-12-10
## [1.3.6] - 2019-12-09
- Autoloader: Use long-form sytax for array
## [1.3.5] - 2019-11-26
- Fix/php notice status
## [1.3.4] - 2019-11-08
- Deprecate Jetpack::is_development_mode() in favor of the packaged Status()-&gt;is_development_mode()
## [1.3.3] - 2019-10-28
- Packages: Add gitattributes files to all packages that need th…
## [1.3.2] - 2019-09-24
- Autoloader: Cover scenarios where composer/autoload_files.php…
## [1.3.1] - 2019-09-20
- Docs: Unify usage of @package phpdoc tags
## [1.3.0] - 2019-09-14
- Fix for empty namespaces. #13459
- Connection: Move the Jetpack IXR client to the package
- Adds full connection cycle capability to the Connection package.
- Jetpack 7.5: Back compatibility package
## [1.2.0] - 2019-06-24
- Jetpack DNA: Add full classmap support to Autoloader
- Move Jetpack_Sync_Main from legacy to PSR-4
## [1.1.0] - 2019-06-19
- Packages: Move autoloader tests to the package
- DNA: Move Jetpack Usage tracking to its own file
- Jetpack DNA: More isolation of Tracks Package
- Autoloader: Ignore XMLRPC_Connector if called too early
- Autoloader: Ignore Jetpack_Signature if called too early
## 1.0.0 - 2019-06-11
- Add Custom Autoloader
[5.0.15]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.14...v5.0.15
[5.0.14]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.13...v5.0.14
[5.0.13]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.12...v5.0.13
[5.0.12]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.11...v5.0.12
[5.0.11]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.10...v5.0.11
[5.0.10]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.9...v5.0.10
[5.0.9]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.8...v5.0.9
[5.0.8]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.7...v5.0.8
[5.0.7]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.6...v5.0.7
[5.0.6]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.5...v5.0.6
[5.0.5]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.4...v5.0.5
[5.0.4]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.3...v5.0.4
[5.0.3]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.2...v5.0.3
[5.0.2]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.1...v5.0.2
[5.0.1]: https://github.com/Automattic/jetpack-autoloader/compare/v5.0.0...v5.0.1
[5.0.0]: https://github.com/Automattic/jetpack-autoloader/compare/v4.0.0...v5.0.0
[4.0.0]: https://github.com/Automattic/jetpack-autoloader/compare/v3.1.3...v4.0.0
[3.1.3]: https://github.com/Automattic/jetpack-autoloader/compare/v3.1.2...v3.1.3
[3.1.2]: https://github.com/Automattic/jetpack-autoloader/compare/v3.1.1...v3.1.2
[3.1.1]: https://github.com/Automattic/jetpack-autoloader/compare/v3.1.0...v3.1.1
[3.1.0]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.10...v3.1.0
[3.0.10]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.9...v3.0.10
[3.0.9]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.8...v3.0.9
[3.0.8]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.7...v3.0.8
[3.0.7]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.6...v3.0.7
[3.0.6]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.5...v3.0.6
[3.0.5]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.4...v3.0.5
[3.0.4]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.3...v3.0.4
[3.0.3]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.2...v3.0.3
[3.0.2]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.1...v3.0.2
[3.0.1]: https://github.com/Automattic/jetpack-autoloader/compare/v3.0.0...v3.0.1
[3.0.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.12.0...v3.0.0
[2.12.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.23...v2.12.0
[2.11.23]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.22...v2.11.23
[2.11.22]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.21...v2.11.22
[2.11.21]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.20...v2.11.21
[2.11.20]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.19...v2.11.20
[2.11.19]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.18...v2.11.19
[2.11.18]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.17...v2.11.18
[2.11.17]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.16...v2.11.17
[2.11.16]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.15...v2.11.16
[2.11.15]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.14...v2.11.15
[2.11.14]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.13...v2.11.14
[2.11.13]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.12...v2.11.13
[2.11.12]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.11...v2.11.12
[2.11.11]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.10...v2.11.11
[2.11.10]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.9...v2.11.10
[2.11.9]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.8...v2.11.9
[2.11.8]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.7...v2.11.8
[2.11.7]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.6...v2.11.7
[2.11.6]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.5...v2.11.6
[2.11.5]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.4...v2.11.5
[2.11.4]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.3...v2.11.4
[2.11.3]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.2...v2.11.3
[2.11.2]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.1...v2.11.2
[2.11.1]: https://github.com/Automattic/jetpack-autoloader/compare/v2.11.0...v2.11.1
[2.11.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.13...v2.11.0
[2.10.13]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.12...v2.10.13
[2.10.12]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.11...v2.10.12
[2.10.11]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.10...v2.10.11
[2.10.10]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.9...v2.10.10
[2.10.9]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.8...v2.10.9
[2.10.8]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.7...v2.10.8
[2.10.7]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.6...v2.10.7
[2.10.6]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.5...v2.10.6
[2.10.5]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.4...v2.10.5
[2.10.4]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.3...v2.10.4
[2.10.3]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.2...v2.10.3
[2.10.2]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.1...v2.10.2
[2.10.1]: https://github.com/Automattic/jetpack-autoloader/compare/v2.10.0...v2.10.1
[2.10.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.9.1...v2.10.0
[2.9.1]: https://github.com/Automattic/jetpack-autoloader/compare/v2.9.0...v2.9.1
[2.9.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.8.0...v2.9.0
[2.8.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.7.1...v2.8.0
[2.7.1]: https://github.com/Automattic/jetpack-autoloader/compare/v2.7.0...v2.7.1
[2.7.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.6.0...v2.7.0
[2.6.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.3.0...v2.4.0
[2.3.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.2.0...v2.3.0
[2.2.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.1.0...v2.2.0
[2.1.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.0.2...v2.1.0
[2.0.2]: https://github.com/Automattic/jetpack-autoloader/compare/v2.0.1...v2.0.2
[2.0.1]: https://github.com/Automattic/jetpack-autoloader/compare/v2.0.0...v2.0.1
[2.0.0]: https://github.com/Automattic/jetpack-autoloader/compare/v2.0.0-beta...v2.0.0
[2.0.0-beta]: https://github.com/Automattic/jetpack-autoloader/compare/v1.7.0...v2.0.0-beta
[1.7.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.6.0...v1.7.0
[1.6.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.4.1...v1.5.0
[1.4.1]: https://github.com/Automattic/jetpack-autoloader/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.8...v1.4.0
[1.3.8]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.7...v1.3.8
[1.3.7]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.6...v1.3.7
[1.3.6]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.5...v1.3.6
[1.3.5]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.4...v1.3.5
[1.3.4]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.3...v1.3.4
[1.3.3]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.2...v1.3.3
[1.3.2]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.1...v1.3.2
[1.3.1]: https://github.com/Automattic/jetpack-autoloader/compare/v1.3.0...v1.3.1
[1.3.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.1.0...v1.2.0
[1.1.0]: https://github.com/Automattic/jetpack-autoloader/compare/v1.0.0...v1.1.0

View File

@@ -0,0 +1,47 @@
# Security Policy
Full details of the Automattic Security Policy can be found on [automattic.com](https://automattic.com/security/).
## Supported Versions
Generally, only the latest version of Jetpack and its associated plugins have continued support. If a critical vulnerability is found in the current version of a plugin, we may opt to backport any patches to previous versions.
## Reporting a Vulnerability
Our HackerOne program covers the below plugin software, as well as a variety of related projects and infrastructure:
* [Jetpack](https://jetpack.com/)
* Jetpack Backup
* Jetpack Boost
* Jetpack CRM
* Jetpack Protect
* Jetpack Search
* Jetpack Social
* Jetpack VideoPress
**For responsible disclosure of security issues and to be eligible for our bug bounty program, please submit your report via the [HackerOne](https://hackerone.com/automattic) portal.**
Our most critical targets are:
* Jetpack and the Jetpack composer packages (all within this repo)
* Jetpack.com -- the primary marketing site.
* cloud.jetpack.com -- a management site.
* wordpress.com -- the shared management site for both Jetpack and WordPress.com sites.
For more targets, see the `In Scope` section on [HackerOne](https://hackerone.com/automattic).
_Please note that the **WordPress software is a separate entity** from Automattic. Please report vulnerabilities for WordPress through [the WordPress Foundation's HackerOne page](https://hackerone.com/wordpress)._
## Guidelines
We're committed to working with security researchers to resolve the vulnerabilities they discover. You can help us by following these guidelines:
* Follow [HackerOne's disclosure guidelines](https://www.hackerone.com/disclosure-guidelines).
* Pen-testing Production:
* Please **setup a local environment** instead whenever possible. Most of our code is open source (see above).
* If that's not possible, **limit any data access/modification** to the bare minimum necessary to reproduce a PoC.
* **_Don't_ automate form submissions!** That's very annoying for us, because it adds extra work for the volunteers who manage those systems, and reduces the signal/noise ratio in our communication channels.
* To be eligible for a bounty, all of these guidelines must be followed.
* Be Patient - Give us a reasonable time to correct the issue before you disclose the vulnerability.
We also expect you to comply with all applicable laws. You're responsible to pay any taxes associated with your bounties.

View File

@@ -0,0 +1,101 @@
<?php
/**
* Autoloader file writer.
*
* @package automattic/jetpack-autoloader
*/
namespace Automattic\Jetpack\Autoloader;
use Composer\IO\IOInterface;
/**
* Class AutoloadFileWriter.
*/
class AutoloadFileWriter {
/**
* The file comment to use.
*/
const COMMENT = <<<'AUTOLOADER_COMMENT'
/**
* This file was automatically generated by automattic/jetpack-autoloader.
*
* @package automattic/jetpack-autoloader
*/
AUTOLOADER_COMMENT;
/**
* Copies autoloader files and replaces any placeholders in them.
*
* @param IOInterface|null $io An IO for writing to.
* @param string $outDir The directory to place the autoloader files in.
* @param string $suffix The suffix to use in the autoloader's namespace.
*/
public static function copyAutoloaderFiles( $io, $outDir, $suffix ) {
$renameList = array(
'autoload.php' => '../autoload_packages.php',
);
$ignoreList = array(
'AutoloadGenerator.php',
'AutoloadProcessor.php',
'CustomAutoloaderPlugin.php',
'ManifestGenerator.php',
'AutoloadFileWriter.php',
);
// Copy all of the autoloader files.
$files = scandir( __DIR__ );
foreach ( $files as $file ) {
// Only PHP files will be copied.
if ( substr( $file, -4 ) !== '.php' ) {
continue;
}
if ( in_array( $file, $ignoreList, true ) ) {
continue;
}
$newFile = $renameList[ $file ] ?? $file;
$content = self::prepareAutoloaderFile( $file, $suffix );
$written = file_put_contents( $outDir . '/' . $newFile, $content );
if ( $io ) {
if ( $written ) {
$io->writeError( " <info>Generated: $newFile</info>" );
} else {
$io->writeError( " <error>Error: $newFile</error>" );
}
}
}
}
/**
* Prepares an autoloader file to be written to the destination.
*
* @param String $filename a file to prepare.
* @param String $suffix Unique suffix used in the namespace.
*
* @return string
*/
private static function prepareAutoloaderFile( $filename, $suffix ) {
$header = self::COMMENT;
$header .= PHP_EOL;
if ( $suffix === 'Current' ) {
// Unit testing.
$header .= 'namespace Automattic\Jetpack\Autoloader\jpCurrent;';
} else {
$header .= 'namespace Automattic\Jetpack\Autoloader\jp' . $suffix . '\al' . preg_replace( '/[^0-9a-zA-Z]/', '_', AutoloadGenerator::VERSION ) . ';';
}
$header .= PHP_EOL . PHP_EOL;
$sourceLoader = fopen( __DIR__ . '/' . $filename, 'r' );
$file_contents = stream_get_contents( $sourceLoader );
return str_replace(
'/* HEADER */',
$header,
$file_contents
);
}
}

View File

@@ -0,0 +1,403 @@
<?php
/**
* Autoloader Generator.
*
* @package automattic/jetpack-autoloader
*/
namespace Automattic\Jetpack\Autoloader;
use Composer\Composer;
use Composer\Config;
use Composer\Installer\InstallationManager;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Repository\InstalledRepositoryInterface;
use Composer\Util\Filesystem;
use Composer\Util\PackageSorter;
/**
* Class AutoloadGenerator.
*/
class AutoloadGenerator {
const VERSION = '5.0.15';
/**
* IO object.
*
* @var IOInterface IO object.
*/
private $io;
/**
* The filesystem utility.
*
* @var Filesystem
*/
private $filesystem;
/**
* Instantiate an AutoloadGenerator object.
*
* @param IOInterface $io IO object.
*/
public function __construct( IOInterface $io ) {
$this->io = $io;
$this->filesystem = new Filesystem();
}
/**
* Dump the Jetpack autoloader files.
*
* @param Composer $composer The Composer object.
* @param Config $config Config object.
* @param InstalledRepositoryInterface $localRepo Installed Repository object.
* @param PackageInterface $mainPackage Main Package object.
* @param InstallationManager $installationManager Manager for installing packages.
* @param string $targetDir Path to the current target directory.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
* @param string $suffix The autoloader suffix.
*/
public function dump(
Composer $composer,
Config $config,
InstalledRepositoryInterface $localRepo,
PackageInterface $mainPackage,
InstallationManager $installationManager,
$targetDir,
$scanPsrPackages = false,
$suffix = null
) {
$this->filesystem->ensureDirectoryExists( $config->get( 'vendor-dir' ) );
$packageMap = $composer->getAutoloadGenerator()->buildPackageMap( $installationManager, $mainPackage, $localRepo->getCanonicalPackages() );
$autoloads = $this->parseAutoloads( $packageMap, $mainPackage );
// Convert the autoloads into a format that the manifest generator can consume more easily.
$basePath = $this->filesystem->normalizePath( realpath( getcwd() ) );
$vendorPath = $this->filesystem->normalizePath( realpath( $config->get( 'vendor-dir' ) ) );
$processedAutoloads = $this->processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath );
unset( $packageMap, $autoloads );
// Make sure none of the legacy files remain that can lead to problems with the autoloader.
$this->removeLegacyFiles( $vendorPath );
// Write all of the files now that we're done.
$this->writeAutoloaderFiles( $vendorPath . '/jetpack-autoloader/', $suffix );
$this->writeManifests( $vendorPath . '/' . $targetDir, $processedAutoloads );
if ( ! $scanPsrPackages ) {
$this->io->writeError( '<warning>You are generating an unoptimized autoloader. If this is a production build, consider using the -o option.</warning>' );
}
}
/**
* Compiles an ordered list of namespace => path mappings
*
* @param array $packageMap Array of array(package, installDir-relative-to-composer.json).
* @param PackageInterface $mainPackage Main package instance.
*
* @return array The list of path mappings.
*/
public function parseAutoloads( array $packageMap, PackageInterface $mainPackage ) {
$rootPackageMap = array_shift( $packageMap );
$sortedPackageMap = $this->sortPackageMap( $packageMap );
$sortedPackageMap[] = $rootPackageMap;
array_unshift( $packageMap, $rootPackageMap );
$psr0 = $this->parseAutoloadsType( $packageMap, 'psr-0', $mainPackage );
$psr4 = $this->parseAutoloadsType( $packageMap, 'psr-4', $mainPackage );
$classmap = $this->parseAutoloadsType( array_reverse( $sortedPackageMap ), 'classmap', $mainPackage );
$files = $this->parseAutoloadsType( $sortedPackageMap, 'files', $mainPackage );
krsort( $psr0 );
krsort( $psr4 );
return array(
'psr-0' => $psr0,
'psr-4' => $psr4,
'classmap' => $classmap,
'files' => $files,
);
}
/**
* Sorts packages by dependency weight
*
* Packages of equal weight retain the original order
*
* @param array $packageMap The package map.
*
* @return array
*/
protected function sortPackageMap( array $packageMap ) {
$packages = array();
$paths = array();
foreach ( $packageMap as $item ) {
list( $package, $path ) = $item;
$name = $package->getName();
$packages[ $name ] = $package;
$paths[ $name ] = $path;
}
$sortedPackages = PackageSorter::sortPackages( $packages );
$sortedPackageMap = array();
foreach ( $sortedPackages as $package ) {
$name = $package->getName();
$sortedPackageMap[] = array( $packages[ $name ], $paths[ $name ] );
}
return $sortedPackageMap;
}
/**
* Returns the file identifier.
*
* @param PackageInterface $package The package instance.
* @param string $path The path.
*/
protected function getFileIdentifier( PackageInterface $package, $path ) {
return md5( $package->getName() . ':' . $path );
}
/**
* Returns the path code for the given path.
*
* @param Filesystem $filesystem The filesystem instance.
* @param string $basePath The base path.
* @param string $vendorPath The vendor path.
* @param string $path The path.
*
* @return string The path code.
*/
protected function getPathCode( Filesystem $filesystem, $basePath, $vendorPath, $path ) {
if ( ! $filesystem->isAbsolutePath( $path ) ) {
$path = $basePath . '/' . $path;
}
$path = $filesystem->normalizePath( $path );
$baseDir = '';
if ( 0 === strpos( $path . '/', $vendorPath . '/' ) ) {
$path = substr( $path, strlen( $vendorPath ) );
$baseDir = '$vendorDir';
if ( false !== $path ) {
$baseDir .= ' . ';
}
} else {
$path = $filesystem->normalizePath( $filesystem->findShortestPath( $basePath, $path, true ) );
if ( ! $filesystem->isAbsolutePath( $path ) ) {
$baseDir = '$baseDir . ';
$path = '/' . $path;
}
}
if ( strpos( $path, '.phar' ) !== false ) {
$baseDir = "'phar://' . " . $baseDir;
}
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
return $baseDir . ( ( false !== $path ) ? var_export( $path, true ) : '' );
}
/**
* This function differs from the composer parseAutoloadsType in that beside returning the path.
* It also return the path and the version of a package.
*
* Supports PSR-4, PSR-0, and classmap parsing.
*
* @param array $packageMap Map of all the packages.
* @param string $type Type of autoloader to use.
* @param PackageInterface $mainPackage Instance of the Package Object.
*
* @return array
*/
protected function parseAutoloadsType( array $packageMap, $type, PackageInterface $mainPackage ) {
$autoloads = array();
foreach ( $packageMap as $item ) {
list($package, $installPath) = $item;
$autoload = $package->getAutoload();
$version = $package->getVersion(); // Version of the class comes from the package - should we try to parse it?
// Store our own actual package version, not "dev-trunk" or whatever.
if ( $package->getName() === 'automattic/jetpack-autoloader' ) {
$version = self::VERSION;
}
if ( $package === $mainPackage ) {
$autoload = array_merge_recursive( $autoload, $package->getDevAutoload() );
}
if ( null !== $package->getTargetDir() && $package !== $mainPackage ) {
$installPath = substr( $installPath, 0, -strlen( '/' . $package->getTargetDir() ) );
}
if ( in_array( $type, array( 'psr-4', 'psr-0' ), true ) && isset( $autoload[ $type ] ) && is_array( $autoload[ $type ] ) ) {
foreach ( $autoload[ $type ] as $namespace => $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[ $namespace ][] = array(
'path' => $relativePath,
'version' => $version,
);
}
}
}
if ( 'classmap' === $type && isset( $autoload['classmap'] ) && is_array( $autoload['classmap'] ) ) {
foreach ( $autoload['classmap'] as $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[] = array(
'path' => $relativePath,
'version' => $version,
);
}
}
}
if ( 'files' === $type && isset( $autoload['files'] ) && is_array( $autoload['files'] ) ) {
foreach ( $autoload['files'] as $paths ) {
$paths = is_array( $paths ) ? $paths : array( $paths );
foreach ( $paths as $path ) {
$relativePath = empty( $installPath ) ? ( empty( $path ) ? '.' : $path ) : $installPath . '/' . $path;
$autoloads[ $this->getFileIdentifier( $package, $path ) ] = array(
'path' => $relativePath,
'version' => $version,
);
}
}
}
}
return $autoloads;
}
/**
* Given Composer's autoloads this will convert them to a version that we can use to generate the manifests.
*
* When the $scanPsrPackages argument is true, PSR-4 namespaces are converted to classmaps. When $scanPsrPackages
* is false, PSR-4 namespaces are not converted to classmaps.
*
* PSR-0 namespaces are always converted to classmaps.
*
* @param array $autoloads The autoloads we want to process.
* @param bool $scanPsrPackages Whether or not PSR-4 packages should be converted to a classmap.
* @param string $vendorPath The path to the vendor directory.
* @param string $basePath The path to the current directory.
*
* @return array $processedAutoloads
*/
private function processAutoloads( $autoloads, $scanPsrPackages, $vendorPath, $basePath ) {
$processor = new AutoloadProcessor(
function ( $path, $excludedClasses, $namespace ) use ( $basePath ) {
$dir = $this->filesystem->normalizePath(
$this->filesystem->isAbsolutePath( $path ) ? $path : $basePath . '/' . $path
);
// Composer 2.4 changed the name of the class.
if ( class_exists( \Composer\ClassMapGenerator\ClassMapGenerator::class ) ) {
if ( ! is_dir( $dir ) && ! is_file( $dir ) ) {
return array();
}
$generator = new \Composer\ClassMapGenerator\ClassMapGenerator();
$generator->scanPaths( $dir, $excludedClasses, 'classmap', empty( $namespace ) ? null : $namespace );
return $generator->getClassMap()->getMap();
}
return \Composer\Autoload\ClassMapGenerator::createMap(
$dir,
$excludedClasses,
null, // Don't pass the IOInterface since the normal autoload generation will have reported already.
empty( $namespace ) ? null : $namespace
);
},
function ( $path ) use ( $basePath, $vendorPath ) {
return $this->getPathCode( $this->filesystem, $basePath, $vendorPath, $path );
}
);
return array(
'psr-4' => $processor->processPsr4Packages( $autoloads, $scanPsrPackages ),
'classmap' => $processor->processClassmap( $autoloads, $scanPsrPackages ),
'files' => $processor->processFiles( $autoloads ),
);
}
/**
* Removes all of the legacy autoloader files so they don't cause any problems.
*
* @param string $outDir The directory legacy files are written to.
*/
private function removeLegacyFiles( $outDir ) {
$files = array(
'autoload_functions.php',
'class-autoloader-handler.php',
'class-classes-handler.php',
'class-files-handler.php',
'class-plugins-handler.php',
'class-version-selector.php',
);
foreach ( $files as $file ) {
$this->filesystem->remove( $outDir . '/' . $file );
}
}
/**
* Writes all of the autoloader files to disk.
*
* @param string $outDir The directory to write to.
* @param string $suffix The unique autoloader suffix.
*/
private function writeAutoloaderFiles( $outDir, $suffix ) {
$this->io->writeError( "<info>Generating jetpack autoloader ($outDir)</info>" );
// We will remove all autoloader files to generate this again.
$this->filesystem->emptyDirectory( $outDir );
// Write the autoloader files.
AutoloadFileWriter::copyAutoloaderFiles( $this->io, $outDir, $suffix );
}
/**
* Writes all of the manifest files to disk.
*
* @param string $outDir The directory to write to.
* @param array $processedAutoloads The processed autoloads.
*/
private function writeManifests( $outDir, $processedAutoloads ) {
$this->io->writeError( "<info>Generating jetpack autoloader manifests ($outDir)</info>" );
$manifestFiles = array(
'classmap' => 'jetpack_autoload_classmap.php',
'psr-4' => 'jetpack_autoload_psr4.php',
'files' => 'jetpack_autoload_filemap.php',
);
foreach ( $manifestFiles as $key => $file ) {
// Make sure the file doesn't exist so it isn't there if we don't write it.
$this->filesystem->remove( $outDir . '/' . $file );
if ( empty( $processedAutoloads[ $key ] ) ) {
continue;
}
$content = ManifestGenerator::buildManifest( $key, $file, $processedAutoloads[ $key ] );
if ( empty( $content ) ) {
continue;
}
if ( file_put_contents( $outDir . '/' . $file, $content ) ) {
$this->io->writeError( " <info>Generated: $file</info>" );
} else {
$this->io->writeError( " <error>Error: $file</error>" );
}
}
}
}

View File

@@ -0,0 +1,176 @@
<?php
/**
* Autoload Processor.
*
* @package automattic/jetpack-autoloader
*/
namespace Automattic\Jetpack\Autoloader;
/**
* Class AutoloadProcessor.
*/
class AutoloadProcessor {
/**
* A callable for scanning a directory for all of its classes.
*
* @var callable
*/
private $classmapScanner;
/**
* A callable for transforming a path into one to be used in code.
*
* @var callable
*/
private $pathCodeTransformer;
/**
* The constructor.
*
* @param callable $classmapScanner A callable for scanning a directory for all of its classes.
* @param callable $pathCodeTransformer A callable for transforming a path into one to be used in code.
*/
public function __construct( $classmapScanner, $pathCodeTransformer ) {
$this->classmapScanner = $classmapScanner;
$this->pathCodeTransformer = $pathCodeTransformer;
}
/**
* Processes the classmap autoloads into a relative path format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
*
* @return array|null $processed
* @phan-param array{classmap:?array{path:string,version:string}[],psr-4:?array<string,array{path:string,version:string}[]>,psr-0:?array<string,array{path:string,version:string}[]>} $autoloads
*/
public function processClassmap( $autoloads, $scanPsrPackages ) {
// We can't scan PSR packages if we don't actually have any.
if ( empty( $autoloads['psr-4'] ) ) {
$scanPsrPackages = false;
}
if ( empty( $autoloads['classmap'] ) && ! $scanPsrPackages ) {
return null;
}
$excludedClasses = null;
if ( ! empty( $autoloads['exclude-from-classmap'] ) ) {
$excludedClasses = '{(' . implode( '|', $autoloads['exclude-from-classmap'] ) . ')}';
}
$processed = array();
if ( $scanPsrPackages ) {
foreach ( $autoloads['psr-4'] as $namespace => $sources ) {
$namespace = empty( $namespace ) ? null : $namespace;
foreach ( $sources as $source ) {
$classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $source['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
}
/*
* PSR-0 namespaces are converted to classmaps for both optimized and unoptimized autoloaders because any new
* development should use classmap or PSR-4 autoloading.
*/
if ( ! empty( $autoloads['psr-0'] ) ) {
foreach ( $autoloads['psr-0'] as $namespace => $sources ) {
$namespace = empty( $namespace ) ? null : $namespace;
foreach ( $sources as $source ) {
$classmap = call_user_func( $this->classmapScanner, $source['path'], $excludedClasses, $namespace );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $source['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
}
if ( ! empty( $autoloads['classmap'] ) ) {
foreach ( $autoloads['classmap'] as $package ) {
$classmap = call_user_func( $this->classmapScanner, $package['path'], $excludedClasses, null );
foreach ( $classmap as $class => $path ) {
$processed[ $class ] = array(
'version' => $package['version'],
'path' => call_user_func( $this->pathCodeTransformer, $path ),
);
}
}
}
ksort( $processed );
return $processed;
}
/**
* Processes the PSR-4 autoloads into a relative path format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
* @param bool $scanPsrPackages Whether or not PSR packages should be converted to a classmap.
*
* @return array|null $processed
*/
public function processPsr4Packages( $autoloads, $scanPsrPackages ) {
if ( $scanPsrPackages || empty( $autoloads['psr-4'] ) ) {
return null;
}
$processed = array();
foreach ( $autoloads['psr-4'] as $namespace => $packages ) {
$namespace = empty( $namespace ) ? null : $namespace;
$paths = array();
foreach ( $packages as $package ) {
$paths[] = call_user_func( $this->pathCodeTransformer, $package['path'] );
}
$processed[ $namespace ] = array(
'version' => $package['version'],
'path' => $paths,
);
}
return $processed;
}
/**
* Processes the file autoloads into a relative format including the version for each file.
*
* @param array $autoloads The autoloads we are processing.
*
* @return array|null $processed
*/
public function processFiles( $autoloads ) {
if ( empty( $autoloads['files'] ) ) {
return null;
}
$processed = array();
foreach ( $autoloads['files'] as $file_id => $package ) {
$processed[ $file_id ] = array(
'version' => $package['version'],
'path' => call_user_func( $this->pathCodeTransformer, $package['path'] ),
);
}
return $processed;
}
}

View File

@@ -0,0 +1,188 @@
<?php
/**
* Custom Autoloader Composer Plugin, hooks into composer events to generate the custom autoloader.
*
* @package automattic/jetpack-autoloader
*/
namespace Automattic\Jetpack\Autoloader;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
/**
* Class CustomAutoloaderPlugin.
*
* @package automattic/jetpack-autoloader
*/
class CustomAutoloaderPlugin implements PluginInterface, EventSubscriberInterface {
/**
* IO object.
*
* @var IOInterface IO object.
*/
private $io;
/**
* Composer object.
*
* @var Composer Composer object.
*/
private $composer;
/**
* Do nothing.
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function activate( Composer $composer, IOInterface $io ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$this->composer = $composer;
$this->io = $io;
}
/**
* Do nothing.
* phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function deactivate( Composer $composer, IOInterface $io ) {
/*
* Intentionally left empty. This is a PluginInterface method.
* phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*/
}
/**
* Do nothing.
* phpcs:disable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*
* @param Composer $composer Composer object.
* @param IOInterface $io IO object.
*/
public function uninstall( Composer $composer, IOInterface $io ) {
/*
* Intentionally left empty. This is a PluginInterface method.
* phpcs:enable VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
*/
}
/**
* Tell composer to listen for events and do something with them.
*
* @return array List of subscribed events.
*/
public static function getSubscribedEvents() {
return array(
ScriptEvents::POST_AUTOLOAD_DUMP => 'postAutoloadDump',
);
}
/**
* Generate the custom autolaoder.
*
* @param Event $event Script event object.
*/
public function postAutoloadDump( Event $event ) {
// When the autoloader is not required by the root package we don't want to execute it.
// This prevents unwanted transitive execution that generates unused autoloaders or
// at worst throws fatal executions.
if ( ! $this->isRequiredByRoot() ) {
return;
}
$config = $this->composer->getConfig();
if ( 'vendor' !== $config->raw()['config']['vendor-dir'] ) {
$this->io->writeError( "\n<error>An error occurred while generating the autoloader files:", true );
$this->io->writeError( 'The project\'s composer.json or composer environment set a non-default vendor directory.', true );
$this->io->writeError( 'The default composer vendor directory must be used.</error>', true );
exit( 0 );
}
$installationManager = $this->composer->getInstallationManager();
$repoManager = $this->composer->getRepositoryManager();
$localRepo = $repoManager->getLocalRepository();
$package = $this->composer->getPackage();
$optimize = $event->getFlags()['optimize'];
$suffix = $this->determineSuffix();
$generator = new AutoloadGenerator( $this->io );
$generator->dump( $this->composer, $config, $localRepo, $package, $installationManager, 'composer', $optimize, $suffix );
}
/**
* Determine the suffix for the autoloader class.
*
* Reuses an existing suffix from vendor/autoload_packages.php or vendor/autoload.php if possible.
*
* @return string Suffix.
*/
private function determineSuffix() {
$config = $this->composer->getConfig();
$vendorPath = $config->get( 'vendor-dir' );
// Command line.
$suffix = $config->get( 'autoloader-suffix' );
if ( $suffix ) {
return $suffix;
}
// Reuse our own suffix, if any.
if ( is_readable( $vendorPath . '/autoload_packages.php' ) ) {
$content = file_get_contents( $vendorPath . '/autoload_packages.php' );
if ( preg_match( '/^namespace Automattic\\\\Jetpack\\\\Autoloader\\\\jp([^;\s]+?)(?:\\\\al[^;\s]+)?;/m', $content, $match ) ) {
return $match[1];
}
}
// Reuse Composer's suffix, if any.
if ( is_readable( $vendorPath . '/autoload.php' ) ) {
$content = file_get_contents( $vendorPath . '/autoload.php' );
if ( preg_match( '{ComposerAutoloaderInit([^:\s]+)::}', $content, $match ) ) {
return $match[1];
}
}
// Generate a random suffix.
return md5( uniqid( '', true ) );
}
/**
* Checks to see whether or not the root package is the one that required the autoloader.
*
* @return bool
*/
private function isRequiredByRoot() {
$package = $this->composer->getPackage();
$requires = $package->getRequires();
if ( ! is_array( $requires ) ) { // @phan-suppress-current-line PhanRedundantCondition -- Earlier Composer versions may not have guaranteed this.
$requires = array();
}
$devRequires = $package->getDevRequires();
if ( ! is_array( $devRequires ) ) { // @phan-suppress-current-line PhanRedundantCondition -- Earlier Composer versions may not have guaranteed this.
$devRequires = array();
}
$requires = array_merge( $requires, $devRequires );
if ( empty( $requires ) ) {
$this->io->writeError( "\n<error>The package is not required and this should never happen?</error>", true );
exit( 0 );
}
foreach ( $requires as $require ) {
if ( 'automattic/jetpack-autoloader' === $require->getTarget() ) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,115 @@
<?php
/**
* Manifest Generator.
*
* @package automattic/jetpack-autoloader
*/
// phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_var_export
namespace Automattic\Jetpack\Autoloader;
/**
* Class ManifestGenerator.
*/
class ManifestGenerator {
/**
* Builds a manifest file for the given autoloader type.
*
* @param string $autoloaderType The type of autoloader to build a manifest for.
* @param string $fileName The filename of the manifest.
* @param array $content The manifest content to generate using.
*
* @return string|null $manifestFile
* @throws \InvalidArgumentException When an invalid autoloader type is given.
*/
public static function buildManifest( $autoloaderType, $fileName, $content ) {
if ( empty( $content ) ) {
return null;
}
switch ( $autoloaderType ) {
case 'classmap':
case 'files':
return self::buildStandardManifest( $fileName, $content );
case 'psr-4':
return self::buildPsr4Manifest( $fileName, $content );
}
throw new \InvalidArgumentException( 'An invalid manifest type of ' . $autoloaderType . ' was passed!' );
}
/**
* Builds the contents for the standard manifest file.
*
* @param string $fileName The filename we are building.
* @param array $manifestData The formatted data for the manifest.
*
* @return string|null $manifestFile
*/
private static function buildStandardManifest( $fileName, $manifestData ) {
$fileContent = PHP_EOL;
foreach ( $manifestData as $key => $data ) {
$key = var_export( $key, true );
$versionCode = var_export( $data['version'], true );
$fileContent .= <<<MANIFEST_CODE
$key => array(
'version' => $versionCode,
'path' => {$data['path']}
),
MANIFEST_CODE;
$fileContent .= PHP_EOL;
}
return self::buildFile( $fileName, $fileContent );
}
/**
* Builds the contents for the PSR-4 manifest file.
*
* @param string $fileName The filename we are building.
* @param array $namespaces The formatted PSR-4 data for the manifest.
*
* @return string|null $manifestFile
*/
private static function buildPsr4Manifest( $fileName, $namespaces ) {
$fileContent = PHP_EOL;
foreach ( $namespaces as $namespace => $data ) {
$namespaceCode = var_export( $namespace, true );
$versionCode = var_export( $data['version'], true );
$pathCode = 'array( ' . implode( ', ', $data['path'] ) . ' )';
$fileContent .= <<<MANIFEST_CODE
$namespaceCode => array(
'version' => $versionCode,
'path' => $pathCode
),
MANIFEST_CODE;
$fileContent .= PHP_EOL;
}
return self::buildFile( $fileName, $fileContent );
}
/**
* Generate the PHP that will be used in the file.
*
* @param string $fileName The filename we are building.
* @param string $content The content to be written into the file.
*
* @return string $fileContent
*/
private static function buildFile( $fileName, $content ) {
return <<<INCLUDE_FILE
<?php
// This file `$fileName` was auto generated by automattic/jetpack-autoloader.
\$vendorDir = dirname(__DIR__);
\$baseDir = dirname(\$vendorDir);
return array($content);
INCLUDE_FILE;
}
}

View File

@@ -0,0 +1,5 @@
<?php
/* HEADER */ // phpcs:ignore
require_once __DIR__ . '/jetpack-autoloader/class-autoloader.php';
Autoloader::init();

View File

@@ -0,0 +1,139 @@
<?php
/* HEADER */ // phpcs:ignore
use Automattic\Jetpack\Autoloader\AutoloadGenerator;
/**
* This class selects the package version for the autoloader.
*/
class Autoloader_Handler {
/**
* The PHP_Autoloader instance.
*
* @var PHP_Autoloader
*/
private $php_autoloader;
/**
* The Hook_Manager instance.
*
* @var Hook_Manager
*/
private $hook_manager;
/**
* The Manifest_Reader instance.
*
* @var Manifest_Reader
*/
private $manifest_reader;
/**
* The Version_Selector instance.
*
* @var Version_Selector
*/
private $version_selector;
/**
* The constructor.
*
* @param PHP_Autoloader $php_autoloader The PHP_Autoloader instance.
* @param Hook_Manager $hook_manager The Hook_Manager instance.
* @param Manifest_Reader $manifest_reader The Manifest_Reader instance.
* @param Version_Selector $version_selector The Version_Selector instance.
*/
public function __construct( $php_autoloader, $hook_manager, $manifest_reader, $version_selector ) {
$this->php_autoloader = $php_autoloader;
$this->hook_manager = $hook_manager;
$this->manifest_reader = $manifest_reader;
$this->version_selector = $version_selector;
}
/**
* Checks to see whether or not an autoloader is currently in the process of initializing.
*
* @return bool
*/
public function is_initializing() {
// If no version has been set it means that no autoloader has started initializing yet.
global $jetpack_autoloader_latest_version;
if ( ! isset( $jetpack_autoloader_latest_version ) ) {
return false;
}
// When the version is set but the classmap is not it ALWAYS means that this is the
// latest autoloader and is being included by an older one.
global $jetpack_packages_classmap;
if ( empty( $jetpack_packages_classmap ) ) {
return true;
}
// Version 2.4.0 added a new global and altered the reset semantics. We need to check
// the other global as well since it may also point at initialization.
// Note: We don't need to check for the class first because every autoloader that
// will set the latest version global requires this class in the classmap.
$replacing_version = $jetpack_packages_classmap[ AutoloadGenerator::class ]['version'];
if ( $this->version_selector->is_dev_version( $replacing_version ) || version_compare( $replacing_version, '2.4.0.0', '>=' ) ) {
global $jetpack_autoloader_loader;
if ( ! isset( $jetpack_autoloader_loader ) ) {
return true;
}
}
return false;
}
/**
* Activates an autoloader using the given plugins and activates it.
*
* @param string[] $plugins The plugins to initialize the autoloader for.
*/
public function activate_autoloader( $plugins ) {
global $jetpack_packages_psr4;
$jetpack_packages_psr4 = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_psr4.php', $jetpack_packages_psr4 );
global $jetpack_packages_classmap;
$jetpack_packages_classmap = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_classmap.php', $jetpack_packages_classmap );
global $jetpack_packages_filemap;
$jetpack_packages_filemap = array();
$this->manifest_reader->read_manifests( $plugins, 'vendor/composer/jetpack_autoload_filemap.php', $jetpack_packages_filemap );
$loader = new Version_Loader(
$this->version_selector,
$jetpack_packages_classmap,
$jetpack_packages_psr4,
$jetpack_packages_filemap
);
$this->php_autoloader->register_autoloader( $loader );
// Now that the autoloader is active we can load the filemap.
$loader->load_filemap();
}
/**
* Resets the active autoloader and all related global state.
*/
public function reset_autoloader() {
$this->php_autoloader->unregister_autoloader();
$this->hook_manager->reset();
// Clear all of the autoloader globals so that older autoloaders don't do anything strange.
global $jetpack_autoloader_latest_version;
$jetpack_autoloader_latest_version = null;
global $jetpack_packages_classmap;
$jetpack_packages_classmap = array(); // Must be array to avoid exceptions in old autoloaders!
global $jetpack_packages_psr4;
$jetpack_packages_psr4 = array(); // Must be array to avoid exceptions in old autoloaders!
global $jetpack_packages_filemap;
$jetpack_packages_filemap = array(); // Must be array to avoid exceptions in old autoloaders!
}
}

View File

@@ -0,0 +1,82 @@
<?php
/* HEADER */ // phpcs:ignore
use Automattic\Jetpack\Autoloader\AutoloadGenerator;
/**
* This class locates autoloaders.
*/
class Autoloader_Locator {
/**
* The object for comparing autoloader versions.
*
* @var Version_Selector
*/
private $version_selector;
/**
* The constructor.
*
* @param Version_Selector $version_selector The version selector object.
*/
public function __construct( $version_selector ) {
$this->version_selector = $version_selector;
}
/**
* Finds the path to the plugin with the latest autoloader.
*
* @param array $plugin_paths An array of plugin paths.
* @param string $latest_version The latest version reference. @phan-output-reference.
*
* @return string|null
*/
public function find_latest_autoloader( $plugin_paths, &$latest_version ) {
$latest_plugin = null;
foreach ( $plugin_paths as $plugin_path ) {
$version = $this->get_autoloader_version( $plugin_path );
if ( ! $version || ! $this->version_selector->is_version_update_required( $latest_version, $version ) ) {
continue;
}
$latest_version = $version;
$latest_plugin = $plugin_path;
}
return $latest_plugin;
}
/**
* Gets the path to the autoloader.
*
* @param string $plugin_path The path to the plugin.
*
* @return string
*/
public function get_autoloader_path( $plugin_path ) {
return trailingslashit( $plugin_path ) . 'vendor/autoload_packages.php';
}
/**
* Gets the version for the autoloader.
*
* @param string $plugin_path The path to the plugin.
*
* @return string|null
*/
public function get_autoloader_version( $plugin_path ) {
$classmap = trailingslashit( $plugin_path ) . 'vendor/composer/jetpack_autoload_classmap.php';
if ( ! file_exists( $classmap ) ) {
return null;
}
$classmap = require $classmap;
if ( isset( $classmap[ AutoloadGenerator::class ] ) ) {
return $classmap[ AutoloadGenerator::class ]['version'];
}
return null;
}
}

View File

@@ -0,0 +1,85 @@
<?php
/* HEADER */ // phpcs:ignore
/**
* This class handles management of the actual PHP autoloader.
*/
class Autoloader {
/**
* Checks to see whether or not the autoloader should be initialized and then initializes it if so.
*
* @param Container|null $container The container we want to use for autoloader initialization. If none is given
* then a container will be created automatically.
*/
public static function init( $container = null ) {
// The container holds and manages the lifecycle of our dependencies
// to make them easier to work with and increase flexibility.
if ( ! isset( $container ) ) {
require_once __DIR__ . '/class-container.php';
$container = new Container();
}
// phpcs:disable Generic.Commenting.DocComment.MissingShort
/** @var Autoloader_Handler $autoloader_handler */
$autoloader_handler = $container->get( Autoloader_Handler::class );
// If the autoloader is already initializing it means that it has included us as the latest.
$was_included_by_autoloader = $autoloader_handler->is_initializing();
/** @var Plugin_Locator $plugin_locator */
$plugin_locator = $container->get( Plugin_Locator::class );
/** @var Plugins_Handler $plugins_handler */
$plugins_handler = $container->get( Plugins_Handler::class );
// The current plugin is the one that we are attempting to initialize here.
$current_plugin = $plugin_locator->find_current_plugin();
// The active plugins are those that we were able to discover on the site. This list will not
// include mu-plugins, those activated by code, or those who are hidden by filtering. We also
// want to take care to not consider the current plugin unknown if it was included by an
// autoloader. This avoids the case where a plugin will be marked "active" while deactivated
// due to it having the latest autoloader.
$active_plugins = $plugins_handler->get_active_plugins( true, ! $was_included_by_autoloader );
// The cached plugins are all of those that were active or discovered by the autoloader during a previous request.
// Note that it's possible this list will include plugins that have since been deactivated, but after a request
// the cache should be updated and the deactivated plugins will be removed.
$cached_plugins = $plugins_handler->get_cached_plugins();
// We combine the active list and cached list to preemptively load classes for plugins that are
// presently unknown but will be loaded during the request. While this may result in us considering packages in
// deactivated plugins there shouldn't be any problems as a result and the eventual consistency is sufficient.
$all_plugins = array_merge( $active_plugins, $cached_plugins );
// In particular we also include the current plugin to address the case where it is the latest autoloader
// but also unknown (and not cached). We don't want it in the active list because we don't know that it
// is active but we need it in the all plugins list so that it is considered by the autoloader.
$all_plugins[] = $current_plugin;
// We require uniqueness in the array to avoid processing the same plugin more than once.
$all_plugins = array_values( array_unique( $all_plugins ) );
/** @var Latest_Autoloader_Guard $guard */
$guard = $container->get( Latest_Autoloader_Guard::class );
if ( $guard->should_stop_init( $current_plugin, $all_plugins, $was_included_by_autoloader ) ) {
return;
}
// Initialize the autoloader using the handler now that we're ready.
$autoloader_handler->activate_autoloader( $all_plugins );
/** @var Hook_Manager $hook_manager */
$hook_manager = $container->get( Hook_Manager::class );
// Register a shutdown handler to clean up the autoloader.
$hook_manager->add_action( 'shutdown', new Shutdown_Handler( $plugins_handler, $cached_plugins, $was_included_by_autoloader ) );
// Register a plugins_loaded handler to check for conflicting autoloaders.
$hook_manager->add_action( 'plugins_loaded', array( $guard, 'check_for_conflicting_autoloaders' ), 1 );
// phpcs:enable Generic.Commenting.DocComment.MissingShort
}
}

View File

@@ -0,0 +1,142 @@
<?php
/* HEADER */ // phpcs:ignore
/**
* This class manages the files and dependencies of the autoloader.
*/
class Container {
/**
* Since each autoloader's class files exist within their own namespace we need a map to
* convert between the local class and a shared key. Note that no version checking is
* performed on these dependencies and the first autoloader to register will be the
* one that is utilized.
*/
const SHARED_DEPENDENCY_KEYS = array(
Hook_Manager::class => 'Hook_Manager',
);
/**
* A map of all the dependencies we've registered with the container and created.
*
* @var array
*/
protected $dependencies;
/**
* The constructor.
*/
public function __construct() {
$this->dependencies = array();
$this->register_shared_dependencies();
$this->register_dependencies();
$this->initialize_globals();
}
/**
* Gets a dependency out of the container.
*
* @param string $class The class to fetch.
*
* @return mixed
* @throws \InvalidArgumentException When a class that isn't registered with the container is fetched.
*/
public function get( $class ) {
if ( ! isset( $this->dependencies[ $class ] ) ) {
throw new \InvalidArgumentException( "Class '$class' is not registered with the container." );
}
return $this->dependencies[ $class ];
}
/**
* Registers all of the dependencies that are shared between all instances of the autoloader.
*/
private function register_shared_dependencies() {
global $jetpack_autoloader_container_shared;
if ( ! isset( $jetpack_autoloader_container_shared ) ) {
$jetpack_autoloader_container_shared = array();
}
$key = self::SHARED_DEPENDENCY_KEYS[ Hook_Manager::class ];
if ( ! isset( $jetpack_autoloader_container_shared[ $key ] ) ) {
require_once __DIR__ . '/class-hook-manager.php';
$jetpack_autoloader_container_shared[ $key ] = new Hook_Manager();
}
$this->dependencies[ Hook_Manager::class ] = &$jetpack_autoloader_container_shared[ $key ];
}
/**
* Registers all of the dependencies with the container.
*/
private function register_dependencies() {
require_once __DIR__ . '/class-path-processor.php';
$this->dependencies[ Path_Processor::class ] = new Path_Processor();
require_once __DIR__ . '/class-plugin-locator.php';
$this->dependencies[ Plugin_Locator::class ] = new Plugin_Locator(
$this->get( Path_Processor::class )
);
require_once __DIR__ . '/class-version-selector.php';
$this->dependencies[ Version_Selector::class ] = new Version_Selector();
require_once __DIR__ . '/class-autoloader-locator.php';
$this->dependencies[ Autoloader_Locator::class ] = new Autoloader_Locator(
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-php-autoloader.php';
$this->dependencies[ PHP_Autoloader::class ] = new PHP_Autoloader();
require_once __DIR__ . '/class-manifest-reader.php';
$this->dependencies[ Manifest_Reader::class ] = new Manifest_Reader(
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-plugins-handler.php';
$this->dependencies[ Plugins_Handler::class ] = new Plugins_Handler(
$this->get( Plugin_Locator::class ),
$this->get( Path_Processor::class )
);
require_once __DIR__ . '/class-autoloader-handler.php';
$this->dependencies[ Autoloader_Handler::class ] = new Autoloader_Handler(
$this->get( PHP_Autoloader::class ),
$this->get( Hook_Manager::class ),
$this->get( Manifest_Reader::class ),
$this->get( Version_Selector::class )
);
require_once __DIR__ . '/class-latest-autoloader-guard.php';
$this->dependencies[ Latest_Autoloader_Guard::class ] = new Latest_Autoloader_Guard(
$this->get( Plugins_Handler::class ),
$this->get( Autoloader_Handler::class ),
$this->get( Autoloader_Locator::class )
);
// Register any classes that we will use elsewhere.
require_once __DIR__ . '/class-version-loader.php';
require_once __DIR__ . '/class-shutdown-handler.php';
}
/**
* Initializes any of the globals needed by the autoloader.
*/
private function initialize_globals() {
/*
* This global was retired in version 2.9. The value is set to 'false' to maintain
* compatibility with older versions of the autoloader.
*/
global $jetpack_autoloader_including_latest;
$jetpack_autoloader_including_latest = false;
// Not all plugins can be found using the locator. In cases where a plugin loads the autoloader
// but was not discoverable, we will record them in this array to track them as "active".
global $jetpack_autoloader_activating_plugins_paths;
if ( ! isset( $jetpack_autoloader_activating_plugins_paths ) ) {
$jetpack_autoloader_activating_plugins_paths = array();
}
}
}

View File

@@ -0,0 +1,68 @@
<?php
/* HEADER */ // phpcs:ignore
/**
* Allows the latest autoloader to register hooks that can be removed when the autoloader is reset.
*/
class Hook_Manager {
/**
* An array containing all of the hooks that we've registered.
*
* @var array
*/
private $registered_hooks;
/**
* The constructor.
*/
public function __construct() {
$this->registered_hooks = array();
}
/**
* Adds an action to WordPress and registers it internally.
*
* @param string $tag The name of the action which is hooked.
* @param callable $callable The function to call.
* @param int $priority Used to specify the priority of the action.
* @param int $accepted_args Used to specify the number of arguments the callable accepts.
*/
public function add_action( $tag, $callable, $priority = 10, $accepted_args = 1 ) {
$this->registered_hooks[ $tag ][] = array(
'priority' => $priority,
'callable' => $callable,
);
add_action( $tag, $callable, $priority, $accepted_args );
}
/**
* Adds a filter to WordPress and registers it internally.
*
* @param string $tag The name of the filter which is hooked.
* @param callable $callable The function to call.
* @param int $priority Used to specify the priority of the filter.
* @param int $accepted_args Used to specify the number of arguments the callable accepts.
*/
public function add_filter( $tag, $callable, $priority = 10, $accepted_args = 1 ) {
$this->registered_hooks[ $tag ][] = array(
'priority' => $priority,
'callable' => $callable,
);
add_filter( $tag, $callable, $priority, $accepted_args );
}
/**
* Removes all of the registered hooks.
*/
public function reset() {
foreach ( $this->registered_hooks as $tag => $hooks ) {
foreach ( $hooks as $hook ) {
remove_filter( $tag, $hook['callable'], $hook['priority'] );
}
}
$this->registered_hooks = array();
}
}

View File

@@ -0,0 +1,157 @@
<?php
/* HEADER */ // phpcs:ignore
/**
* This class ensures that we're only executing the latest autoloader.
*/
class Latest_Autoloader_Guard {
/**
* The Plugins_Handler instance.
*
* @var Plugins_Handler
*/
private $plugins_handler;
/**
* The Autoloader_Handler instance.
*
* @var Autoloader_Handler
*/
private $autoloader_handler;
/**
* The Autoloader_locator instance.
*
* @var Autoloader_Locator
*/
private $autoloader_locator;
/**
* The constructor.
*
* @param Plugins_Handler $plugins_handler The Plugins_Handler instance.
* @param Autoloader_Handler $autoloader_handler The Autoloader_Handler instance.
* @param Autoloader_Locator $autoloader_locator The Autoloader_Locator instance.
*/
public function __construct( $plugins_handler, $autoloader_handler, $autoloader_locator ) {
$this->plugins_handler = $plugins_handler;
$this->autoloader_handler = $autoloader_handler;
$this->autoloader_locator = $autoloader_locator;
}
/**
* Indicates whether or not the autoloader should be initialized. Note that this function
* has the side-effect of actually loading the latest autoloader in the event that this
* is not it.
*
* @param string $current_plugin The current plugin we're checking.
* @param string[] $plugins The active plugins to check for autoloaders in.
* @param bool $was_included_by_autoloader Indicates whether or not this autoloader was included by another.
*
* @return bool True if we should stop initialization, otherwise false.
*/
public function should_stop_init( $current_plugin, $plugins, $was_included_by_autoloader ) {
global $jetpack_autoloader_latest_version;
// We need to reset the autoloader when the plugins change because
// that means the autoloader was generated with a different list.
if ( $this->plugins_handler->have_plugins_changed( $plugins ) ) {
$this->autoloader_handler->reset_autoloader();
}
// When the latest autoloader has already been found we don't need to search for it again.
// We should take care however because this will also trigger if the autoloader has been
// included by an older one.
if ( isset( $jetpack_autoloader_latest_version ) && ! $was_included_by_autoloader ) {
return true;
}
$latest_plugin = $this->autoloader_locator->find_latest_autoloader( $plugins, $jetpack_autoloader_latest_version );
if ( isset( $latest_plugin ) && $latest_plugin !== $current_plugin ) {
require $this->autoloader_locator->get_autoloader_path( $latest_plugin );
return true;
}
return false;
}
/**
* Check for conflicting autoloaders.
*
* A common source of strange and confusing problems is when another plugin
* registers a Composer autoloader at a higher priority that us. If enabled,
* check for this problem and warn about it.
*
* Called from the plugins_loaded hook.
*
* @since 3.1.0
* @return void
*/
public function check_for_conflicting_autoloaders() {
if ( ! defined( 'JETPACK_AUTOLOAD_DEBUG_CONFLICTING_LOADERS' ) || ! JETPACK_AUTOLOAD_DEBUG_CONFLICTING_LOADERS ) {
return;
}
global $jetpack_autoloader_loader;
if ( ! isset( $jetpack_autoloader_loader ) ) {
return;
}
$prefixes = array();
foreach ( ( $jetpack_autoloader_loader->get_class_map() ?? array() ) as $classname => $data ) {
$parts = explode( '\\', trim( $classname, '\\' ) );
array_pop( $parts );
while ( $parts ) {
$prefixes[ implode( '\\', $parts ) . '\\' ] = true;
array_pop( $parts );
}
}
foreach ( ( $jetpack_autoloader_loader->get_psr4_map() ?? array() ) as $prefix => $data ) {
$parts = explode( '\\', trim( $prefix, '\\' ) );
while ( $parts ) {
$prefixes[ implode( '\\', $parts ) . '\\' ] = true;
array_pop( $parts );
}
}
$autoload_chain = spl_autoload_functions();
if ( ! $autoload_chain ) {
return;
}
foreach ( $autoload_chain as $autoloader ) {
// No need to check anything after us.
if ( is_array( $autoloader ) && is_string( $autoloader[0] ) && substr( $autoloader[0], 0, strlen( __NAMESPACE__ ) + 1 ) === __NAMESPACE__ . '\\' ) {
break;
}
// We can check Composer autoloaders easily enough.
if ( is_array( $autoloader ) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader && is_callable( array( $autoloader[0], 'getPrefixesPsr4' ) ) ) {
$composer_autoloader = $autoloader[0];
foreach ( $composer_autoloader->getClassMap() as $classname => $path ) {
if ( $jetpack_autoloader_loader->find_class_file( $classname ) ) {
$msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the classes we handle (e.g. $classname => $path). This may cause strange and confusing problems.";
wp_trigger_error( '', $msg );
continue 2;
}
}
foreach ( $composer_autoloader->getPrefixesPsr4() as $prefix => $paths ) {
if ( isset( $prefixes[ $prefix ] ) ) {
$path = array_pop( $paths );
$msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the namespaces we handle (e.g. $prefix => $path). This may cause strange and confusing problems.";
wp_trigger_error( '', $msg );
continue 2;
}
}
foreach ( $composer_autoloader->getPrefixes() as $prefix => $paths ) {
if ( isset( $prefixes[ $prefix ] ) ) {
$path = array_pop( $paths );
$msg = "A Composer autoloader is registered with a higher priority than the Jetpack Autoloader and would also handle some of the namespaces we handle (e.g. $prefix => $path). This may cause strange and confusing problems.";
wp_trigger_error( '', $msg );
continue 2;
}
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More