'custom', 'system' => 'system', 'marketplace' => 'marketplace', ]; /** * Plugin formats * phar: Phar plugins (only from marketplace) * folder: Folder plugins */ private array $pluginFormat = [ 'phar' => 'phar', 'folder' => 'phar', ]; public string $marketplaceUrl; private int $timeout = 60; /** * @return void * * @throws BindingResolutionException **/ public function __construct( private PluginRepository $pluginRepository, private EnvironmentCore $config, private SettingsService $settingsService, private UsersService $usersService, private AppSettings $appSettings, ) { $this->marketplaceUrl = rtrim($config->marketplaceUrl, '/'); // $this->marketplaceUrl = 'https://marketplace.leantime.test'; } protected function httpClient() { return Http::withoutVerifying()->timeout($this->timeout); } /** * Retrieves all plugins, optionally filtering only the enabled ones. * * @param bool $enabledOnly If set to true, only enabled plugins will be returned. * @return false|array Returns an array of all plugins or false if an error occurs. * * @api */ public function getAllPlugins(bool $enabledOnly = false): false|array { $installedPluginsById = []; try { $installedPlugins = $this->pluginRepository->getAllPlugins($enabledOnly); } catch (\Exception $e) { $installedPlugins = []; } // Build array with pluginId as $key foreach ($installedPlugins as &$plugin) { /** @var array */ $marketplacePluginCache = Cache::store('installation')->get('plugins.marketplacePluginsFlat', false); $plugin->type = $plugin->format === $this->pluginFormat['phar'] ? $plugin->type = $this->pluginTypes['marketplace'] : $plugin->type = $this->pluginTypes['custom']; // Make installed plugins pretty $pluginIdentifier = Str::replace('/', '_', Str::lower($plugin->name)); if ($marketplacePluginCache && isset($marketplacePluginCache[$pluginIdentifier])) { $plugin->identifier = $marketplacePluginCache[$pluginIdentifier]->identifier; $plugin->name = $marketplacePluginCache[$pluginIdentifier]->name; $plugin->imageUrl = $marketplacePluginCache[$pluginIdentifier]->imageUrl; $plugin->description = $marketplacePluginCache[$pluginIdentifier]->excerpt; $plugin->vendorDisplayName = $marketplacePluginCache[$pluginIdentifier]->vendorDisplayName; $plugin->vendorId = $marketplacePluginCache[$pluginIdentifier]->vendorId; $plugin->vendorEmail = $marketplacePluginCache[$pluginIdentifier]->vendorEmail; } $installedPluginsById[$plugin->foldername] = $plugin; } // Gets plugins from the config, which are automatically enabled if (isset($this->config->plugins)) { $configplugins = explode(',', $this->config->plugins); collect($configplugins) ->filter(fn ($plugin) => ! empty($plugin)) ->each(function ($plugin) use (&$installedPluginsById) { try { $pluginModel = $this->createPluginFromComposer($plugin); $installedPluginsById[$plugin] ??= $pluginModel; $installedPluginsById[$plugin]->enabled = true; $installedPluginsById[$plugin]->type = $this->pluginTypes['system']; } catch (Exception $e) { report($e); } }); } /** * Filters array of plugins from database and config before returning * * @var array $allPlugins */ $allPlugins = self::dispatch_filter('beforeReturnAllPlugins', $installedPluginsById, ['enabledOnly' => $enabledOnly]); return $allPlugins; } /** * @throws BindingResolutionException * * @api */ public function isEnabled($pluginFolder): bool { $plugins = $this->getEnabledPlugins(); foreach ($plugins as $plugin) { if (strtolower($plugin->foldername) == strtolower($pluginFolder)) { return true; } } return false; } /** * @return array|false|mixed * * @throws BindingResolutionException * * @api */ public function getEnabledPlugins(): mixed { if (Cache::store('installation')->has('plugins.enabledPlugins') && $this->config->debug === false) { $enabledPlugins = static::dispatch_filter('beforeReturnCachedPlugins', Cache::store('installation')->get('plugins.enabledPlugins'), ['enabledOnly' => true]); return $enabledPlugins; } Cache::store('installation')->set('plugins.enabledPlugins', $this->getAllPlugins(enabledOnly: true)); /** * Filters session array of enabled plugins before returning */ return self::dispatch_filter( hook: 'beforeReturnCachedPlugins', payload: Cache::store('installation')->get('plugins.enabledPlugins'), available_params: ['enabledOnly' => true]); } /** * @return InstalledPlugin[] * * @throws BindingResolutionException * * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function discoverNewPlugins(): array { $this->clearCache(); $installedPluginNames = array_map(fn ($plugin) => $plugin->foldername, $this->getAllPlugins()); $scanned_directory = array_diff(scandir($this->pluginDirectory), ['..', '.']); $newPlugins = collect($scanned_directory) ->filter(fn ($directory) => is_dir("{$this->pluginDirectory}/{$directory}") && ! array_search($directory, $installedPluginNames)) ->map(function ($directory) { try { return $this->createPluginFromComposer($directory); } catch (\Exception $e) { Log::warning("Can't create plugin from composer"); Log::warning($e); return null; } }) ->filter()->all(); return $newPlugins; } public function createPluginFromComposer(string $pluginFolder, string $license_key = ''): InstalledPlugin { $pluginPath = Str::finish($this->pluginDirectory, DIRECTORY_SEPARATOR).Str::finish($pluginFolder, DIRECTORY_SEPARATOR); if (file_exists($composerPath = $pluginPath.'composer.json')) { $format = 'folder'; } elseif (file_exists($composerPath = "phar://{$pluginPath}{$pluginFolder}.phar".DIRECTORY_SEPARATOR.'composer.json')) { $format = 'phar'; } else { throw new \Exception(__('notifications.plugin_install_cant_find_composer')); } $json = file_get_contents($composerPath); $pluginFile = json_decode($json, true); $plugin = build(new InstalledPlugin) ->set('name', $pluginFile['name']) ->set('enabled', 0) ->set('description', $pluginFile['description']) ->set('version', $pluginFile['version']) ->set('installdate', date('y-m-d')) ->set('foldername', $pluginFolder) ->set('license', $license_key) ->set('format', $format) ->set('homepage', $pluginFile['homepage']) ->set('authors', json_encode($pluginFile['authors'])) ->get(); return $plugin; } /** * @throws BindingResolutionException * * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function installPlugin($pluginFolder): false|string { $this->clearCache(); $pluginFolder = Str::studly($pluginFolder); try { $plugin = $this->createPluginFromComposer($pluginFolder); } catch (\Exception $e) { report($e); return false; } $pluginClassName = $this->getPluginClassName($plugin); $newPluginSvc = app()->make($pluginClassName); if (method_exists($newPluginSvc, 'install')) { try { $newPluginSvc->install(); } catch (Exception $e) { report($e); return false; } } return $this->pluginRepository->addPlugin($plugin); } /** * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function enablePlugin(int $id): bool { $this->clearCache(); $pluginModel = $this->pluginRepository->getPlugin($id); if ($pluginModel->format !== 'phar') { return $this->pluginRepository->enablePlugin($id); } if ($this->validLicense($pluginModel)) { return $this->pluginRepository->enablePlugin($id); } return false; } /** * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function disablePlugin(int $id): bool { $this->clearCache(); $pluginModel = $this->pluginRepository->getPlugin($id); $result = $this->pluginRepository->disablePlugin($id); if ($pluginModel->format === 'phar') { $this->deactivate($pluginModel); } return $result; } /** * @throws BindingResolutionException * * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function removePlugin(int $id): bool { $this->clearCache(); /** @var InstalledPlugin|false $plugin */ $plugin = $this->pluginRepository->getPlugin($id); if (! $plugin) { return false; } try { // Any installation calls should happen right here. $pluginClassName = $this->getPluginClassName($plugin); $newPluginSvc = app()->make($pluginClassName); if (method_exists($newPluginSvc, 'uninstall')) { try { $newPluginSvc->uninstall(); } catch (\Exception $e) { report($e); return false; } } } catch (\Exception $e) { // Silence is golden } return $this->pluginRepository->removePlugin($id); } /** * @throws BindingResolutionException * * @api */ public function getPluginClassName(InstalledPlugin $plugin): string { return app()->getNamespace() .'Plugins\\' .Str::studly($plugin->foldername) .'\\Services\\' .Str::studly($plugin->foldername); } /** * Fetches marketplace plugins from a specified URL and organizes them by category. * * @param int $page The page number to fetch. * @param string $query Optional search query to filter plugins by name or identifier. * @return array An associative array containing plugin categories, each with associated plugins. * The categories include metadata such as name, description, and plugins categorized under them. */ public function getMarketplacePlugins(int $page, string $query = ''): array { $plugins = $this->httpClient()->get( "{$this->marketplaceUrl}/ltmp-api" .(! empty($query) ? "/search/$query" : '/index') ."/$page".'?lt-v='.$this->appSettings->appVersion.'&groupBy=category' ); $pluginArray = $plugins->collect()->toArray(); $plugins = []; $pluginsFlat = []; if (isset($pluginArray['categories'])) { foreach ($pluginArray['categories'] as $category) { $plugins[$category['slug']] = [ 'name' => $category['name'], 'description' => $category['description'], 'plugins' => [], ]; foreach ($category['products'] as $plugin) { $priceString = ''; if (! empty($plugin['sub_interval']) && $plugin['sub_interval'] === 'year') { $price = $plugin['price'] ?? 0; $months = 12; $lowestUserTier = 10; $perUserMonth = round(($price / $months / $lowestUserTier), 2); $priceString = '$'.$perUserMonth.' per user/month (billed annually) '; } $plugins[$category['slug']]['plugins'][Str::lower($plugin['identifier'])] = self::buildMarketplacePluginFromListData($plugin, $priceString); $pluginsFlat[Str::lower($plugin['identifier'])] = $plugins[$category['slug']]['plugins'][Str::lower($plugin['identifier'])]; } } } Cache::store('installation')->set('plugins.marketplacePlugins', $plugins); Cache::store('installation')->set('plugins.marketplacePluginsFlat', $pluginsFlat); return $plugins; } public function getLatestPluginUpdates(int $page, string $query = ''): array { $plugins = $this->httpClient()->get( "{$this->marketplaceUrl}/ltmp-api" .(! empty($query) ? "/search/$query" : '/index') ."/$page".'?lt-v='.$this->appSettings->appVersion ); $pluginArray = $plugins->collect()->toArray(); $plugins = []; $pluginsFlat = []; if (isset($pluginArray['categories'])) { foreach ($pluginArray['categories'] as $category) { $plugins[$category['slug']] = [ 'name' => $category['name'], 'description' => $category['description'], 'plugins' => [], ]; foreach ($category['products'] as $plugin) { $priceString = ''; if (! empty($plugin['sub_interval']) && $plugin['sub_interval'] === 'year') { $price = $plugin['price'] ?? 0; $months = 12; $lowestUserTier = 10; $perUserMonth = round(($price / $months / $lowestUserTier), 2); $priceString = '$'.$perUserMonth.' per user/month (billed annually) '; } $plugins[$category['slug']]['plugins'][Str::lower($plugin['identifier'])] = self::buildMarketplacePluginFromListData($plugin, $priceString); $pluginsFlat[Str::lower($plugin['identifier'])] = $plugins[$category['slug']]['plugins'][Str::lower($plugin['identifier'])]; } } } Cache::store('installation')->set('plugins.marketplacePlugins', $plugins); Cache::store('installation')->set('plugins.marketplacePluginsFlat', $pluginsFlat); return $pluginsFlat; } /** * Builds a MarketplacePlugin from listing API data with proper type coercion. * * The marketplace API may return unexpected types (string instead of array, * null instead of int, etc.) so all values are explicitly cast to match the * MarketplacePlugin model's typed properties. * * @param array $plugin Raw plugin data from the marketplace listing API. * @param string $priceString Pre-formatted monthly price string. * @return MarketplacePlugin The hydrated plugin model. */ private static function buildMarketplacePluginFromListData(array $plugin, string $priceString): MarketplacePlugin { return build(new MarketplacePlugin) ->set('identifier', (string) ($plugin['identifier'] ?? '')) ->set('name', (string) ($plugin['post_title'] ?? '')) ->set('excerpt', (string) ($plugin['excerpt'] ?? '')) ->set('description', '') ->set('imageUrl', (string) ($plugin['icon'] ?? '')) ->set('icon', (string) ($plugin['icon'] ?? '')) ->set('vendorDisplayName', (string) ($plugin['vendor'] ?? '')) ->set('vendorId', (int) ($plugin['vendor_id'] ?? 0)) ->set('vendorEmail', (string) ($plugin['vendor_email'] ?? '')) ->set('marketplaceUrl', '') ->set('startingPrice', '$'.($plugin['price'] ?? '')) ->set('calculatedMonthlyPrice', $priceString) ->set('rating', (string) ($plugin['rating'] ?? '')) ->set('reviewCount', (int) ($plugin['review_count'] ?? 0)) ->set('reviews', is_array($plugin['reviews'] ?? null) ? $plugin['reviews'] : []) ->set('marketplaceId', (string) ($plugin['product_id'] ?? '')) ->set('version', (string) ($plugin['version'] ?? '')) ->set('pricingTiers', is_array($plugin['tiers'] ?? null) ? $plugin['tiers'] : []) ->set('categories', is_array($plugin['categories'] ?? null) ? $plugin['categories'] : []) ->set('tags', is_array($plugin['tags'] ?? null) ? $plugin['tags'] : []) ->set('compatibility', is_array($plugin['compatibility'] ?? null) ? $plugin['compatibility'] : []) ->get(); } /** * Builds a MarketplacePlugin from raw request properties. * * Request data arrives with every field encoded as a string. Any field whose * value is a valid JSON string (arrays/objects) is decoded back into its native * structure before being assigned to the model. The 'version' field is treated * separately by the caller and is therefore expected to already be removed. * * @param array $pluginProps Raw plugin properties from the request (without 'version'). * @return MarketplacePlugin The hydrated plugin model. * * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function buildMarketplacePluginFromRequest(array $pluginProps): MarketplacePlugin { $builder = build(new MarketplacePlugin); foreach ($pluginProps as $key => $value) { // Only accept the properties the install round-trip legitimately carries. Everything // else — notably control fields like `type` and `marketplaceUrl` — is ignored, so a // request can't mass-assign arbitrary model state (the download always targets the // server-configured marketplace, keyed by identifier). if (! in_array($key, self::MARKETPLACE_INSTALL_PROPS, true)) { continue; } if (is_string($value)) { $newValue = json_decode(json: $value, flags: JSON_OBJECT_AS_ARRAY); if (json_last_error() === JSON_ERROR_NONE && $newValue !== null) { $value = $newValue; } } $builder->set($key, $value); } return $builder->get(); } /** * Determines whether a marketplace plugin belongs to the "bundles" category. * * @param MarketplacePlugin $plugin The plugin to inspect. * @return bool True if the plugin is part of the bundles category. * * @api */ public function isBundle(MarketplacePlugin $plugin): bool { return collect($plugin->categories)->where('slug', '=', 'bundles')->count() > 0; } /** * Parses a marketplace download/install RequestException into a clean, * user-facing error message. * * The marketplace responds with status-code prefixes (e.g. * "HTTP request returned status code 500:") wrapped around a JSON body. This * strips those prefixes, decodes the JSON and returns the most specific * message available, falling back to a generic installation error. * * @param RequestException $exception The exception thrown while installing. * @return string A clean error message safe to show to the user. * * @api */ public function parseMarketplaceError(RequestException $exception): string { $errorJson = str_replace('HTTP request returned status code 500:', '', $exception->getMessage()); $errorJson = str_replace('HTTP request returned status code 200:', '', $errorJson); $errors = json_decode(trim($errorJson)); return $errors->error ?? $errors->message ?? 'There was an error installing the plugin'; } /** * Retrieves a marketplace plugin's details by its identifier. * * @param string $identifier The unique identifier of the marketplace plugin. * @return MarketplacePlugin|false Returns a MarketplacePlugin instance if found, or false if the plugin could not be retrieved. * * @api */ public function getMarketplacePlugin(string $identifier): MarketplacePlugin|false { $response = $this->httpClient()->get("$this->marketplaceUrl/ltmp-api/details/$identifier?lt-v=".$this->appSettings->appVersion); if (! $response->ok()) { return false; } $data = $response->json(); return build(new MarketplacePlugin) ->set('identifier', $identifier) ->set('name', (string) ($data['name'] ?? '')) ->set('icon', (string) ($data['icon'] ?? '')) ->set('excerpt', (string) ($data['excerpt'] ?? '')) ->set('imageUrl', (string) ($data['icon'] ?? '')) ->set('description', nl2br((string) ($data['description'] ?? ''))) ->set('marketplaceUrl', (string) ($data['marketplaceUrl'] ?? '')) ->set('vendorId', (int) ($data['vendor']['id'] ?? 0)) ->set('vendorDisplayName', (string) ($data['vendor']['name'] ?? '')) ->set('vendorEmail', (string) ($data['vendor']['email'] ?? '')) ->set('rating', (string) ($data['reviews']['average'] ?? 'N/A')) ->set('reviewCount', (int) ($data['reviews']['count'] ?? 0)) ->set('reviews', is_array($data['reviews']['list'] ?? null) ? $data['reviews']['list'] : []) ->set('marketplaceId', (string) ($data['productId'] ?? '')) ->set('version', (string) ($data['version'] ?? '')) ->set('pricingTiers', is_array($data['tiers'] ?? null) ? $data['tiers'] : []) ->set('categories', is_array($data['categories'] ?? null) ? $data['categories'] : []) ->set('tags', is_array($data['tags'] ?? null) ? $data['tags'] : []) ->set('compatibility', is_array($data['compatibility'] ?? null) ? $data['compatibility'] : []) ->get(); } /** * Installs a marketplace plugin by downloading, extracting, and registering it in the plugin repository. * * @param MarketplacePlugin $plugin The marketplace plugin to be installed, including its identifier and license key. * @param string $version The version of the plugin to be installed. * * @throws RequestException If the HTTP request to download the plugin fails or returns an unexpected response type. * @throws \Exception If the plugin cannot be downloaded, removed, or added to the system. * @throws \RuntimeException If the plugin directory cannot be created. */ public function installMarketplacePlugin(MarketplacePlugin $plugin, string $version): void { $this->clearCache(); $response = $this->httpClient()->withHeaders([ 'X-License-Key' => $plugin->license, 'X-Instance-Id' => $this->settingsService->getCompanyId(), 'X-User-Count' => $this->usersService->getNumberOfUsers(activeOnly: true, includeApi: false), 'X-Leantime-Version' => $this->appSettings->appVersion, ])->get("{$this->marketplaceUrl}/ltmp-api/download/{$plugin->identifier}/{$version}"); if (! $response->ok()) { throw new RequestException($response); } if ($response->header('Content-Type') !== 'application/zip') { throw new RequestException($response); } $filename = $response->header('Content-Disposition'); $filename = substr($filename, strpos($filename, 'filename=') + 9); $foldername = Str::studly(basename($filename, '.zip')); $filename = Str::finish($foldername, '.zip'); if ( ! file_put_contents( $temporaryFile = Str::finish(sys_get_temp_dir(), '/').$filename, $response->body() ) ) { throw new \Exception(__('notification.plugin_cant_download')); } if ( is_dir($pluginDir = "{$this->pluginDirectory}{$foldername}") && ! File::deleteDirectory($pluginDir) ) { throw new \Exception(__('notification.plugin_cant_remove')); } if (! mkdir($pluginDir) && ! is_dir($pluginDir)) { throw new \RuntimeException(sprintf('Directory "%s" was not created', $pluginDir)); } $zip = new \ZipArchive; match ($zip->open($temporaryFile)) { \ZipArchive::ER_EXISTS => throw new \Exception(__('notification.plugin_zip_exists')), \ZipArchive::ER_INCONS => throw new \Exception(__('notification.plugin_zip_inconsistent')), \ZipArchive::ER_INVAL => throw new \Exception(__('notification.plugin_zip_invalid_arg')), \ZipArchive::ER_MEMORY => throw new \Exception(__('notification.plugin_zip_malloc')), \ZipArchive::ER_NOENT => throw new \Exception(__('notification.plugin_zip_no_file')), \ZipArchive::ER_NOZIP => throw new \Exception(__('notification.plugin_zip_not_zip')), \ZipArchive::ER_OPEN => throw new \Exception(__('notification.plugin_zip_cant_open')), \ZipArchive::ER_READ => throw new \Exception(__('notification.plugin_zip_read_err')), \ZipArchive::ER_SEEK => throw new \Exception(__('notification.plugin_zip_seek_err')), default => throw new \Exception(__('notification.plugin_zip_unknown_err')), true => null, }; if (! $zip->extractTo($pluginDir)) { throw new \Exception(__('notification.plugin_zip_cant_extract')); } $zip->close(); unlink($temporaryFile); // read the composer.json content from the plugin phar file $pluginModel = $this->createPluginFromComposer($foldername, $plugin->license); if (! $this->pluginRepository->addPlugin($pluginModel)) { throw new \Exception(__('notification_cant_add_to_db')); } } /** * Validates the license of a given plugin. * * @param InstalledPlugin $plugin The plugin object for which the license validity is being checked. * @return bool Returns true if the license is valid or the plugin is not of the marketplace type; returns false otherwise. */ public function validLicense(InstalledPlugin $plugin): bool { if ($plugin->getType() !== $this->pluginTypes['marketplace']) { return true; } $numberOfUsers = $this->usersService->getNumberOfUsers(activeOnly: true, includeApi: false); $instanceId = $this->settingsService->getCompanyId(); $phar = new \Phar( Str::finish($this->pluginDirectory, DIRECTORY_SEPARATOR) .Str::finish($plugin->foldername, DIRECTORY_SEPARATOR) .Str::finish($plugin->foldername, '.phar') ); $signature = $phar->getSignature(); $response = null; try { $response = $this->httpClient()->withHeaders([ 'X-License-Key' => $plugin->license, 'X-Instance-Id' => $instanceId, 'X-User-Count' => $numberOfUsers, 'X-Phar-Hash' => $signature, 'X-Leantime-Version' => $this->appSettings->appVersion, ])->get("{$this->marketplaceUrl}/ltmp-api/verify/{$plugin->getIdentifier()}"); } catch (\Exception $e) { Log::error($e); } // Could not reach the marketplace (transient error); keep the plugin enabled. if ($response === null) { return true; } $jsonResult = []; try { $body = $response->getBody()->getContents(); $jsonResult = json_decode($body, true); } catch (Exception $e) { Log::error($e); } if ($response->ok() && $jsonResult['valid'] === false) { // Notify owners of system $this->disablePluginNotifyOwner($plugin->id); return false; } return true; } /** * Disables the specified plugin and notifies the owner or administrators of the action. * * @param int $pluginId The ID of the plugin to disable. * @return void */ public function disablePluginNotifyOwner($pluginId) { $this->clearCache(); $this->disablePlugin($pluginId); // Get all admin users $userService = app()->make(UsersService::class); $notificationService = app()->make(Notifications::class); $plugin = $this->pluginRepository->getPlugin($pluginId); // Create notification for all admin users $admins = collect($userService->getAll())->filter(fn ($user) => in_array($user['role'], [40, 50])); $notifications = $admins->map(fn ($admin) => [ 'userId' => $admin['id'], 'read' => '0', 'type' => 'plugin_license', 'module' => 'plugins', 'moduleId' => $pluginId, 'message' => sprintf("The plugin '%s' has been disabled due to license validation failure. Please check your marketplace subscription.", $plugin->name), 'datetime' => date('Y-m-d H:i:s'), 'url' => '/plugins/show', 'authorId' => 1, ])->toArray(); $notificationService->addNotifications($notifications); } /** * Deactivates the specified plugin by performing necessary operations * such as sending a request to the marketplace and validating the response. * * @param InstalledPlugin $plugin The plugin instance to be deactivated. * @return bool Returns true if the plugin was successfully deactivated * or does not require deactivation; false otherwise. */ public function deactivate(InstalledPlugin $plugin): bool { if ($plugin->getType() !== $this->pluginTypes['marketplace']) { return true; } $numberOfUsers = $this->usersService->getNumberOfUsers(activeOnly: true, includeApi: false); $instanceId = $this->settingsService->getCompanyId(); $phar = new \Phar( Str::finish($this->pluginDirectory, DIRECTORY_SEPARATOR) .Str::finish($plugin->foldername, DIRECTORY_SEPARATOR) .Str::finish($plugin->foldername, '.phar') ); $signature = $phar->getSignature(); $response = null; try { $response = $this->httpClient()->withHeaders([ 'X-License-Key' => $plugin->license, 'X-Instance-Id' => $instanceId, 'X-User-Count' => $numberOfUsers, 'X-Phar-Hash' => $signature, 'X-Leantime-Version' => $this->appSettings->appVersion, ])->get("{$this->marketplaceUrl}/ltmp-api/deactivate/{$plugin->getIdentifier()}"); } catch (\Exception $e) { Log::error($e); } // Could not reach the marketplace (transient error); treat as a no-op success. if ($response === null) { return true; } $jsonResult = []; try { $body = $response->getBody()->getContents(); $jsonResult = json_decode($body, true); } catch (Exception $e) { Log::error($e); } if ($response->ok() && $jsonResult['valid'] === false) { Log::warning($jsonResult['error']); return false; } return true; } /** * Runs a single plugin lifecycle action (install, enable, disable, remove) and * returns the notification descriptor describing the outcome. * * The returned array is a [messageKey, type] pair where messageKey is a language * key and type is the notification severity ('success' or 'error'). This keeps the * dynamic dispatch and notification-key assembly out of the controller. * * @param string $action One of: install, enable, disable, remove. * @param mixed $id The plugin identifier (folder name for install, numeric id otherwise). * @return array{0: string, 1: string} A [messageKey, type] notification descriptor. * * @throws \InvalidArgumentException If the action is not a supported lifecycle action. * @throws BindingResolutionException * * @api */ #[RequiresPermission(PluginsPermissions::MANAGE, global: true)] public function performPluginAction(string $action, mixed $id): array { $allowedActions = ['install', 'enable', 'disable', 'remove']; if (! in_array($action, $allowedActions, true)) { throw new \InvalidArgumentException("Unsupported plugin action: {$action}"); } $succeeded = $this->{"{$action}Plugin"}($id); return $succeeded ? ["notification.plugin_{$action}_success", 'success'] : ["notification.plugin_{$action}_error", 'error']; } /** * Aggregates the CSS contributed by enabled plugins into a single payload. * * Runs the 'pluginCss' filter to collect the list of plugin CSS files, keeps only * the files that actually exist under APP_ROOT/plugins, reads their contents and * concatenates them into one string. * * @return string The combined CSS contents of all registered, existing plugin files. * * @api */ public function getAggregatedPluginCss(): string { $cssFiles = self::dispatch_filter('pluginCss', []); $cssStrs = collect($cssFiles) ->filter(fn ($file) => file_exists(APP_ROOT."/plugins/$file")) ->map(fn ($file) => file_get_contents(APP_ROOT."/plugins/$file")) ->all(); return implode('', $cssStrs); } /** * Clears cached data related to installation, sessions, and predefined file paths. * * Removes cached domain events, commands, and enabled plugins from the installation cache store. * Clears specific session variables related to template paths and composers. * Deletes stored cached files for view paths and composer paths in the framework's storage. * * @return void */ public function clearCache() { Cache::store('installation')->forget('domainEvents'); Cache::store('installation')->forget('commands'); Cache::store('installation')->forget('plugins.enabledPlugins'); // Permission engine caches (provider discovery + the role->permission map/meta), all // cross-request on the installation store — a documented cache clear must bust them too, // or a newly-shipped domain/plugin permission class stays invisible (and acts as a // recovery path if the provider cache ever goes stale). Cache::store('installation')->forget('permissionProviders'); Cache::store('installation')->forget('leantime.permissionMap'); Cache::store('installation')->forget('leantime.permissionMeta'); session()->forget('template_paths'); session()->forget('composers'); $files = app()->make(\Illuminate\Filesystem\Filesystem::class); $viewPathCachePath = storage_path('framework/viewPaths.php'); $files->delete($viewPathCachePath); $composerPathCachePath = storage_path('framework/composerPaths.php'); $files->delete($composerPathCachePath); } }