From cb621a6e09ba1a954592b0e8272a98f06fad4641 Mon Sep 17 00:00:00 2001 From: NaysKutzu Date: Thu, 14 May 2026 23:16:38 +0200 Subject: [PATCH 1/2] chore: bump version to v1.3.7.2; enhance plugin loader strategy and improve UI for plugin installation and search features --- CHANGELOG.md | 6 + app | 2 +- backend/app/App.php | 22 +- .../Admin/CloudPluginsController.php | 396 +++-- backend/app/Plugins/PluginProcessor.php | 41 + backend/cli | 2 +- backend/public/index.php | 2 +- frontendv2/package.json | 2 +- frontendv2/public/locales/en.json | 36 +- .../(app)/admin/feathercloud/plugins/page.tsx | 1431 ++++++++++++----- .../(app)/admin/feathercloud/spells/page.tsx | 375 +++-- runner/Cargo.lock | 2 +- runner/Cargo.toml | 2 +- 13 files changed, 1658 insertions(+), 661 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47cee5c8..b58f0555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v1.3.7.2 STABLE + +### Fixed + +- Enhanced the plugin loader strategy to resolve minor issues introduced in the previous update. by @nayskutzu + ## v1.3.7.1 STABLE ### Improved diff --git a/app b/app index 6ddbcdf5..57b5c368 100755 --- a/app +++ b/app @@ -48,7 +48,7 @@ define('APP_ADDONS_DIR', APP_STORAGE_DIR . 'addons'); define('APP_SOURCECODE_DIR', APP_DIR . 'app'); define('APP_ROUTES_DIR', APP_SOURCECODE_DIR . '/Api'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.1'); +define('APP_VERSION', 'v1.3.7.2'); define('APP_UPSTREAM', 'stable'); define('TELEMETRY', true); define('IS_CLI', true); diff --git a/backend/app/App.php b/backend/app/App.php index 1220cc1a..2dc8e9a4 100755 --- a/backend/app/App.php +++ b/backend/app/App.php @@ -201,12 +201,20 @@ public function registerApiRoutes(RouteCollection $routes): void } } - // Load plugin routes from backend/storage/addons/*/routes/ + // Load plugin routes from backend/storage/addons/*/Routes/ $addonsDir = __DIR__ . '/../storage/addons'; if (is_dir($addonsDir)) { + $addonIdentifiers = []; + foreach (new \DirectoryIterator($addonsDir) as $entry) { + if ($entry->isDir() && !$entry->isDot()) { + $addonIdentifiers[$entry->getBasename()] = true; + } + } + $pluginDirs = new \DirectoryIterator($addonsDir); foreach ($pluginDirs as $pluginDir) { if ($pluginDir->isDir() && !$pluginDir->isDot()) { + $currentAddonId = $pluginDir->getBasename(); $pluginRoutesDir = $pluginDir->getPathname() . '/Routes'; if (is_dir($pluginRoutesDir)) { $pluginIterator = new \RecursiveIteratorIterator( @@ -216,6 +224,18 @@ public function registerApiRoutes(RouteCollection $routes): void foreach ($pluginIterator as $file) { if ($file->isFile() && $file->getExtension() === 'php') { + $routeBasename = $file->getBasename('.php'); + if ( + isset($addonIdentifiers[$routeBasename]) + && $routeBasename !== $currentAddonId + ) { + self::getLogger()->warning( + 'Skipping addon route file whose name matches another addon identifier ' + . "(likely a mistaken copy): {$file->getPathname()}" + ); + + continue; + } try { $register = require $file->getPathname(); if (is_callable($register)) { diff --git a/backend/app/Controllers/Admin/CloudPluginsController.php b/backend/app/Controllers/Admin/CloudPluginsController.php index 4bb50213..d2683ed1 100755 --- a/backend/app/Controllers/Admin/CloudPluginsController.php +++ b/backend/app/Controllers/Admin/CloudPluginsController.php @@ -79,6 +79,12 @@ required: ['identifier'], properties: [ new OA\Property(property: 'identifier', type: 'string', description: 'Addon identifier to install', pattern: '^[a-zA-Z0-9_\\-]+$'), + new OA\Property( + property: 'queued_identifiers', + type: 'array', + items: new OA\Items(type: 'string'), + description: 'Identifiers selected for the same install session; plugin= dependencies in this list are installed automatically before this addon when missing.' + ), ] )] class CloudPluginsController @@ -572,6 +578,13 @@ public function searchByTag(Request $request, string $tag): Response required: true, schema: new OA\Schema(type: 'string') ), + new OA\Parameter( + name: 'pending_plugins', + in: 'query', + description: 'Comma-separated addon identifiers also queued for install; plugin= dependencies in this list count as satisfied.', + required: false, + schema: new OA\Schema(type: 'string') + ), ], responses: [ new OA\Response( @@ -684,72 +697,24 @@ public function checkRequirements(Request $request, string $identifier): Respons } } - // Check dependencies - $dependencyChecks = []; - $allDependenciesMet = true; - - // Download and parse conf.yml to check dependencies + // Check dependencies (conf.yml inside package); honour addons also selected in the download queue $downloadUrl = isset($latestVersion['download_url']) ? ('https://api.featherpanel.com' . $latestVersion['download_url']) : null; - if ($downloadUrl) { - $tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true) . '.fpa'; - $fileContent = @file_get_contents($downloadUrl, false, $context); - if ($fileContent !== false) { - file_put_contents($tempFile, $fileContent); - - // Extract conf.yml - $tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true); - @mkdir($tempDir, 0755, true); - $pwd = self::PASSWORD; - $unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir)); - exec($unzipCommand, $out, $code); - - if ($code === 0 && file_exists($tempDir . '/conf.yml')) { - try { - $conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml'); - $confDependencies = $conf['plugin']['dependencies'] ?? []; - - foreach ($confDependencies as $dep) { - $met = false; - $message = ''; - - if (strpos($dep, 'composer=') === 0) { - $composerPkg = substr($dep, strlen('composer=')); - $met = \App\Plugins\Dependencies\ComposerDependencies::isInstalled($composerPkg); - $message = $met ? 'Composer package installed' : "Composer package required: {$composerPkg}"; - } elseif (strpos($dep, 'plugin=') === 0) { - $pluginDep = substr($dep, strlen('plugin=')); - $met = \App\Plugins\Dependencies\AppDependencies::isInstalled($pluginDep); - $message = $met ? 'Plugin installed' : "Plugin required: {$pluginDep}"; - } elseif (strpos($dep, 'php=') === 0) { - $phpVersion = substr($dep, strlen('php=')); - $met = \App\Plugins\Dependencies\PhpVersionDependencies::isInstalled($phpVersion); - $message = $met ? 'PHP version requirement met' : "PHP version required: {$phpVersion}"; - } elseif (strpos($dep, 'php-ext=') === 0) { - $ext = substr($dep, strlen('php-ext=')); - $met = \App\Plugins\Dependencies\PhpExtensionDependencies::isInstalled($ext); - $message = $met ? 'PHP extension installed' : "PHP extension required: {$ext}"; - } else { - $met = true; // Unknown dependency format, assume met - $message = "Unknown dependency format: {$dep}"; - } - - $dependencyChecks[] = [ - 'dependency' => $dep, - 'met' => $met, - 'message' => $message, - ]; - - if (!$met) { - $allDependenciesMet = false; - } - } - } catch (\Exception $e) { - // Failed to parse conf.yml, skip dependency checks - } - } - - @exec('rm -rf ' . escapeshellarg($tempDir)); - @unlink($tempFile); + $eval = $this->evaluateConfDependencyChecksFromDownloadUrl($downloadUrl, $context); + $dependencyChecks = $eval['checks']; + $allDependenciesMet = $eval['all_met']; + $pendingQueued = $this->parsePendingPluginsQuery($request); + foreach ($dependencyChecks as &$depRow) { + if (($depRow['type'] ?? '') === 'plugin' && !($depRow['met'] ?? false) && in_array($depRow['name'], $pendingQueued, true)) { + $depRow['met'] = true; + $depRow['message'] = 'Queued in your download list (will be installed in the same session)'; + } + } + unset($depRow); + $allDependenciesMet = true; + foreach ($dependencyChecks as $depRow) { + if (!($depRow['met'] ?? false)) { + $allDependenciesMet = false; + break; } } @@ -841,11 +806,30 @@ public function install(Request $request): Response { try { $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } $identifier = $body['identifier'] ?? null; if (!$identifier || !preg_match('/^[a-zA-Z0-9_\-]+$/', (string) $identifier)) { return ApiResponse::error('Invalid identifier', 'INVALID_IDENTIFIER', 400); } + $installStack = $request->attributes->get('_fp_install_stack', []); + if (!is_array($installStack)) { + $installStack = []; + } + if (isset($installStack[$identifier])) { + return ApiResponse::error( + 'Circular dependency when resolving queued plugin installations. Install one of the queued plugins first, or adjust your download list.', + 'PLUGIN_INSTALL_QUEUE_CYCLE', + 400 + ); + } + $installStack[$identifier] = true; + $request->attributes->set('_fp_install_stack', $installStack); + + $queuedIdentifiers = $this->normalizeQueuedPluginIdentifiers($body['queued_identifiers'] ?? []); + if (!defined('APP_ADDONS_DIR')) { define('APP_ADDONS_DIR', dirname(__DIR__, 3) . '/storage/addons'); } @@ -876,98 +860,71 @@ public function install(Request $request): Response $pkg = $data['data']['package']; $latestVersion = $data['data']['latest_version'] ?? []; - // Check dependencies BEFORE downloading/installing + // Resolve plugin= dependencies using the same download queue (install missing deps first) + $downloadUrlForConf = isset($latestVersion['download_url']) ? ('https://api.featherpanel.com' . $latestVersion['download_url']) : null; $dependencyChecks = []; - $missingDependencies = []; - $allDependenciesMet = true; - - // Download and parse conf.yml to check dependencies - $downloadUrl = isset($latestVersion['download_url']) ? ('https://api.featherpanel.com' . $latestVersion['download_url']) : null; - if ($downloadUrl) { - $tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true) . '.fpa'; - $fileContent = @file_get_contents($downloadUrl, false, $context); - if ($fileContent !== false) { - file_put_contents($tempFile, $fileContent); - - // Extract conf.yml - $tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true); - @mkdir($tempDir, 0755, true); - $pwd = self::PASSWORD; - $unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir)); - exec($unzipCommand, $out, $code); - - if ($code === 0 && file_exists($tempDir . '/conf.yml')) { - try { - $conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml'); - $confDependencies = $conf['plugin']['dependencies'] ?? []; - - foreach ($confDependencies as $dep) { - $met = false; - $message = ''; - $type = 'unknown'; - $name = $dep; - - if (strpos($dep, 'composer=') === 0) { - $composerPkg = substr($dep, strlen('composer=')); - $met = \App\Plugins\Dependencies\ComposerDependencies::isInstalled($composerPkg); - $message = $met ? 'Composer package installed' : "Composer package required: {$composerPkg}"; - $type = 'composer'; - $name = $composerPkg; - } elseif (strpos($dep, 'plugin=') === 0) { - $pluginDep = substr($dep, strlen('plugin=')); - $met = \App\Plugins\Dependencies\AppDependencies::isInstalled($pluginDep); - $message = $met ? 'Plugin installed' : "Plugin required: {$pluginDep}"; - $type = 'plugin'; - $name = $pluginDep; - } elseif (strpos($dep, 'php=') === 0) { - $phpVersion = substr($dep, strlen('php=')); - $met = \App\Plugins\Dependencies\PhpVersionDependencies::isInstalled($phpVersion); - $message = $met ? 'PHP version requirement met' : "PHP version required: {$phpVersion}"; - $type = 'php'; - $name = $phpVersion; - } elseif (strpos($dep, 'php-ext=') === 0) { - $ext = substr($dep, strlen('php-ext=')); - $met = \App\Plugins\Dependencies\PhpExtensionDependencies::isInstalled($ext); - $message = $met ? 'PHP extension installed' : "PHP extension required: {$ext}"; - $type = 'php-ext'; - $name = $ext; - } else { - $met = true; // Unknown dependency format, assume met - $message = "Unknown dependency format: {$dep}"; - } + $allDependenciesMet = false; + + for ($depResolveAttempt = 0; $depResolveAttempt < 32; ++$depResolveAttempt) { + $eval = $this->evaluateConfDependencyChecksFromDownloadUrl($downloadUrlForConf, $context); + $dependencyChecks = $eval['checks']; + if ($eval['all_met']) { + $allDependenciesMet = true; + break; + } - $dependencyChecks[] = [ - 'dependency' => $dep, - 'type' => $type, - 'name' => $name, - 'met' => $met, - 'message' => $message, - ]; - - if (!$met) { - $missingDependencies[] = $dep; - $allDependenciesMet = false; - } - } - } catch (\Exception $e) { - // Failed to parse conf.yml, log but continue (fail open for safety) - App::getInstance(true)->getLogger()->warning('Failed to parse conf.yml for dependency check: ' . $e->getMessage()); - } + $pluginToInstallFirst = null; + foreach ($eval['checks'] as $check) { + if (($check['type'] ?? '') === 'plugin' && !($check['met'] ?? false) && in_array($check['name'], $queuedIdentifiers, true)) { + $pluginToInstallFirst = $check['name']; + break; } + } - @exec('rm -rf ' . escapeshellarg($tempDir)); - @unlink($tempFile); + if ($pluginToInstallFirst === null) { + return ApiResponse::error( + 'Cannot install plugin: missing dependencies', + 'MISSING_DEPENDENCIES', + 412, + [ + 'missing_dependencies' => $eval['missing'], + 'dependency_details' => $eval['checks'], + ] + ); + } + + if (\App\Plugins\Dependencies\AppDependencies::isInstalled($pluginToInstallFirst)) { + continue; + } + + $nestedPayload = json_encode([ + 'identifier' => $pluginToInstallFirst, + 'queued_identifiers' => $queuedIdentifiers, + ]); + $nestedRequest = Request::create( + '/api/admin/plugins/online/install', + 'POST', + [], + [], + [], + ['CONTENT_TYPE' => 'application/json'], + $nestedPayload + ); + $nestedRequest->attributes->set('user', $request->attributes->get('user')); + $nestedRequest->attributes->set('_fp_install_stack', $request->attributes->get('_fp_install_stack', [])); + + $nestedResponse = $this->install($nestedRequest); + if ($nestedResponse->getStatusCode() >= 400) { + return $nestedResponse; } } - // If dependencies are not met, return error with details if (!$allDependenciesMet) { return ApiResponse::error( - 'Cannot install plugin: missing dependencies', - 'MISSING_DEPENDENCIES', - 412, + 'Unable to resolve plugin dependencies from the install queue', + 'PLUGIN_DEPENDENCY_RESOLVE_LIMIT', + 500, [ - 'missing_dependencies' => $missingDependencies, 'dependency_details' => $dependencyChecks, ] ); @@ -1104,6 +1061,151 @@ public function install(Request $request): Response } } + /** + * @param mixed $raw + * + * @return list + */ + private function normalizeQueuedPluginIdentifiers(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $unique = []; + foreach ($raw as $id) { + if (!is_string($id)) { + continue; + } + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $id)) { + continue; + } + $unique[$id] = true; + } + + return array_keys($unique); + } + + /** + * @return list + */ + private function parsePendingPluginsQuery(Request $request): array + { + $raw = $request->query->get('pending_plugins'); + if (is_array($raw)) { + return $this->normalizeQueuedPluginIdentifiers($raw); + } + if (is_string($raw) && $raw !== '') { + $parts = array_map('trim', explode(',', $raw)); + + return $this->normalizeQueuedPluginIdentifiers($parts); + } + + return []; + } + + /** + * Download the .fpa and read conf.yml dependency lines. + * + * @return array{checks: list>, all_met: bool, missing: list} + */ + private function evaluateConfDependencyChecksFromDownloadUrl(?string $downloadUrl, mixed $streamContext): array + { + $dependencyChecks = []; + $missingDependencies = []; + $allDependenciesMet = true; + + if ($downloadUrl === null || $downloadUrl === '') { + return ['checks' => [], 'all_met' => true, 'missing' => []]; + } + + $tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true) . '.fpa'; + $fileContent = @file_get_contents($downloadUrl, false, $streamContext); + if ($fileContent === false) { + return ['checks' => [], 'all_met' => true, 'missing' => []]; + } + + file_put_contents($tempFile, $fileContent); + + $tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true); + @mkdir($tempDir, 0755, true); + $pwd = self::PASSWORD; + $unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir)); + exec($unzipCommand, $out, $code); + + if ($code === 0 && file_exists($tempDir . '/conf.yml')) { + try { + $conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml'); + $confDependencies = $conf['plugin']['dependencies'] ?? []; + if (!is_array($confDependencies)) { + $confDependencies = []; + } + + foreach ($confDependencies as $dep) { + if (!is_string($dep)) { + continue; + } + $met = false; + $message = ''; + $type = 'unknown'; + $name = $dep; + + if (strpos($dep, 'composer=') === 0) { + $composerPkg = substr($dep, strlen('composer=')); + $met = \App\Plugins\Dependencies\ComposerDependencies::isInstalled($composerPkg); + $message = $met ? 'Composer package installed' : "Composer package required: {$composerPkg}"; + $type = 'composer'; + $name = $composerPkg; + } elseif (strpos($dep, 'plugin=') === 0) { + $pluginDep = substr($dep, strlen('plugin=')); + $met = \App\Plugins\Dependencies\AppDependencies::isInstalled($pluginDep); + $message = $met ? 'Plugin installed' : "Plugin required: {$pluginDep}"; + $type = 'plugin'; + $name = $pluginDep; + } elseif (strpos($dep, 'php=') === 0) { + $phpVersion = substr($dep, strlen('php=')); + $met = \App\Plugins\Dependencies\PhpVersionDependencies::isInstalled($phpVersion); + $message = $met ? 'PHP version requirement met' : "PHP version required: {$phpVersion}"; + $type = 'php'; + $name = $phpVersion; + } elseif (strpos($dep, 'php-ext=') === 0) { + $ext = substr($dep, strlen('php-ext=')); + $met = \App\Plugins\Dependencies\PhpExtensionDependencies::isInstalled($ext); + $message = $met ? 'PHP extension installed' : "PHP extension required: {$ext}"; + $type = 'php-ext'; + $name = $ext; + } else { + $met = true; + $message = "Unknown dependency format: {$dep}"; + } + + $dependencyChecks[] = [ + 'dependency' => $dep, + 'type' => $type, + 'name' => $name, + 'met' => $met, + 'message' => $message, + ]; + + if (!$met) { + $missingDependencies[] = $dep; + $allDependenciesMet = false; + } + } + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->warning('Failed to parse conf.yml for dependency check: ' . $e->getMessage()); + } + } + + @exec('rm -rf ' . escapeshellarg($tempDir)); + @unlink($tempFile); + + return [ + 'checks' => $dependencyChecks, + 'all_met' => $allDependenciesMet, + 'missing' => $missingDependencies, + ]; + } + /** * Perform the common installation routine given an extracted addon temp directory. * Handles identifier resolution (from conf.yml if not provided), copying files, diff --git a/backend/app/Plugins/PluginProcessor.php b/backend/app/Plugins/PluginProcessor.php index 653f1c60..ab3196ff 100755 --- a/backend/app/Plugins/PluginProcessor.php +++ b/backend/app/Plugins/PluginProcessor.php @@ -54,6 +54,12 @@ public static function getEventProcessor(string $identifier): ?AppPlugin $entryClass = $config['plugin']['name']; $eventClass = "App\\Addons\\{$identifier}\\{$entryClass}"; + if (!class_exists($eventClass)) { + $discovered = self::discoverAppPluginClassInAddonRoot($identifier); + if ($discovered !== null) { + $eventClass = $discovered; + } + } if (!class_exists($eventClass)) { $logger->warning("Event class not found: {$eventClass}"); @@ -78,6 +84,41 @@ public static function getEventProcessor(string $identifier): ?AppPlugin } } + /** + * When plugin.name in conf.yml does not match the AppPlugin entry class (for example + * after a copy-paste between similar addons), locate the single AppPlugin implementation + * shipped at the root of the addon directory. + */ + private static function discoverAppPluginClassInAddonRoot(string $identifier): ?string + { + $dir = PluginHelper::getPluginsDir() . '/' . $identifier; + if (!is_dir($dir)) { + return null; + } + + $candidates = []; + foreach (glob($dir . '/*.php') ?: [] as $file) { + $basename = basename($file, '.php'); + $class = "App\\Addons\\{$identifier}\\{$basename}"; + if (class_exists($class) && is_subclass_of($class, AppPlugin::class)) { + $candidates[] = $class; + } + } + + if (count($candidates) === 1) { + return $candidates[0]; + } + + if (count($candidates) > 1) { + App::getInstance(true)->getLogger()->warning( + 'Multiple AppPlugin classes in addon root for ' . $identifier + . '; set plugin.name in conf.yml to the correct entry class name.' + ); + } + + return null; + } + /** * Check if a plugin has a valid event implementation. * diff --git a/backend/cli b/backend/cli index b58b54a3..1947eb20 100755 --- a/backend/cli +++ b/backend/cli @@ -42,7 +42,7 @@ define('APP_START', microtime(true)); define('APP_DIR', APP_PUBLIC . '/'); define('APP_CRON_DIR', APP_PUBLIC . '/storage/cron/'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.1'); +define('APP_VERSION', 'v1.3.7.2'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); define('TELEMETRY', true); diff --git a/backend/public/index.php b/backend/public/index.php index 2fd98895..b52abbbc 100755 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -34,7 +34,7 @@ define('SYSTEM_OS_NAME', gethostname() . '/' . PHP_OS_FAMILY); define('SYSTEM_KERNEL_NAME', php_uname('s')); define('TELEMETRY', true); -define('APP_VERSION', 'v1.3.7.1'); +define('APP_VERSION', 'v1.3.7.2'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); diff --git a/frontendv2/package.json b/frontendv2/package.json index 26c7dc01..4d052007 100644 --- a/frontendv2/package.json +++ b/frontendv2/package.json @@ -1,6 +1,6 @@ { "name": "frontendv2", - "version": "1.3.7+1", + "version": "1.3.7+2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/frontendv2/public/locales/en.json b/frontendv2/public/locales/en.json index 656466b0..b913068e 100644 --- a/frontendv2/public/locales/en.json +++ b/frontendv2/public/locales/en.json @@ -5983,6 +5983,10 @@ "action": "Cloud management" }, "popular": "Popular Plugins", + "search_section_title": "Search plugins", + "search_helper": "Search by addon name, author, or keyword from the description. Press Enter or use the Search button to load results.", + "search_button": "Search", + "sort_label": "Sort plugin list", "search_placeholder": "Search online addons...", "verified_only": "Verified Only", "loading": "Loading online addons...", @@ -5997,8 +6001,10 @@ "install": "Install", "installing": "Installing...", "requires_cloud": "Requires Cloud Account", + "premium_not_licensed": "Not licensed on this FeatherCloud team", "verified": "Verified", "premium": "Premium", + "purchase_at_store": "Buy on store", "website": "Website", "downloads": "downloads", "tag_label": "Tag:", @@ -6010,6 +6016,14 @@ "available": "Available Plugins", "clear_search": "Clear Search", "no_results": "No results found", + "online_list_heading": "Online addons", + "online_list_count": "Showing {shown} of {total} addons", + "purchase_official_store": "Official store", + "results_title": "Search results", + "results_showing": "Showing {shown} of {total} addons", + "load_more": "Load more", + "load_more_hint": "Loaded page {page} of {pages}. Tap to fetch the next batch.", + "results_count": "{count} addons", "featured": "Featured", "has_dependencies": "Has Dependencies", "install_success": "Successfully installed {identifier}", @@ -6041,7 +6055,20 @@ "install_success_multiple": "Installed {count} plugins successfully", "install_failed": "Failed to install selected plugins", "install_failed_single": "Failed to install {identifier}", - "multiple_requirements_issues": "{count} plugins have unmet requirements and cannot be installed" + "multiple_requirements_issues": "{count} plugins have unmet requirements and cannot be installed", + "premium_not_owned": "This premium addon is not in your FeatherCloud purchases. Only addons your team already owns can go in the download list.", + "premium_skipped_not_owned": "Some premium plugins were skipped because your FeatherCloud team does not own them." + }, + "ui_preview": { + "banner_title": "UI preview mode", + "banner_body": "You are viewing mock marketplace cards only. Installs are simulated (no server changes). Type a preview keyword in the search field and press Enter again to exit.", + "preview_on": "Plugin UI preview turned on", + "preview_off": "Plugin UI preview turned off", + "install_disabled": "Preview mode: install actions are disabled", + "install_simulated": "Simulated install: {identifier} (preview only)", + "bulk_simulated": "Simulated batch install for {count} plugin(s) (preview only)", + "open_sample_dialog": "Open sample missing-requirements dialog", + "secret_hint": "Tip: type testpluginuinow or testingpluginui in the search field and press Enter to toggle UI preview (mock list, simulated installs)." }, "repo": { "title": "Plugin Repositories", @@ -6073,6 +6100,13 @@ "learn_more": "Learn more" }, "search_placeholder": "Search online spells...", + "search_section_title": "Search spells", + "search_helper": "Search by spell name, category, or keyword from the description. Press Enter or Search to load results.", + "search_button": "Search", + "online_list_heading": "Online spells", + "online_list_count": "Showing {shown} of {total} spells", + "load_more": "Load more", + "load_more_hint": "Loaded page {page} of {pages}. Tap to fetch the next batch.", "loading": "Loading online spells...", "grid": { "verified": "Verified", diff --git a/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx b/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx index 3e904c00..05ecdf92 100644 --- a/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx +++ b/frontendv2/src/app/(app)/admin/feathercloud/plugins/page.tsx @@ -15,13 +15,15 @@ See the LICENSE file or . 'use client'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useTranslation } from '@/contexts/TranslationContext'; import { usePluginWidgets } from '@/hooks/usePluginWidgets'; import { WidgetRenderer } from '@/components/server/WidgetRenderer'; import { useFeatherCloud, type CreditsData, type TeamData } from '@/hooks/useFeatherCloud'; +import { useChromeLayout } from '@/hooks/useChromeLayout'; import axios from 'axios'; import { toast } from 'sonner'; import { @@ -38,8 +40,6 @@ import { Globe, X, BadgeCheck as CheckIcon, - ChevronLeft, - ChevronRight, Search, Lock, Package, @@ -48,6 +48,8 @@ import { CheckCircle2, XCircle, Layers, + ExternalLink, + FlaskConical, } from 'lucide-react'; import { PageHeader } from '@/components/featherui/PageHeader'; import { ResourceCard, type ResourceBadge } from '@/components/featherui/ResourceCard'; @@ -88,6 +90,8 @@ interface OnlinePagination { current_page: number; total_pages: number; total_records: number; + has_next?: boolean; + has_prev?: boolean; } interface DependencyCheck { @@ -125,6 +129,141 @@ interface RequirementsCheckResult { }; } +/** Any of these in the search box + Enter toggles UI preview mode. */ +const PLUGIN_UI_PREVIEW_SECRETS = ['testpluginuinow', 'testingpluginui'] as const; + +function pluginSearchHasPreviewSecret(raw: string): boolean { + return PLUGIN_UI_PREVIEW_SECRETS.some((s) => raw.includes(s)); +} + +function stripPluginPreviewSecrets(raw: string): string { + let out = raw; + for (const s of PLUGIN_UI_PREVIEW_SECRETS) { + out = out.split(s).join(''); + } + return out.replace(/\s{2,}/g, ' ').trim(); +} + +const PLUGIN_UI_PREVIEW_MOCK_ADDONS: OnlineAddon[] = [ + { + identifier: 'billingcore', + name: 'Billing Core (preview)', + description: + 'Mock dependency base. Add this to your download list, then open Billing Resources to see the queue-aware requirement check.', + icon: null, + website: null, + author: 'UI Preview', + tags: ['billing', 'mock'], + verified: true, + downloads: 2400, + premium: 0, + latest_version: { + version: '2.1.0', + download_url: '/packages/billingcore/download/2.1.0', + dependencies: [], + }, + }, + { + identifier: 'billingresources', + name: 'Billing Resources (preview)', + description: + 'Mock addon that depends on Billing Core. Used to exercise the requirements dialog and download list.', + icon: null, + website: null, + author: 'UI Preview', + tags: ['billing', 'mock'], + verified: true, + downloads: 980, + premium: 0, + latest_version: { + version: '1.4.2', + download_url: '/packages/billingresources/download/1.4.2', + dependencies: ['plugin=billingcore'], + }, + }, + { + identifier: 'premiumstorepreview', + name: 'Premium Store Card (preview)', + description: + 'Premium mock: with FeatherCloud linked you can add to the list; without cloud, only the official store link is shown.', + icon: null, + website: 'https://example.com', + author: 'UI Preview', + tags: ['premium', 'mock'], + verified: true, + downloads: 3, + premium: 1, + premium_price: '5.00', + premium_link: 'https://example.com/purchase/premiumstorepreview', + latest_version: { + version: '1.0.0', + download_url: '/packages/premiumstorepreview/download/1.0.0', + dependencies: [], + }, + }, + { + identifier: 'simplefreepreview', + name: 'Simple Free Plugin (preview)', + description: 'Plain list row with no premium link or extra dependencies.', + icon: null, + website: null, + author: 'UI Preview', + tags: ['free', 'mock'], + verified: false, + downloads: 42, + premium: 0, + latest_version: { + version: '0.9.1', + download_url: '/packages/simplefreepreview/download/0.9.1', + dependencies: [], + }, + }, +]; + +function buildMockRequirementsSampleDialog(): RequirementsCheckResult { + return { + can_install: false, + already_installed: false, + update_available: false, + installed_version: null, + latest_version: '1.0.0', + package: { + identifier: 'billingreferrals', + name: 'Billing Referrals (sample)', + description: null, + version: '1.0.0', + author: 'UI Preview', + verified: true, + premium: 0, + }, + dependencies: { + checks: [ + { + dependency: 'plugin=billingcore', + type: 'plugin', + name: 'billingcore', + met: false, + message: 'Plugin required: billingcore', + }, + { + dependency: 'php=8.1', + type: 'php', + name: '8.1', + met: true, + message: 'PHP version requirement met', + }, + ], + all_met: false, + }, + panel_version: { + ok: true, + message: null, + min: '1.0.0', + max: '3.0.0', + }, + }; +} + export default function PluginsPage() { const { t } = useTranslation(); const router = useRouter(); @@ -154,15 +293,62 @@ export default function PluginsPage() { const [selectedPluginIds, setSelectedPluginIds] = useState([]); const [queuedPlugins, setQueuedPlugins] = useState>({}); const [bulkInstalling, setBulkInstalling] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); // Dependency check state const [requirementsDialogOpen, setRequirementsDialogOpen] = useState(false); const [requirementsCheck, setRequirementsCheck] = useState(null); const [, setCheckingRequirements] = useState(false); const [pendingInstallId, setPendingInstallId] = useState(null); + const [uiPreviewMode, setUiPreviewMode] = useState(false); + const [ownedCloudPackageIds, setOwnedCloudPackageIds] = useState([]); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [portalReady, setPortalReady] = useState(false); + + const onlineAddonsRef = useRef(onlineAddons); + const popularAddonsRef = useRef(popularAddons); + const ownedCloudPackageIdsRef = useRef(ownedCloudPackageIds); + onlineAddonsRef.current = onlineAddons; + popularAddonsRef.current = popularAddons; + ownedCloudPackageIdsRef.current = ownedCloudPackageIds; + + const { chromeLayout } = useChromeLayout(); const { fetchWidgets, getWidgets } = usePluginWidgets('admin-feathercloud-plugins'); + const fetchOwnedCloudPackageIds = useCallback(async () => { + const ids = new Set(); + let page = 1; + const limit = 100; + try { + for (;;) { + const res = await axios.get('/api/admin/cloud/data/products', { params: { page, limit } }); + const data = res.data?.data; + const purchases = Array.isArray(data?.purchases) ? data.purchases : []; + for (const p of purchases) { + const raw = p?.product?.identifier; + if (typeof raw === 'string' && raw.trim() !== '') { + ids.add(raw.trim().toLowerCase()); + } + } + if (purchases.length < limit) { + break; + } + const total = typeof data?.pagination?.total === 'number' ? data.pagination.total : 0; + if (total > 0 && page * limit >= total) { + break; + } + page += 1; + if (page > 100) { + break; + } + } + setOwnedCloudPackageIds([...ids]); + } catch { + setOwnedCloudPackageIds([]); + } + }, []); + const fetchCloudData = useCallback(async () => { try { const credsResponse = await axios.get('/api/admin/cloud/credentials'); @@ -174,11 +360,42 @@ export default function PluginsPage() { const team = await fetchTeam(); setCloudCredits(credits); setCloudTeam(team); + await fetchOwnedCloudPackageIds(); + } else { + setCloudCredits(null); + setCloudTeam(null); + setOwnedCloudPackageIds([]); } } catch (error) { console.error('Failed to fetch cloud credentials:', error); } - }, [fetchCredits, fetchTeam]); + }, [fetchCredits, fetchTeam, fetchOwnedCloudPackageIds]); + + useEffect(() => { + setPortalReady(true); + }, []); + + useEffect(() => { + const read = () => { + try { + setSidebarCollapsed(localStorage.getItem('featherpanel_sidebar_collapsed') === 'true'); + } catch { + setSidebarCollapsed(false); + } + }; + read(); + window.addEventListener('toggle-sidebar', read as EventListener); + const onStorage = (e: StorageEvent) => { + if (e.key === 'featherpanel_sidebar_collapsed') { + read(); + } + }; + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener('toggle-sidebar', read as EventListener); + window.removeEventListener('storage', onStorage); + }; + }, []); const fetchInstalledPlugins = useCallback(async () => { try { @@ -200,8 +417,15 @@ export default function PluginsPage() { }, []); const fetchOnlineAddons = useCallback( - async (page = currentOnlinePage, search = onlineSearch) => { - setOnlineLoading(true); + async (page: number, mode: 'replace' | 'append' = 'replace') => { + if (uiPreviewMode) { + return; + } + if (mode === 'append') { + setLoadingMore(true); + } else { + setOnlineLoading(true); + } setOnlineError(null); const params = new URLSearchParams({ @@ -211,25 +435,155 @@ export default function PluginsPage() { sort_order: 'DESC', }); - if (search) params.set('q', search); + if (onlineSearch) { + const safeQuery = stripPluginPreviewSecrets(onlineSearch); + if (safeQuery) { + params.set('q', safeQuery); + } + } if (verifiedOnly) params.set('verified', '1'); if (selectedTag) params.set('tag', selectedTag); try { const response = await axios.get(`/api/admin/plugins/online/list?${params.toString()}`); const addons: OnlineAddon[] = response.data?.data?.addons || []; - setOnlineAddons(addons); - setOnlinePagination(response.data?.data?.pagination || null); + const pagination = response.data?.data?.pagination || null; + + if (mode === 'append') { + setOnlineAddons((prev) => { + const seen = new Set(prev.map((a) => a.identifier)); + const merged = [...prev]; + for (const a of addons) { + if (!seen.has(a.identifier)) { + seen.add(a.identifier); + merged.push(a); + } + } + return merged; + }); + } else { + setOnlineAddons(addons); + } + setOnlinePagination(pagination); + setCurrentOnlinePage(page); } catch (err: unknown) { const e = err as { response?: { data?: { message?: string } } }; setOnlineError(e?.response?.data?.message || t('admin.marketplace.plugins.loading_error')); } finally { - setOnlineLoading(false); + if (mode === 'append') { + setLoadingMore(false); + } else { + setOnlineLoading(false); + } + } + }, + [onlineSearch, verifiedOnly, sortBy, selectedTag, t, uiPreviewMode], + ); + + const buildPreviewRequirementsCheck = useCallback( + (identifier: string): RequirementsCheckResult => { + const pending = selectedPluginIds; + const installed = installedPluginIds; + const coreMet = installed.includes('billingcore') || pending.includes('billingcore'); + + if (identifier === 'billingresources') { + const checks: DependencyCheck[] = [ + { + dependency: 'plugin=billingcore', + type: 'plugin', + name: 'billingcore', + met: coreMet, + message: coreMet + ? pending.includes('billingcore') && !installed.includes('billingcore') + ? 'Queued in your download list (will be installed in the same session)' + : 'Plugin installed' + : 'Plugin required: billingcore', + }, + ]; + const allMet = checks.every((c) => c.met); + return { + can_install: allMet, + already_installed: installed.includes(identifier), + update_available: false, + installed_version: installed.includes(identifier) ? '1.0.0' : null, + latest_version: '1.4.2', + package: { + identifier, + name: 'Billing Resources (preview)', + description: null, + version: '1.4.2', + author: 'UI Preview', + verified: true, + premium: 0, + }, + dependencies: { checks, all_met: allMet }, + panel_version: { + ok: true, + message: null, + min: '1.0.0', + max: '3.0.0', + }, + }; } + + return { + can_install: !installed.includes(identifier), + already_installed: installed.includes(identifier), + update_available: false, + installed_version: installed.includes(identifier) ? '1.0.0' : null, + latest_version: '1.0.0', + package: { + identifier, + name: PLUGIN_UI_PREVIEW_MOCK_ADDONS.find((a) => a.identifier === identifier)?.name ?? identifier, + description: null, + version: '1.0.0', + author: 'UI Preview', + verified: true, + premium: 0, + }, + dependencies: { + checks: [ + { + dependency: 'php=8.1', + type: 'php', + name: '8.1', + met: true, + message: 'PHP version requirement met', + }, + ], + all_met: true, + }, + panel_version: { ok: true, message: null, min: null, max: null }, + }; }, - [currentOnlinePage, onlineSearch, verifiedOnly, sortBy, selectedTag, t], + [selectedPluginIds, installedPluginIds], ); + const tryToggleUiPreviewFromSearch = useCallback((): boolean => { + if (!pluginSearchHasPreviewSecret(onlineSearch)) { + return false; + } + const next = !uiPreviewMode; + setUiPreviewMode(next); + setOnlineSearch((s) => stripPluginPreviewSecrets(s)); + toast.message( + next + ? t('admin.marketplace.plugins.ui_preview.preview_on') + : t('admin.marketplace.plugins.ui_preview.preview_off'), + ); + if (!next) { + void fetchInstalledPlugins(); + } + return true; + }, [onlineSearch, uiPreviewMode, t, fetchInstalledPlugins]); + + const runSearchOrPreviewToggle = useCallback(() => { + if (tryToggleUiPreviewFromSearch()) { + return; + } + void fetchOnlineAddons(1, 'replace'); + }, [tryToggleUiPreviewFromSearch, fetchOnlineAddons]); + useEffect(() => { fetchWidgets(); fetchCloudData(); @@ -238,10 +592,40 @@ export default function PluginsPage() { }, [fetchCloudData, fetchPopularAddons, fetchInstalledPlugins, fetchWidgets]); useEffect(() => { - fetchOnlineAddons(); - }, [fetchOnlineAddons]); + if (!uiPreviewMode) { + return; + } + setOnlineLoading(false); + setOnlineError(null); + setOnlineAddons(PLUGIN_UI_PREVIEW_MOCK_ADDONS); + setOnlinePagination({ + current_page: 1, + total_pages: 1, + total_records: PLUGIN_UI_PREVIEW_MOCK_ADDONS.length, + has_next: false, + has_prev: false, + }); + setPopularAddons(PLUGIN_UI_PREVIEW_MOCK_ADDONS.slice(0, 2)); + setInstalledPluginIds([]); + setSelectedPluginIds([]); + setQueuedPlugins({}); + }, [uiPreviewMode]); + + useEffect(() => { + if (uiPreviewMode) { + return; + } + fetchOnlineAddons(1, 'replace'); + }, [fetchOnlineAddons, uiPreviewMode]); const viewPackageDetails = async (addon: OnlineAddon) => { + if (uiPreviewMode) { + const found = PLUGIN_UI_PREVIEW_MOCK_ADDONS.find((a) => a.identifier === addon.identifier) || addon; + setSelectedPackage(found); + setPackageDetailsOpen(true); + setPackageDetailsLoading(false); + return; + } setSelectedPackage(addon); setPackageDetailsOpen(true); setPackageDetailsLoading(true); @@ -255,8 +639,19 @@ export default function PluginsPage() { }; const checkRequirements = async (identifier: string): Promise => { + if (uiPreviewMode) { + return buildPreviewRequirementsCheck(identifier); + } try { - const response = await axios.get(`/api/admin/plugins/online/${identifier}/check`); + const params = new URLSearchParams(); + if (selectedPluginIds.length > 0) { + params.set('pending_plugins', selectedPluginIds.join(',')); + } + const qs = params.toString(); + const url = qs + ? `/api/admin/plugins/online/${encodeURIComponent(identifier)}/check?${qs}` + : `/api/admin/plugins/online/${encodeURIComponent(identifier)}/check`; + const response = await axios.get(url); return response.data?.data || null; } catch (err) { console.error('Failed to check requirements:', err); @@ -290,8 +685,25 @@ export default function PluginsPage() { const performInstall = async (identifier: string) => { setInstallingOnlineId(identifier); setRequirementsDialogOpen(false); + if (uiPreviewMode) { + try { + await new Promise((r) => setTimeout(r, 450)); + toast.success( + t('admin.marketplace.plugins.ui_preview.install_simulated', { + identifier, + }), + ); + } finally { + setInstallingOnlineId(null); + setPendingInstallId(null); + } + return; + } try { - await axios.post('/api/admin/plugins/online/install', { identifier }); + await axios.post('/api/admin/plugins/online/install', { + identifier, + queued_identifiers: selectedPluginIds, + }); toast.success( t('admin.marketplace.plugins.install_success', { identifier, @@ -362,44 +774,83 @@ export default function PluginsPage() { // Show warning about skipped plugins if (pluginsWithIssues.length > 1) { - toast.warning( + toast( t('admin.marketplace.plugins.queue.multiple_requirements_issues', { count: String(pluginsWithIssues.length), }), + { + className: + 'border border-amber-500/25 bg-card text-foreground shadow-lg [&_[data-description]]:text-muted-foreground', + }, ); } return; } + // Drop premium rows the linked FeatherCloud account does not own (defense in depth) + const toInstall = pluginsReady.filter((identifier) => { + const row = + onlineAddonsRef.current.find((a) => a.identifier === identifier) ?? + popularAddonsRef.current.find((a) => a.identifier === identifier) ?? + (uiPreviewMode ? PLUGIN_UI_PREVIEW_MOCK_ADDONS.find((a) => a.identifier === identifier) : undefined); + if (!row || row.premium !== 1) { + return true; + } + const id = identifier.toLowerCase(); + if (uiPreviewMode && id === 'premiumstorepreview') { + return true; + } + return ownedCloudPackageIdsRef.current.some((x) => x.toLowerCase() === id); + }); + if (toInstall.length < pluginsReady.length) { + toast.message(t('admin.marketplace.plugins.queue.premium_skipped_not_owned')); + } + // Install all ready plugins let successCount = 0; - for (const identifier of pluginsReady) { - try { - await axios.post('/api/admin/plugins/online/install', { identifier }); - successCount++; - } catch (err: unknown) { - const e = err as { response?: { data?: { message?: string } } }; - toast.error( - e?.response?.data?.message || - t('admin.marketplace.plugins.queue.install_failed_single', { - identifier, - }), - ); + if (uiPreviewMode) { + successCount = toInstall.length; + } else { + for (const identifier of toInstall) { + try { + await axios.post('/api/admin/plugins/online/install', { + identifier, + queued_identifiers: selectedPluginIds, + }); + successCount++; + } catch (err: unknown) { + const e = err as { response?: { data?: { message?: string } } }; + toast.error( + e?.response?.data?.message || + t('admin.marketplace.plugins.queue.install_failed_single', { + identifier, + }), + ); + } } } if (successCount > 0) { - toast.success( - successCount === 1 - ? t('admin.marketplace.plugins.queue.install_success_single') - : t('admin.marketplace.plugins.queue.install_success_multiple', { - count: String(successCount), - }), - ); - await fetchInstalledPlugins(); + if (uiPreviewMode) { + toast.success( + t('admin.marketplace.plugins.ui_preview.bulk_simulated', { + count: String(successCount), + }), + ); + } else { + toast.success( + successCount === 1 + ? t('admin.marketplace.plugins.queue.install_success_single') + : t('admin.marketplace.plugins.queue.install_success_multiple', { + count: String(successCount), + }), + ); + await fetchInstalledPlugins(); + setTimeout(() => window.location.reload(), 1500); + } setSelectedPluginIds([]); - setTimeout(() => window.location.reload(), 1500); + setQueuedPlugins({}); } else { toast.error(t('admin.marketplace.plugins.queue.install_failed')); } @@ -407,9 +858,60 @@ export default function PluginsPage() { setBulkInstalling(false); }; + const isPremiumOwnedForQueue = useCallback( + (identifier: string) => { + const id = identifier.toLowerCase(); + if (uiPreviewMode && id === 'premiumstorepreview') { + return true; + } + return ownedCloudPackageIdsRef.current.some((x) => x.toLowerCase() === id); + }, + [uiPreviewMode], + ); + + const lookupAddonRow = useCallback( + (identifier: string): OnlineAddon | undefined => + onlineAddonsRef.current.find((a) => a.identifier === identifier) ?? + popularAddonsRef.current.find((a) => a.identifier === identifier) ?? + (uiPreviewMode ? PLUGIN_UI_PREVIEW_MOCK_ADDONS.find((a) => a.identifier === identifier) : undefined), + [uiPreviewMode], + ); + + useEffect(() => { + if (uiPreviewMode) { + return; + } + const ownedSet = new Set(ownedCloudPackageIds.map((x) => x.toLowerCase())); + const oa = onlineAddonsRef.current; + const pa = popularAddonsRef.current; + setSelectedPluginIds((prev) => { + const next = prev.filter((id) => { + const row = oa.find((a) => a.identifier === id) ?? pa.find((a) => a.identifier === id); + if (!row || row.premium !== 1) { + return true; + } + return ownedSet.has(id.toLowerCase()); + }); + return next.length === prev.length ? prev : next; + }); + }, [ownedCloudPackageIds, uiPreviewMode]); + + useEffect(() => { + setQueuedPlugins((q) => { + const toRemove = Object.keys(q).filter((k) => !selectedPluginIds.includes(k)); + if (toRemove.length === 0) { + return q; + } + const n = { ...q }; + for (const k of toRemove) { + delete n[k]; + } + return n; + }); + }, [selectedPluginIds]); + const clearTagFilter = () => { setSelectedTag(null); - setCurrentOnlinePage(1); }; const toggleSelectPlugin = (identifier: string, name?: string) => { @@ -423,6 +925,12 @@ export default function PluginsPage() { return prev.filter((id) => id !== identifier); } + const row = lookupAddonRow(identifier); + if (row?.premium === 1 && !isPremiumOwnedForQueue(identifier)) { + toast.error(t('admin.marketplace.plugins.queue.premium_not_owned')); + return prev; + } + setQueuedPlugins((prevQueue) => ({ ...prevQueue, [identifier]: name || identifier, @@ -432,38 +940,34 @@ export default function PluginsPage() { }); }; - const renderPagination = () => { - if (!onlinePagination || onlinePagination.total_pages <= 1) return null; - - return ( -
- -
- - {currentOnlinePage} / {onlinePagination.total_pages} - -
- -
- ); - }; + const loadMoreOnlineAddons = useCallback(() => { + if (uiPreviewMode || loadingMore || onlineLoading) return; + if (!onlinePagination) return; + const cur = onlinePagination.current_page ?? currentOnlinePage; + const totalPages = onlinePagination.total_pages ?? 1; + const hasMore = onlinePagination.has_next === true || (typeof cur === 'number' && cur < totalPages); + if (!hasMore) return; + void fetchOnlineAddons(cur + 1, 'append'); + }, [uiPreviewMode, loadingMore, onlineLoading, onlinePagination, currentOnlinePage, fetchOnlineAddons]); + + const hasMoreToLoad = + !uiPreviewMode && + onlinePagination != null && + onlineAddons.length > 0 && + (onlinePagination.has_next === true || + (onlinePagination.current_page ?? currentOnlinePage) < (onlinePagination.total_pages ?? 1)); + + const dockLgLeftClass = + chromeLayout === 'classic' + ? sidebarCollapsed + ? 'lg:left-16' + : 'lg:left-64' + : sidebarCollapsed + ? 'lg:left-14' + : 'lg:left-56'; return ( -
+
0 && 'pb-24 sm:pb-28')}> + {uiPreviewMode && ( +
+
+
+ +
+
+

+ {t('admin.marketplace.plugins.ui_preview.banner_title')} +

+

+ {t('admin.marketplace.plugins.ui_preview.banner_body')} +

+
+
+ +
+ )} + {!cloudAccountConfigured && ( -
-
- - setOnlineSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && fetchOnlineAddons(1)} - /> -
-
- - + +
+

+ {t('admin.marketplace.plugins.search_helper')} +

+
+
+ + setOnlineSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSearchOrPreviewToggle(); + } + }} + autoComplete='off' + spellCheck={false} + aria-describedby='feathercloud-plugins-search-hint' + /> +
+ +
+

