<?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);
}
}