mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-13 05:47:23 -04:00
Previously, plugin migrations only ran once during PluginManager initialization for already-enabled plugins, which caused the login "latest version" check to report the system as up to date even when plugin migrations were still pending. Newly enabled plugins also had no path to run their migrations at enable time. - Login controller: factor pending-migration check into isLatest determination, and explicitly run pending plugin migrations after core migrations complete - PluginManager: remove implicit migration run from init(); add public hasPendingMigrations() and runPendingMigrations() so callers can check/trigger migrations explicitly - PluginManager: extract getPendingMigrationFiles() helper shared by hasPendingMigrations() and per-plugin migration runner - PluginManager: run pending migrations immediately when a plugin is enabled, instead of waiting for next request/init - PluginManager: throw RuntimeException on missing migration class or failed migration instead of silently breaking the loop, so failures propagate to callers Signed-off-by: objecttothis <17935339+objecttothis@users.noreply.github.com>
392 lines
12 KiB
PHP
392 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Libraries\Plugins;
|
|
|
|
use App\Models\PluginConfig;
|
|
use App\Models\PluginMigrationModel;
|
|
use CodeIgniter\Events\Events;
|
|
use Config\Database;
|
|
use Config\Services;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class PluginManager
|
|
{
|
|
private array $plugins = [];
|
|
private array $enabledPlugins = [];
|
|
private PluginConfig $configModel;
|
|
private string $pluginsPath;
|
|
private bool $eventsRegistered = false;
|
|
private static bool $discovered = false;
|
|
private static array $registeredNamespaces = [];
|
|
|
|
public function __construct()
|
|
{
|
|
$this->configModel = new PluginConfig();
|
|
$this->pluginsPath = APPPATH . 'Plugins';
|
|
}
|
|
|
|
public function discoverPlugins(): void
|
|
{
|
|
if (self::$discovered) {
|
|
log_message('debug', 'Plugin discovery already completed, skipping');
|
|
return;
|
|
}
|
|
|
|
if (!is_dir($this->pluginsPath)) {
|
|
log_message('debug', 'Plugins directory does not exist: ' . $this->pluginsPath);
|
|
return;
|
|
}
|
|
|
|
$pluginDirs = glob($this->pluginsPath . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
|
|
|
|
foreach ($pluginDirs as $pluginDir) {
|
|
$pluginName = basename($pluginDir);
|
|
$mainFile = $pluginDir . DIRECTORY_SEPARATOR . $pluginName . '.php';
|
|
|
|
if (!file_exists($mainFile)) {
|
|
continue;
|
|
}
|
|
|
|
$className = 'App\\Plugins\\' . $pluginName . '\\' . $pluginName;
|
|
|
|
if (!class_exists($className)) {
|
|
continue;
|
|
}
|
|
|
|
if (!is_subclass_of($className, PluginInterface::class)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$plugin = new $className();
|
|
} catch (Throwable $e) {
|
|
log_message('error', "Failed to instantiate plugin {$className}: " . $e->getMessage());
|
|
continue;
|
|
}
|
|
|
|
$this->plugins[$plugin->getPluginId()] = $plugin;
|
|
|
|
if ($this->isPluginEnabled($plugin->getPluginId())) {
|
|
$this->registerNamespace($plugin->getPluginId());
|
|
}
|
|
|
|
log_message('debug', "Discovered plugin: {$plugin->getPluginName()}");
|
|
}
|
|
|
|
self::$discovered = true;
|
|
log_message('debug', 'Plugin discovery completed');
|
|
}
|
|
|
|
public function registerPluginEvents(): void
|
|
{
|
|
if ($this->eventsRegistered) {
|
|
return;
|
|
}
|
|
|
|
foreach ($this->plugins as $pluginId => $plugin) {
|
|
if ($this->isPluginEnabled($pluginId)) {
|
|
$this->enabledPlugins[$pluginId] = $plugin;
|
|
$plugin->registerEvents();
|
|
log_message('debug', "Registered events for plugin: {$plugin->getPluginName()}");
|
|
}
|
|
}
|
|
|
|
$this->eventsRegistered = true;
|
|
}
|
|
|
|
public function hasPendingMigrations(): bool
|
|
{
|
|
$db = Database::connect();
|
|
|
|
if (!$db->tableExists('plugin_migrations')) {
|
|
return false;
|
|
}
|
|
|
|
$migrationModel = new PluginMigrationModel();
|
|
|
|
foreach ($this->plugins as $pluginId => $plugin) {
|
|
$currentVersion = $migrationModel->getVersion($pluginId);
|
|
|
|
foreach ($this->getPendingMigrationFiles($pluginId, $plugin, $currentVersion) as $file) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function runPendingMigrations(): void
|
|
{
|
|
foreach ($this->plugins as $pluginId => $plugin) {
|
|
$this->runPendingMigrationsForPlugin($pluginId);
|
|
}
|
|
}
|
|
|
|
private function runPendingMigrationsForPlugin(string $pluginId): void
|
|
{
|
|
$db = Database::connect();
|
|
|
|
if (!$db->tableExists('plugin_migrations')) {
|
|
return;
|
|
}
|
|
|
|
$plugin = $this->getPlugin($pluginId);
|
|
if ($plugin === null) {
|
|
return;
|
|
}
|
|
|
|
$parts = explode('\\', get_class($plugin));
|
|
if (count($parts) < 4) {
|
|
return;
|
|
}
|
|
|
|
$pluginDirName = $parts[2];
|
|
$migrationModel = new PluginMigrationModel();
|
|
$forge = Database::forge();
|
|
$currentVersion = $migrationModel->getVersion($pluginId);
|
|
|
|
foreach ($this->getPendingMigrationFiles($pluginId, $plugin, $currentVersion) as $file) {
|
|
$basename = basename($file, '.php');
|
|
$timestamp = (int) substr($basename, 0, 14);
|
|
$className = substr($basename, 15); // strip "20260627120000_"
|
|
$fqcn = "App\\Plugins\\{$pluginDirName}\\Migrations\\{$className}";
|
|
|
|
require_once $file;
|
|
|
|
if (!class_exists($fqcn)) {
|
|
log_message('error', "Plugin migration class not found: {$fqcn}");
|
|
throw new RuntimeException("Plugin migration class not found: {$fqcn}");
|
|
}
|
|
|
|
try {
|
|
(new $fqcn($db, $forge))->up();
|
|
$migrationModel->setVersion($pluginId, $timestamp);
|
|
log_message('info', "Plugin migration ran: {$pluginId} v{$timestamp}");
|
|
} catch (Throwable $e) {
|
|
log_message('error', "Plugin migration failed: {$pluginId} v{$timestamp}: " . $e->getMessage());
|
|
throw $e;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return string[] Sorted list of migration file paths not yet applied for the given plugin.
|
|
*/
|
|
private function getPendingMigrationFiles(string $pluginId, PluginInterface $plugin, int $currentVersion): array
|
|
{
|
|
if (!$this->isPluginEnabled($pluginId)) {
|
|
return [];
|
|
}
|
|
|
|
$parts = explode('\\', get_class($plugin));
|
|
if (count($parts) < 4) {
|
|
return [];
|
|
}
|
|
|
|
$pluginDirName = $parts[2];
|
|
$migrationsPath = APPPATH . "Plugins/{$pluginDirName}/Migrations/";
|
|
|
|
if (!is_dir($migrationsPath)) {
|
|
return [];
|
|
}
|
|
|
|
$files = glob($migrationsPath . '*.php') ?: [];
|
|
$migrationFiles = array_filter($files, static fn($f) => preg_match('/\/\d{14}_/', $f));
|
|
sort($migrationFiles);
|
|
|
|
return array_filter(
|
|
$migrationFiles,
|
|
static fn($file) => (int) substr(basename($file, '.php'), 0, 14) > $currentVersion
|
|
);
|
|
}
|
|
|
|
public function getAllPlugins(): array
|
|
{
|
|
return $this->plugins;
|
|
}
|
|
|
|
public function getEnabledPlugins(): array
|
|
{
|
|
return $this->enabledPlugins;
|
|
}
|
|
|
|
public function getPlugin(string $pluginId): ?PluginInterface
|
|
{
|
|
return $this->plugins[$pluginId] ?? null;
|
|
}
|
|
|
|
public function isPluginEnabled(string $pluginId): bool
|
|
{
|
|
$enabled = $this->configModel->getValue($pluginId, 'enabled');
|
|
return $enabled === '1' || $enabled === 'true';
|
|
}
|
|
|
|
public function canLoadPlugins(): bool
|
|
{
|
|
$db = Database::connect();
|
|
return $db->tableExists('plugin_config');
|
|
}
|
|
|
|
public function enablePlugin(string $pluginId): bool
|
|
{
|
|
$plugin = $this->getPlugin($pluginId);
|
|
if (!$plugin) {
|
|
log_message('error', "Plugin not found: {$pluginId}");
|
|
return false;
|
|
}
|
|
|
|
if (!$this->configModel->exists($pluginId, 'installed') || $this->configModel->getValue($pluginId, 'installed') === '0') {
|
|
if (!$plugin->install()) {
|
|
log_message('error', "Failed to install plugin: {$pluginId}");
|
|
return false;
|
|
}
|
|
$this->configModel->setValue($pluginId, 'installed', '1', true);
|
|
}
|
|
|
|
$this->configModel->setValue($pluginId, 'enabled', '1', true);
|
|
|
|
$this->runPendingMigrationsForPlugin($pluginId);
|
|
|
|
$this->registerNamespace($pluginId);
|
|
|
|
log_message('info', "Plugin enabled: {$pluginId}");
|
|
|
|
return true;
|
|
}
|
|
|
|
public function disablePlugin(string $pluginId): bool
|
|
{
|
|
if (!$this->getPlugin($pluginId)) {
|
|
log_message('error', "Plugin not found: {$pluginId}");
|
|
return false;
|
|
}
|
|
|
|
$this->configModel->setValue($pluginId, 'enabled', '0', true);
|
|
log_message('info', "Plugin disabled: {$pluginId}");
|
|
|
|
return true;
|
|
}
|
|
|
|
public function uninstallPlugin(string $pluginId): bool
|
|
{
|
|
$plugin = $this->getPlugin($pluginId);
|
|
if (!$plugin) {
|
|
log_message('error', "Plugin not found: {$pluginId}");
|
|
return false;
|
|
}
|
|
|
|
$this->disablePlugin($pluginId);
|
|
|
|
if (!$plugin->uninstall()) {
|
|
log_message('error', "Failed to uninstall plugin: {$pluginId}");
|
|
return false;
|
|
}
|
|
|
|
$this->unregisterPluginModules($pluginId);
|
|
|
|
$this->configModel->deleteAllNonControlForPlugin($pluginId);
|
|
$this->configModel->setValue($pluginId, 'installed', '0', true);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Remove all modules and sub-permissions the plugin registered via
|
|
* BasePlugin::registerModule()/registerSubPermission(). Matched by the
|
|
* enforced id convention: ids are the plugin id itself or prefixed with
|
|
* '{plugin_id}_'. Runs automatically on uninstall so plugins don't have
|
|
* to call unregisterModule() themselves. Grants cascade via FK.
|
|
*/
|
|
private function unregisterPluginModules(string $pluginId): void
|
|
{
|
|
$db = Database::connect();
|
|
|
|
// No FK cascade from modules to permissions — delete permissions first
|
|
$db->table('permissions')
|
|
->groupStart()
|
|
->where('permission_id', $pluginId)
|
|
->orLike('permission_id', $pluginId . '_', 'after')
|
|
->orWhere('module_id', $pluginId)
|
|
->orLike('module_id', $pluginId . '_', 'after')
|
|
->groupEnd()
|
|
->delete();
|
|
|
|
$db->table('modules')
|
|
->groupStart()
|
|
->where('module_id', $pluginId)
|
|
->orLike('module_id', $pluginId . '_', 'after')
|
|
->groupEnd()
|
|
->delete();
|
|
}
|
|
|
|
public function isPluginInstalled(string $pluginId): bool
|
|
{
|
|
return $this->configModel->getValue($pluginId, 'installed') === '1';
|
|
}
|
|
|
|
public function getSetting(string $pluginId, string $key, mixed $default = null): mixed
|
|
{
|
|
return $this->configModel->getValue($pluginId, $key) ?? $default;
|
|
}
|
|
|
|
public function setSetting(string $pluginId, string $key, mixed $value): bool
|
|
{
|
|
return $this->configModel->setValue($pluginId, $key, $value);
|
|
}
|
|
|
|
/**
|
|
* Registers PSR-4 namespaces for all plugin directories without touching the DB.
|
|
* Call this early (pre_system) so CI4's module route discovery can find each
|
|
* plugin's Config/Routes.php before the router runs.
|
|
*/
|
|
public static function registerAllNamespaces(): void
|
|
{
|
|
$pluginsPath = APPPATH . 'Plugins';
|
|
if (!is_dir($pluginsPath)) {
|
|
return;
|
|
}
|
|
|
|
$loader = Services::autoloader();
|
|
foreach (glob($pluginsPath . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR) ?: [] as $dir) {
|
|
$name = basename($dir);
|
|
$namespace = "App\\Plugins\\{$name}";
|
|
if (!in_array($namespace, self::$registeredNamespaces, true)) {
|
|
$loader->addNamespace($namespace, $dir . DIRECTORY_SEPARATOR);
|
|
self::$registeredNamespaces[] = $namespace;
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function resetStatic(): void
|
|
{
|
|
self::$discovered = false;
|
|
self::$registeredNamespaces = [];
|
|
}
|
|
|
|
private function registerNamespace(string $pluginId): void
|
|
{
|
|
$plugin = $this->plugins[$pluginId] ?? null;
|
|
if ($plugin === null) {
|
|
return;
|
|
}
|
|
|
|
// Derive the directory name from the class: App\Plugins\MailchimpPlugin\MailchimpPlugin → MailchimpPlugin
|
|
// Single-file plugins have only 3 segments (App\Plugins\ClassName) and have no subdirectory.
|
|
$parts = explode('\\', get_class($plugin));
|
|
if (count($parts) < 4) {
|
|
return;
|
|
}
|
|
|
|
$pluginDirName = $parts[2];
|
|
$namespace = "App\\Plugins\\{$pluginDirName}";
|
|
|
|
if (!in_array($namespace, self::$registeredNamespaces, true)) {
|
|
$loader = Services::autoloader();
|
|
$loader->addNamespace($namespace, APPPATH . "Plugins/{$pluginDirName}");
|
|
self::$registeredNamespaces[] = $namespace;
|
|
log_message('debug', "Registered namespace for plugin dir: {$pluginDirName}");
|
|
}
|
|
}
|
|
}
|