+ {t('admin.marketplace.plugins.ui_preview.secret_hint')} +

+
+ + +
-
+ {selectedTag && (
@@ -677,7 +1243,7 @@ export default function PluginsPage() { description={onlineError} icon={AlertCircle} action={ - @@ -693,7 +1259,7 @@ export default function PluginsPage() { variant='outline' onClick={() => { setOnlineSearch(''); - fetchOnlineAddons(1); + fetchOnlineAddons(1, 'replace'); }} > {t('admin.marketplace.plugins.clear_search')} @@ -701,219 +1267,238 @@ export default function PluginsPage() { } /> ) : ( -
- {onlineAddons.map((addon) => { - const IconComponent = ({ className }: { className?: string }) => - addon.icon ? ( -
- {addon.name} -
- ) : ( - - ); + 0 ? ( +

+ {t('admin.marketplace.plugins.online_list_count', { + shown: String(onlineAddons.length), + total: String(onlinePagination.total_records), + })} +

+ ) : undefined + } + > +
+ {onlineAddons.map((addon) => { + const IconComponent = ({ className }: { className?: string }) => + addon.icon ? ( +
+ {addon.name} +
+ ) : ( + + ); - const isInstalled = installedPluginIds.includes(addon.identifier); - const isSelected = selectedPluginIds.includes(addon.identifier); - const requiresCloud = addon.premium === 1 && !cloudAccountConfigured; - const queueDisabled = bulkInstalling || requiresCloud || isInstalled; - - return ( - 0 && - !installedPluginIds.includes(addon.identifier) - ? { - label: t('admin.marketplace.plugins.has_dependencies'), - className: 'bg-purple-500/10 text-purple-600 border-purple-500/20', - } - : null, - ].filter(Boolean) as ResourceBadge[] - } - description={ -
-

- {addon.description || t('admin.marketplace.plugins.details.no_description')} -

-
-
- - {addon.downloads.toLocaleString()} + const isInstalled = installedPluginIds.includes(addon.identifier); + const isSelected = selectedPluginIds.includes(addon.identifier); + const isPremium = addon.premium === 1; + const storeUrl = addon.premium_link?.trim() ?? ''; + const hasStore = Boolean(storeUrl); + const premiumOwned = !isPremium || isPremiumOwnedForQueue(addon.identifier); + const requiresCloudBlock = isPremium && !hasStore && !cloudAccountConfigured; + const premiumNotLicensed = isPremium && cloudAccountConfigured && !premiumOwned; + const storePrimary = + (isPremium && hasStore && !cloudAccountConfigured) || (premiumNotLicensed && hasStore); + const queueDisabled = bulkInstalling || isInstalled; + + return ( + 0 && + !installedPluginIds.includes(addon.identifier) + ? { + label: t('admin.marketplace.plugins.has_dependencies'), + className: + 'bg-purple-500/10 text-purple-600 border-purple-500/20', + } + : null, + ].filter(Boolean) as ResourceBadge[] + } + description={ +
+

+ {addon.description || + t('admin.marketplace.plugins.details.no_description')} +

+
+
+ + {addon.downloads.toLocaleString()} +
+ {addon.premium === 1 && addon.premium_price && ( +
+ €{addon.premium_price} +
+ )}
- {addon.premium === 1 && addon.premium_price && ( -
- €{addon.premium_price} + {addon.tags.length > 0 && ( +
+ {addon.tags.slice(0, 3).map((tag) => ( + { + e.stopPropagation(); + setSelectedTag(tag); + }} + > + #{tag} + + ))} + {addon.tags.length > 3 && ( + + +{addon.tags.length - 3} + + )}
)}
- {addon.tags.length > 0 && ( -
- {addon.tags.slice(0, 3).map((tag) => ( - { - e.stopPropagation(); - setSelectedTag(tag); - setCurrentOnlinePage(1); - }} - > - #{tag} - - ))} - {addon.tags.length > 3 && ( - - +{addon.tags.length - 3} - - )} -
- )} -
- } - actions={ -
- - {requiresCloud ? ( + } + actions={ +
- ) : ( - - )} -
- } - /> - ); - })} -
- )} - - {renderPagination()} - - {selectedPluginIds.length > 0 && ( -
-
-
-
-

- {t('admin.marketplace.plugins.queue.title')} -

-

- {t('admin.marketplace.plugins.queue.subtitle')} -

-
- {selectedPluginIds.length} -
-
- {Object.entries(queuedPlugins) - .filter(([id]) => selectedPluginIds.includes(id)) - .map(([id, name]) => ( -
- {name} - -
- ))} -
-
+ {isInstalled ? ( + + ) : storePrimary ? ( + + ) : requiresCloudBlock ? ( + + ) : premiumNotLicensed ? ( + + ) : ( + <> + {isPremium && hasStore && cloudAccountConfigured && ( + + )} + + + )} +
+ } + /> + ); + })} +
+ {hasMoreToLoad && ( +
- + {onlinePagination && ( +

+ {t('admin.marketplace.plugins.load_more_hint', { + page: String(onlinePagination.current_page ?? currentOnlinePage), + pages: String(onlinePagination.total_pages ?? 1), + })} +

+ )}
-
-
+ )} + )} @@ -945,24 +1530,6 @@ export default function PluginsPage() {
-
- -

- {t('admin.marketplace.spells.help.repo_desc')} -

-
- -

- {t('admin.marketplace.spells.help.install_desc')} -

-
- -

- {t('admin.marketplace.spells.help.security_desc')} -

-
-
-
@@ -1093,7 +1660,7 @@ export default function PluginsPage() { )}
- + - {selectedPackage && ( - + )} + - )} - - )} + ); + })()}
+ {/* Portal: PageTransition uses transform+overflow; fixed inside it is viewport-wrong. Body = true viewport bottom. */} + {portalReady && + selectedPluginIds.length > 0 && + createPortal( +
+
+

+ {t('admin.marketplace.plugins.queue.subtitle')} +

+
+
+ + + {t('admin.marketplace.plugins.queue.title')} + + + {selectedPluginIds.length} + +
+
+ + +
+
+
+ {selectedPluginIds.map((id) => ( +
+ + {queuedPlugins[id] ?? id} + + +
+ ))} +
+
+
, + document.body, + )} + {/* Requirements Check Dialog */} - + {requirementsCheck?.can_install ? ( @@ -1165,14 +1874,14 @@ export default function PluginsPage() { className={cn( 'flex items-start gap-3 rounded-lg border p-3', requirementsCheck.panel_version.ok - ? 'border-green-200 bg-green-50/50' - : 'border-red-200 bg-red-50/50', + ? 'border-emerald-500/25 bg-emerald-500/10' + : 'border-destructive/30 bg-destructive/10', )} > {requirementsCheck.panel_version.ok ? ( - + ) : ( - + )}

@@ -1203,14 +1912,14 @@ export default function PluginsPage() { className={cn( 'flex items-start gap-2 rounded-md border p-2 text-sm', dep.met - ? 'border-green-200 bg-green-50/30' - : 'border-red-200 bg-red-50/30', + ? 'border-emerald-500/20 bg-emerald-500/5' + : 'border-destructive/25 bg-destructive/10', )} > {dep.met ? ( - + ) : ( - + )}

@@ -1219,8 +1928,8 @@ export default function PluginsPage() { className={cn( 'h-5 text-[10px]', dep.met - ? 'border-green-300 text-green-700' - : 'border-red-300 text-red-700', + ? 'border-emerald-500/40 text-emerald-200' + : 'border-destructive/40 text-destructive-foreground', )} > {dep.type} @@ -1241,9 +1950,9 @@ export default function PluginsPage() { {/* Missing dependencies warning */} {!requirementsCheck?.dependencies.all_met && ( -
-

- +

+

+ {t('admin.marketplace.plugins.requirements.please_install_deps')}

diff --git a/frontendv2/src/app/(app)/admin/feathercloud/spells/page.tsx b/frontendv2/src/app/(app)/admin/feathercloud/spells/page.tsx index b75bdc55..06297451 100644 --- a/frontendv2/src/app/(app)/admin/feathercloud/spells/page.tsx +++ b/frontendv2/src/app/(app)/admin/feathercloud/spells/page.tsx @@ -46,6 +46,7 @@ import { Settings, Info, BadgeCheck, + Package, } from 'lucide-react'; interface OnlineSpell { @@ -67,6 +68,8 @@ interface OnlinePagination { current_page: number; total_pages: number; total_records: number; + has_next?: boolean; + has_prev?: boolean; } interface Realm { @@ -84,6 +87,7 @@ export default function SpellsPage() { const [onlinePagination, setOnlinePagination] = useState(null); const [currentOnlinePage, setCurrentOnlinePage] = useState(1); const [onlineSearch, setOnlineSearch] = useState(''); + const [loadingMore, setLoadingMore] = useState(false); const [confirmInstallOpen, setConfirmInstallOpen] = useState(false); const [selectedSpell, setSelectedSpell] = useState(null); @@ -133,8 +137,12 @@ export default function SpellsPage() { }, []); const fetchOnlineSpells = useCallback( - async (page = currentOnlinePage, search = onlineSearch) => { - setOnlineLoading(true); + async (page: number, mode: 'replace' | 'append' = 'replace') => { + if (mode === 'append') { + setLoadingMore(true); + } else { + setOnlineLoading(true); + } setOnlineError(null); const params = new URLSearchParams({ @@ -142,25 +150,61 @@ export default function SpellsPage() { per_page: '20', }); - if (search) params.set('q', search); + const q = onlineSearch.trim(); + if (q) params.set('q', q); try { const response = await axios.get(`/api/admin/spells/online/list?${params.toString()}`); - setOnlineSpells(response.data?.data?.spells || []); - setOnlinePagination(response.data?.data?.pagination || null); + const spells: OnlineSpell[] = response.data?.data?.spells || []; + const pagination = response.data?.data?.pagination || null; + + if (mode === 'append') { + setOnlineSpells((prev) => { + const seen = new Set(prev.map((s) => s.identifier)); + const merged = [...prev]; + for (const s of spells) { + if (!seen.has(s.identifier)) { + seen.add(s.identifier); + merged.push(s); + } + } + return merged; + }); + } else { + setOnlineSpells(spells); + } + setOnlinePagination(pagination); + setCurrentOnlinePage(page); } catch (err: unknown) { const e = err as { response?: { data?: { message?: string } } }; setOnlineError(e?.response?.data?.message || t('admin.marketplace.spells.loading_error')); } finally { - setOnlineLoading(false); + if (mode === 'append') { + setLoadingMore(false); + } else { + setOnlineLoading(false); + } } }, - [currentOnlinePage, onlineSearch, t], + [onlineSearch, t], ); + const loadMoreOnlineSpells = useCallback(() => { + if (loadingMore || onlineLoading) return; + void fetchOnlineSpells(currentOnlinePage + 1, 'append'); + }, [currentOnlinePage, fetchOnlineSpells, loadingMore, onlineLoading]); + + const hasMoreToLoad = + onlineSpells.length > 0 && + (onlinePagination?.has_next === true || currentOnlinePage < (onlinePagination?.total_pages ?? 1)); + + const runSpellsSearch = useCallback(() => { + void fetchOnlineSpells(1, 'replace'); + }, [fetchOnlineSpells]); + useEffect(() => { fetchWidgets(); - fetchOnlineSpells(); + void fetchOnlineSpells(1, 'replace'); fetchRealms(1, ''); fetchInstalledSpells(); }, [fetchOnlineSpells, fetchRealms, fetchInstalledSpells, fetchWidgets]); @@ -230,36 +274,6 @@ export default function SpellsPage() { } }; - const renderPagination = () => { - if (!onlinePagination || onlinePagination.total_pages <= 1) return null; - - return ( -
- -
- - {currentOnlinePage} / {onlinePagination.total_pages} - -
- -
- ); - }; - return (
@@ -305,18 +319,41 @@ export default function SpellsPage() { -
-
- - setOnlineSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && fetchOnlineSpells(1)} - /> + +
+

+ {t('admin.marketplace.spells.search_helper')} +

+
+
+ + setOnlineSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + runSpellsSearch(); + } + }} + autoComplete='off' + /> +
+ +
-
+ {onlineLoading ? ( fetchOnlineSpells()}> + @@ -341,110 +378,158 @@ export default function SpellsPage() { title={t('admin.marketplace.plugins.no_results')} description={t('admin.marketplace.spells.search_placeholder')} icon={Settings} + action={ + + } /> ) : ( -
- {onlineSpells.map((spell) => { - const IconComponent = ({ className }: { className?: string }) => - spell.icon ? ( -
- {spell.name} -
- ) : ( - - ); - - return ( - -

- {spell.description || t('admin.marketplace.spells.grid.no_description')} -

- {!spell.verified && ( -
- - {t('admin.marketplace.spells.grid.external_source')} -
- )} -
-
- - {spell.downloads.toLocaleString()} -
-
+ 0 ? ( +

+ {t('admin.marketplace.spells.online_list_count', { + shown: String(onlineSpells.length), + total: String(onlinePagination.total_records), + })} +

+ ) : undefined + } + > +
+ {onlineSpells.map((spell) => { + const IconComponent = ({ className }: { className?: string }) => + spell.icon ? ( +
+ {spell.name}
- } - actions={ -
- - {spell.website && ( +
+
+ + {spell.downloads.toLocaleString()} +
+
+
+ } + actions={ +
- )} -
- } - /> - ); - })} -
+ {spell.website && ( + + )} +
+ } + /> + ); + })} +
+ {hasMoreToLoad && ( +
+ + {onlinePagination && ( +

+ {t('admin.marketplace.spells.load_more_hint', { + page: String(onlinePagination.current_page ?? currentOnlinePage), + pages: String(onlinePagination.total_pages ?? 1), + })} +

+ )} +
+ )} + )} - {renderPagination()} -

diff --git a/runner/Cargo.lock b/runner/Cargo.lock index 48b13880..62907b22 100644 --- a/runner/Cargo.lock +++ b/runner/Cargo.lock @@ -74,7 +74,7 @@ dependencies = [ [[package]] name = "async-runner" -version = "1.3.7+1" +version = "1.3.7+2" dependencies = [ "anyhow", "base64", diff --git a/runner/Cargo.toml b/runner/Cargo.toml index b343bb27..a6b5595d 100644 --- a/runner/Cargo.toml +++ b/runner/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "async-runner" -version = "1.3.7+1" +version = "1.3.7+2" edition = "2021" [dependencies] From 4143bc8cab5672dbc5a416307a40bcf92d2aa6b0 Mon Sep 17 00:00:00 2001 From: NaysKutzu Date: Thu, 14 May 2026 23:42:29 +0200 Subject: [PATCH 2/2] refactor: reorganize and enhance plugin handling methods in CloudPluginsController and PluginProcessor; improve analytics page rendering logic for better status display --- .github/README.md | 8 +- .../Admin/CloudManagementController.php | 9 +- .../Admin/CloudPluginsController.php | 288 +++++++++--------- backend/app/Plugins/PluginProcessor.php | 70 ++--- .../app/(app)/admin/analytics/system/page.tsx | 65 ++-- 5 files changed, 224 insertions(+), 216 deletions(-) diff --git a/.github/README.md b/.github/README.md index 553838f7..e759d38a 100755 --- a/.github/README.md +++ b/.github/README.md @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please -_Last updated: 2026-05-14T19:20:29.097Z_ +_Last updated: 2026-05-14T21:32:57.470Z_ | Extension | Files | Lines | | --- | ---: | ---: | -| `.php` | 504 | 126,600 | -| `.tsx` | 355 | 111,800 | +| `.php` | 504 | 126,711 | +| `.tsx` | 355 | 112,571 | | `.ts` | 70 | 7,661 | | `.yaml` | 3 | 5,940 | | `.rs` | 16 | 3,395 | | `.sql` | 130 | 2,034 | | `.yml` | 18 | 1,877 | | `.css` | 7 | 445 | -| **Total** | 1,103 | 259,752 | +| **Total** | 1,103 | 260,634 | diff --git a/backend/app/Controllers/Admin/CloudManagementController.php b/backend/app/Controllers/Admin/CloudManagementController.php index 7d879fc7..ba909650 100755 --- a/backend/app/Controllers/Admin/CloudManagementController.php +++ b/backend/app/Controllers/Admin/CloudManagementController.php @@ -369,7 +369,14 @@ public function getOAuth2Link(Request $request): Response // Get panel information $panelName = $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'); - $panelUrl = $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'); + // app_url may exist in DB as an empty string, which bypasses the default argument to getSetting() + $panelUrl = trim((string) ($config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems') ?? '')); + if ($panelUrl === '') { + $panelUrl = rtrim($request->getSchemeAndHttpHost() . $request->getBasePath(), '/'); + } + if ($panelUrl === '') { + $panelUrl = 'https://featherpanel.mythical.systems'; + } $logoUrl = $config->getSetting(ConfigInterface::APP_LOGO_WHITE, 'https://github.com/featherpanel-com.png'); // Get or generate panel credentials diff --git a/backend/app/Controllers/Admin/CloudPluginsController.php b/backend/app/Controllers/Admin/CloudPluginsController.php index d2683ed1..33120550 100755 --- a/backend/app/Controllers/Admin/CloudPluginsController.php +++ b/backend/app/Controllers/Admin/CloudPluginsController.php @@ -1061,151 +1061,6 @@ public function install(Request $request): Response } } - /** - * @param mixed $raw - * - * @return list - */ - private function normalizeQueuedPluginIdentifiers(mixed $raw): array - { - if (!is_array($raw)) { - return []; - } - $unique = []; - foreach ($raw as $id) { - if (!is_string($id)) { - continue; - } - if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $id)) { - continue; - } - $unique[$id] = true; - } - - return array_keys($unique); - } - - /** - * @return list - */ - private function parsePendingPluginsQuery(Request $request): array - { - $raw = $request->query->get('pending_plugins'); - if (is_array($raw)) { - return $this->normalizeQueuedPluginIdentifiers($raw); - } - if (is_string($raw) && $raw !== '') { - $parts = array_map('trim', explode(',', $raw)); - - return $this->normalizeQueuedPluginIdentifiers($parts); - } - - return []; - } - - /** - * Download the .fpa and read conf.yml dependency lines. - * - * @return array{checks: list>, all_met: bool, missing: list} - */ - private function evaluateConfDependencyChecksFromDownloadUrl(?string $downloadUrl, mixed $streamContext): array - { - $dependencyChecks = []; - $missingDependencies = []; - $allDependenciesMet = true; - - if ($downloadUrl === null || $downloadUrl === '') { - return ['checks' => [], 'all_met' => true, 'missing' => []]; - } - - $tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true) . '.fpa'; - $fileContent = @file_get_contents($downloadUrl, false, $streamContext); - if ($fileContent === false) { - return ['checks' => [], 'all_met' => true, 'missing' => []]; - } - - file_put_contents($tempFile, $fileContent); - - $tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true); - @mkdir($tempDir, 0755, true); - $pwd = self::PASSWORD; - $unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir)); - exec($unzipCommand, $out, $code); - - if ($code === 0 && file_exists($tempDir . '/conf.yml')) { - try { - $conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml'); - $confDependencies = $conf['plugin']['dependencies'] ?? []; - if (!is_array($confDependencies)) { - $confDependencies = []; - } - - foreach ($confDependencies as $dep) { - if (!is_string($dep)) { - continue; - } - $met = false; - $message = ''; - $type = 'unknown'; - $name = $dep; - - if (strpos($dep, 'composer=') === 0) { - $composerPkg = substr($dep, strlen('composer=')); - $met = \App\Plugins\Dependencies\ComposerDependencies::isInstalled($composerPkg); - $message = $met ? 'Composer package installed' : "Composer package required: {$composerPkg}"; - $type = 'composer'; - $name = $composerPkg; - } elseif (strpos($dep, 'plugin=') === 0) { - $pluginDep = substr($dep, strlen('plugin=')); - $met = \App\Plugins\Dependencies\AppDependencies::isInstalled($pluginDep); - $message = $met ? 'Plugin installed' : "Plugin required: {$pluginDep}"; - $type = 'plugin'; - $name = $pluginDep; - } elseif (strpos($dep, 'php=') === 0) { - $phpVersion = substr($dep, strlen('php=')); - $met = \App\Plugins\Dependencies\PhpVersionDependencies::isInstalled($phpVersion); - $message = $met ? 'PHP version requirement met' : "PHP version required: {$phpVersion}"; - $type = 'php'; - $name = $phpVersion; - } elseif (strpos($dep, 'php-ext=') === 0) { - $ext = substr($dep, strlen('php-ext=')); - $met = \App\Plugins\Dependencies\PhpExtensionDependencies::isInstalled($ext); - $message = $met ? 'PHP extension installed' : "PHP extension required: {$ext}"; - $type = 'php-ext'; - $name = $ext; - } else { - $met = true; - $message = "Unknown dependency format: {$dep}"; - } - - $dependencyChecks[] = [ - 'dependency' => $dep, - 'type' => $type, - 'name' => $name, - 'met' => $met, - 'message' => $message, - ]; - - if (!$met) { - $missingDependencies[] = $dep; - $allDependenciesMet = false; - } - } - } catch (\Exception $e) { - App::getInstance(true)->getLogger()->warning('Failed to parse conf.yml for dependency check: ' . $e->getMessage()); - } - } - - @exec('rm -rf ' . escapeshellarg($tempDir)); - @unlink($tempFile); - - return [ - 'checks' => $dependencyChecks, - 'all_met' => $allDependenciesMet, - 'missing' => $missingDependencies, - ]; - } - /** * Perform the common installation routine given an extracted addon temp directory. * Handles identifier resolution (from conf.yml if not provided), copying files, @@ -1485,6 +1340,149 @@ public static function getInstance(): self return self::$instance; } + /** + * @return list + */ + private function normalizeQueuedPluginIdentifiers(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $unique = []; + foreach ($raw as $id) { + if (!is_string($id)) { + continue; + } + if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $id)) { + continue; + } + $unique[$id] = true; + } + + return array_keys($unique); + } + + /** + * @return list + */ + private function parsePendingPluginsQuery(Request $request): array + { + $raw = $request->query->get('pending_plugins'); + if (is_array($raw)) { + return $this->normalizeQueuedPluginIdentifiers($raw); + } + if (is_string($raw) && $raw !== '') { + $parts = array_map('trim', explode(',', $raw)); + + return $this->normalizeQueuedPluginIdentifiers($parts); + } + + return []; + } + + /** + * Download the .fpa and read conf.yml dependency lines. + * + * @return array{checks: list>, all_met: bool, missing: list} + */ + private function evaluateConfDependencyChecksFromDownloadUrl(?string $downloadUrl, mixed $streamContext): array + { + $dependencyChecks = []; + $missingDependencies = []; + $allDependenciesMet = true; + + if ($downloadUrl === null || $downloadUrl === '') { + return ['checks' => [], 'all_met' => true, 'missing' => []]; + } + + $tempFile = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true) . '.fpa'; + $fileContent = @file_get_contents($downloadUrl, false, $streamContext); + if ($fileContent === false) { + return ['checks' => [], 'all_met' => true, 'missing' => []]; + } + + file_put_contents($tempFile, $fileContent); + + $tempDir = sys_get_temp_dir() . '/' . uniqid('featherpanel_check_', true); + @mkdir($tempDir, 0755, true); + $pwd = self::PASSWORD; + $unzipCommand = sprintf('unzip -P %s %s conf.yml -d %s', escapeshellarg($pwd), escapeshellarg($tempFile), escapeshellarg($tempDir)); + exec($unzipCommand, $out, $code); + + if ($code === 0 && file_exists($tempDir . '/conf.yml')) { + try { + $conf = \Symfony\Component\Yaml\Yaml::parseFile($tempDir . '/conf.yml'); + $confDependencies = $conf['plugin']['dependencies'] ?? []; + if (!is_array($confDependencies)) { + $confDependencies = []; + } + + foreach ($confDependencies as $dep) { + if (!is_string($dep)) { + continue; + } + $met = false; + $message = ''; + $type = 'unknown'; + $name = $dep; + + if (strpos($dep, 'composer=') === 0) { + $composerPkg = substr($dep, strlen('composer=')); + $met = \App\Plugins\Dependencies\ComposerDependencies::isInstalled($composerPkg); + $message = $met ? 'Composer package installed' : "Composer package required: {$composerPkg}"; + $type = 'composer'; + $name = $composerPkg; + } elseif (strpos($dep, 'plugin=') === 0) { + $pluginDep = substr($dep, strlen('plugin=')); + $met = \App\Plugins\Dependencies\AppDependencies::isInstalled($pluginDep); + $message = $met ? 'Plugin installed' : "Plugin required: {$pluginDep}"; + $type = 'plugin'; + $name = $pluginDep; + } elseif (strpos($dep, 'php=') === 0) { + $phpVersion = substr($dep, strlen('php=')); + $met = \App\Plugins\Dependencies\PhpVersionDependencies::isInstalled($phpVersion); + $message = $met ? 'PHP version requirement met' : "PHP version required: {$phpVersion}"; + $type = 'php'; + $name = $phpVersion; + } elseif (strpos($dep, 'php-ext=') === 0) { + $ext = substr($dep, strlen('php-ext=')); + $met = \App\Plugins\Dependencies\PhpExtensionDependencies::isInstalled($ext); + $message = $met ? 'PHP extension installed' : "PHP extension required: {$ext}"; + $type = 'php-ext'; + $name = $ext; + } else { + $met = true; + $message = "Unknown dependency format: {$dep}"; + } + + $dependencyChecks[] = [ + 'dependency' => $dep, + 'type' => $type, + 'name' => $name, + 'met' => $met, + 'message' => $message, + ]; + + if (!$met) { + $missingDependencies[] = $dep; + $allDependenciesMet = false; + } + } + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->warning('Failed to parse conf.yml for dependency check: ' . $e->getMessage()); + } + } + + @exec('rm -rf ' . escapeshellarg($tempDir)); + @unlink($tempFile); + + return [ + 'checks' => $dependencyChecks, + 'all_met' => $allDependenciesMet, + 'missing' => $missingDependencies, + ]; + } + /** * Map a FeatherCloud API package row to the panel's online-addon shape. * diff --git a/backend/app/Plugins/PluginProcessor.php b/backend/app/Plugins/PluginProcessor.php index ab3196ff..56a03f6c 100755 --- a/backend/app/Plugins/PluginProcessor.php +++ b/backend/app/Plugins/PluginProcessor.php @@ -84,41 +84,6 @@ public static function getEventProcessor(string $identifier): ?AppPlugin } } - /** - * When plugin.name in conf.yml does not match the AppPlugin entry class (for example - * after a copy-paste between similar addons), locate the single AppPlugin implementation - * shipped at the root of the addon directory. - */ - private static function discoverAppPluginClassInAddonRoot(string $identifier): ?string - { - $dir = PluginHelper::getPluginsDir() . '/' . $identifier; - if (!is_dir($dir)) { - return null; - } - - $candidates = []; - foreach (glob($dir . '/*.php') ?: [] as $file) { - $basename = basename($file, '.php'); - $class = "App\\Addons\\{$identifier}\\{$basename}"; - if (class_exists($class) && is_subclass_of($class, AppPlugin::class)) { - $candidates[] = $class; - } - } - - if (count($candidates) === 1) { - return $candidates[0]; - } - - if (count($candidates) > 1) { - App::getInstance(true)->getLogger()->warning( - 'Multiple AppPlugin classes in addon root for ' . $identifier - . '; set plugin.name in conf.yml to the correct entry class name.' - ); - } - - return null; - } - /** * Check if a plugin has a valid event implementation. * @@ -238,4 +203,39 @@ public static function hasMixin(string $identifier, string $mixinId): bool return false; } } + + /** + * When plugin.name in conf.yml does not match the AppPlugin entry class (for example + * after a copy-paste between similar addons), locate the single AppPlugin implementation + * shipped at the root of the addon directory. + */ + private static function discoverAppPluginClassInAddonRoot(string $identifier): ?string + { + $dir = PluginHelper::getPluginsDir() . '/' . $identifier; + if (!is_dir($dir)) { + return null; + } + + $candidates = []; + foreach (glob($dir . '/*.php') ?: [] as $file) { + $basename = basename($file, '.php'); + $class = "App\\Addons\\{$identifier}\\{$basename}"; + if (class_exists($class) && is_subclass_of($class, AppPlugin::class)) { + $candidates[] = $class; + } + } + + if (count($candidates) === 1) { + return $candidates[0]; + } + + if (count($candidates) > 1) { + App::getInstance(true)->getLogger()->warning( + 'Multiple AppPlugin classes in addon root for ' . $identifier + . '; set plugin.name in conf.yml to the correct entry class name.' + ); + } + + return null; + } } diff --git a/frontendv2/src/app/(app)/admin/analytics/system/page.tsx b/frontendv2/src/app/(app)/admin/analytics/system/page.tsx index 8d162b99..a39bc22c 100644 --- a/frontendv2/src/app/(app)/admin/analytics/system/page.tsx +++ b/frontendv2/src/app/(app)/admin/analytics/system/page.tsx @@ -208,38 +208,41 @@ export default function SystemAnalyticsPage() { {stats.recent_queued.length > 0 ? (

- {stats.recent_queued.map((item) => ( -
-
-

{item.subject}

-

- {item.recipient} -

+ {stats.recent_queued.map((item) => { + const statusKey = item.status === 'pending' ? 'queued' : item.status; + return ( +
+
+

{item.subject}

+

+ {item.recipient} +

+
+
+ + {t(`admin.analytics.system.status.${statusKey}`) || + item.status} + +

+ {formatDistanceToNow(new Date(item.created_at), { + addSuffix: true, + })} +

+
-
- - {t(`admin.analytics.system.status.${item.status}`) || - item.status} - -

- {formatDistanceToNow(new Date(item.created_at), { - addSuffix: true, - })} -

-
-
- ))} + ); + })}
) : (