• File: PluginRepository.php
  • Full Path: /home/docteuh/www/Vulnerability/Repositories/PluginRepository.php
  • Date Modified: 06/18/2026 4:25 PM
  • File size: 7.01 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?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);
    }
}