Upstream pelican/panel state of the touched files before 53ea227d1f
This commit is contained in:
commit
4494b7409f
2 files changed
+1016
No files matched your search
@@ -0,0 +1,594 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Helpers;
|
||||
|
||||
use App\Enums\PluginStatus;
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Models\Plugin;
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use Exception;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Console\Application as ConsoleApplication;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Migrations\Migrator;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use JsonException;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class PluginService
|
||||
{
|
||||
public function __construct(private Application $app) {}
|
||||
|
||||
public function loadPlugins(): void
|
||||
{
|
||||
// Don't load any plugins during tests
|
||||
if ($this->app->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var ClassLoader $classLoader */
|
||||
$classLoader = File::getRequire(base_path('vendor/autoload.php'));
|
||||
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
// Filter out plugins that require a newer plugin api than this panel supports
|
||||
// (only in production, so plugin developers can work on fixing their plugin locally)
|
||||
if ($this->app->isProduction() && !$plugin->isApiVersionSupported()) {
|
||||
$this->setStatus($plugin, PluginStatus::Incompatible, 'This Plugin requires plugin api version ' . $plugin->effectiveApiVersion() . ' but this Panel only supports up to version ' . Plugin::SUPPORTED_API_VERSION . '!');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out plugins that are not compatible with the current panel version
|
||||
if (!$plugin->isCompatible()) {
|
||||
$this->setStatus($plugin, PluginStatus::Incompatible, 'This Plugin is only compatible with Panel version ' . $plugin->panel_version . (!$plugin->isPanelVersionStrict() ? ' or a compatible newer version' : '') . ' but you are using version ' . $this->app->make(SoftwareVersionService::class)->currentPanelVersion() . '!');
|
||||
|
||||
continue;
|
||||
} else {
|
||||
// Make sure to update the status if a plugin is no longer incompatible (e.g. because the user changed their panel version)
|
||||
if ($plugin->status === PluginStatus::Incompatible) {
|
||||
$this->disablePlugin($plugin);
|
||||
}
|
||||
}
|
||||
|
||||
if ($plugin->namespace === 'Error') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always autoload src directory to make sure all class names can be resolved (e.g. in migrations)
|
||||
$namespace = $plugin->namespace . '\\';
|
||||
if (!array_key_exists($namespace, $classLoader->getPrefixesPsr4())) {
|
||||
$classLoader->setPsr4($namespace, plugin_path($plugin->id, 'src/'));
|
||||
|
||||
$classLoader->addPsr4('Database\Factories\\', plugin_path($plugin->id, 'database/Factories/'));
|
||||
$classLoader->addPsr4('Database\Seeders\\', plugin_path($plugin->id, 'database/Seeders/'));
|
||||
}
|
||||
|
||||
// Load config
|
||||
$config = plugin_path($plugin->id, 'config', $plugin->id . '.php');
|
||||
if (file_exists($config)) {
|
||||
config()->set($plugin->id, require $config);
|
||||
}
|
||||
|
||||
// Filter out plugins that should not be loaded (e.g. because they are disabled or not installed yet)
|
||||
if (!$plugin->shouldLoad()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load translations
|
||||
$translations = plugin_path($plugin->id, 'lang');
|
||||
if (file_exists($translations)) {
|
||||
$this->app->afterResolving('translator', function ($translator) use ($plugin, $translations) {
|
||||
if ($plugin->isLanguage()) {
|
||||
$translator->addPath($translations);
|
||||
} else {
|
||||
$translator->addNamespace($plugin->id, $translations);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register service providers
|
||||
foreach ($plugin->getProviders() as $provider) {
|
||||
if (!class_exists($provider) || !is_subclass_of($provider, ServiceProvider::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->app->register($provider);
|
||||
}
|
||||
|
||||
// Resolve artisan commands
|
||||
foreach ($plugin->getCommands() as $command) {
|
||||
if (!class_exists($command) || !is_subclass_of($command, Command::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ConsoleApplication::starting(function ($artisan) use ($command) {
|
||||
$artisan->resolve($command);
|
||||
});
|
||||
}
|
||||
|
||||
// Load migrations
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
$this->app->afterResolving('migrator', function ($migrator) use ($migrations) {
|
||||
$migrator->path($migrations);
|
||||
});
|
||||
}
|
||||
|
||||
// Load views
|
||||
$views = plugin_path($plugin->id, 'resources', 'views');
|
||||
if (file_exists($views)) {
|
||||
$this->app->afterResolving('view', function ($view) use ($plugin, $views) {
|
||||
$view->addNamespace($plugin->id, $views);
|
||||
});
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function loadPanelPlugins(Panel $panel): void
|
||||
{
|
||||
// Don't load any plugins during tests
|
||||
if ($this->app->runningUnitTests()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
try {
|
||||
if (!$plugin->shouldLoad($panel->getId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pluginClass = $plugin->fullClass();
|
||||
|
||||
throw_unless(class_exists($pluginClass), new Exception('Class "' . $pluginClass . '" not found'));
|
||||
|
||||
$panel->plugin(new $pluginClass());
|
||||
|
||||
if ($plugin->status === PluginStatus::Errored) {
|
||||
$this->enablePlugin($plugin);
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->handlePluginException($plugin, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|array<string, string> $newPackages
|
||||
* @param null|array<string, string> $oldPackages
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function manageComposerPackages(?array $newPackages = [], ?array $oldPackages = null): void
|
||||
{
|
||||
$newPackages ??= [];
|
||||
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if (!$plugin->composer_packages) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$plugin->shouldLoad()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$pluginPackages = json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
$newPackages = array_merge($newPackages, $pluginPackages);
|
||||
} catch (Exception $exception) {
|
||||
report($exception);
|
||||
}
|
||||
}
|
||||
|
||||
$oldPackages = collect($oldPackages)
|
||||
->filter(fn ($version, $package) => !array_key_exists($package, $newPackages))
|
||||
->keys()
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (count($oldPackages) > 0) {
|
||||
$result = Process::path(base_path())->timeout(600)->run(['composer', 'remove', ...$oldPackages]);
|
||||
throw_if($result->failed(), new Exception('Could not remove old composer packages: ' . $result->errorOutput()));
|
||||
}
|
||||
|
||||
$newPackages = collect($newPackages)
|
||||
->map(fn ($version, $package) => "$package:$version")
|
||||
->flatten()
|
||||
->unique()
|
||||
->toArray();
|
||||
|
||||
if (count($newPackages) > 0) {
|
||||
$result = Process::path(base_path())->timeout(600)->run(['composer', 'require', ...$newPackages]);
|
||||
throw_if($result->failed(), new Exception('Could not require new composer packages: ' . $result->errorOutput()));
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function runPluginMigrations(Plugin $plugin): void
|
||||
{
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
try {
|
||||
$migrator = $this->app->make(Migrator::class);
|
||||
$migrator->run($migrations);
|
||||
} catch (Exception $exception) {
|
||||
throw new Exception("Could not run migrations': " . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function rollbackPluginMigrations(Plugin $plugin): void
|
||||
{
|
||||
$migrations = plugin_path($plugin->id, 'database', 'migrations');
|
||||
if (file_exists($migrations)) {
|
||||
try {
|
||||
$migrator = $this->app->make(Migrator::class);
|
||||
$migrator->reset($migrations);
|
||||
} catch (Exception $exception) {
|
||||
throw new Exception("Could not rollback migrations': " . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function runPluginSeeder(Plugin $plugin): void
|
||||
{
|
||||
$seeder = $plugin->getSeeder();
|
||||
if ($seeder) {
|
||||
try {
|
||||
$seederObject = $this->app->make($seeder)->setContainer($this->app);
|
||||
|
||||
Model::unguarded(fn () => $seederObject->__invoke());
|
||||
} catch (Exception $exception) {
|
||||
throw new Exception('Could not run seeder: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function buildAssets(bool $throw = false): bool
|
||||
{
|
||||
try {
|
||||
$result = Process::path(base_path())->timeout(300)->run('yarn install');
|
||||
throw_if($result->failed(), new Exception('Could not install yarn dependencies: ' . $result->errorOutput()));
|
||||
|
||||
$result = Process::path(base_path())->timeout(600)->run('yarn build');
|
||||
throw_if($result->failed(), new Exception('Could not build assets: ' . $result->errorOutput()));
|
||||
|
||||
return true;
|
||||
} catch (Exception $exception) {
|
||||
throw_if($throw || $this->isDevModeActive(), ($exception));
|
||||
|
||||
Log::warning($exception->getMessage(), ['exception' => $exception]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function installPlugin(Plugin $plugin, bool $enable = true): void
|
||||
{
|
||||
try {
|
||||
$this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512));
|
||||
|
||||
$this->buildAssets($plugin->isTheme());
|
||||
|
||||
$this->runPluginMigrations($plugin);
|
||||
|
||||
$this->runPluginSeeder($plugin);
|
||||
|
||||
if ($enable) {
|
||||
$this->enablePlugin($plugin);
|
||||
} else {
|
||||
if ($plugin->status === PluginStatus::NotInstalled) {
|
||||
$this->disablePlugin($plugin);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Filament::getPanels() as $panel) {
|
||||
$panel->clearCachedComponents();
|
||||
}
|
||||
} catch (Exception $exception) {
|
||||
$this->setStatus($plugin, PluginStatus::NotInstalled, $exception->getMessage());
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function updatePlugin(Plugin $plugin): void
|
||||
{
|
||||
$downloadUrl = $plugin->getDownloadUrlForUpdate();
|
||||
throw_unless($downloadUrl, new Exception('No download url found.'));
|
||||
|
||||
$this->downloadPluginFromUrl($downloadUrl, $plugin->id);
|
||||
|
||||
Plugin::refreshRows();
|
||||
$plugin = $plugin->refresh();
|
||||
|
||||
$this->installPlugin($plugin, false);
|
||||
|
||||
cache()->forget("plugins.$plugin->id.update");
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function uninstallPlugin(Plugin $plugin, bool $deleteFiles = false): void
|
||||
{
|
||||
$pluginPackages = json_decode($plugin->composer_packages, true, 512);
|
||||
|
||||
$this->rollbackPluginMigrations($plugin);
|
||||
|
||||
$this->setStatus($plugin, PluginStatus::NotInstalled);
|
||||
|
||||
if ($deleteFiles) {
|
||||
$this->deletePlugin($plugin);
|
||||
}
|
||||
|
||||
$this->buildAssets();
|
||||
|
||||
$this->manageComposerPackages(oldPackages: $pluginPackages);
|
||||
|
||||
foreach (Filament::getPanels() as $panel) {
|
||||
$panel->clearCachedComponents();
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function downloadPluginFromFile(UploadedFile $file, ?string $expectedId = null): string
|
||||
{
|
||||
// Validate file size to prevent zip bombs
|
||||
$maxSize = config('panel.plugin.max_import_size');
|
||||
$maxSizeLabel = round($maxSize / 1024 / 1024, 2);
|
||||
throw_if($file->getSize() > $maxSize, new Exception("Zip file too large. ($maxSizeLabel MiB)"));
|
||||
|
||||
$zip = new ZipArchive();
|
||||
|
||||
throw_unless($zip->open($file->getPathname()), new Exception('Could not open zip file.'));
|
||||
|
||||
// The check above only sees the compressed bytes, which say nothing about what the
|
||||
// archive expands to — a small, highly compressible zip can still fill the plugins
|
||||
// volume. Total the uncompressed entry sizes while walking the entries and bail as
|
||||
// soon as they exceed the same limit, before anything is written to disk.
|
||||
$uncompressedSize = 0;
|
||||
|
||||
// Validate zip contents before extraction
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$filename = $zip->getNameIndex($i);
|
||||
if (Str::contains($filename, '..') || Str::startsWith($filename, '/')) {
|
||||
$zip->close();
|
||||
throw new Exception('Zip file contains invalid path traversal sequences.');
|
||||
}
|
||||
|
||||
$stat = $zip->statIndex($i) ?: [];
|
||||
$uncompressedSize += $stat['size'] ?? 0;
|
||||
|
||||
if ($uncompressedSize > $maxSize) {
|
||||
$zip->close();
|
||||
throw new Exception("Zip file contents too large. ($maxSizeLabel MiB)");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract into a temporary directory that lives on the same filesystem as the plugins
|
||||
// directory (named as a dotfile so plugin discovery ignores it). Deriving the id from
|
||||
// the extracted plugin.json — rather than the upload filename — means a renamed zip
|
||||
// still imports, and keeping temp + target on one volume makes every move below an
|
||||
// atomic same-filesystem rename instead of a cross-device copy that can fail.
|
||||
$tmpDir = (new TemporaryDirectory(base_path('plugins')))
|
||||
->name('.import-' . Str::random(16))
|
||||
->deleteWhenDestroyed()
|
||||
->create();
|
||||
|
||||
$extractPath = $tmpDir->path('contents');
|
||||
|
||||
if (!$zip->extractTo($extractPath)) {
|
||||
$zip->close();
|
||||
throw new Exception('Could not extract zip file.');
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Locate plugin.json, either at the archive root (flat zip) or one level deep
|
||||
// (single top-level folder). Prefer the shallowest match.
|
||||
$manifest = $this->locatePluginManifest($extractPath);
|
||||
throw_if($manifest === null, new Exception(trans('admin/plugin.notifications.import_no_manifest')));
|
||||
|
||||
$data = File::json($manifest, JSON_THROW_ON_ERROR);
|
||||
$id = $data['id'] ?? null;
|
||||
throw_if(!is_string($id) || trim($id) === '', new Exception(trans('admin/plugin.notifications.import_no_manifest')));
|
||||
|
||||
// The id becomes the install directory name and must equal it once installed (see
|
||||
// Plugin::getRows()), so guard against anything that isn't a safe slug — e.g. an id of
|
||||
// "../../evil" that would escape the plugins directory when passed to plugin_path().
|
||||
$pluginName = Str::lower(trim($id));
|
||||
throw_unless(preg_match('/^[a-z0-9][a-z0-9._-]*$/', $pluginName), new Exception(trans('admin/plugin.notifications.import_invalid_id')));
|
||||
|
||||
// When updating a known plugin, the archive must be for that same plugin — reject a
|
||||
// mismatched id before moving anything, so an update can't overwrite a different plugin.
|
||||
throw_if($expectedId !== null && $pluginName !== $expectedId, new Exception(trans('admin/plugin.notifications.import_id_mismatch', ['expected' => $expectedId, 'actual' => $pluginName])));
|
||||
|
||||
$target = plugin_path($pluginName);
|
||||
$source = dirname($manifest);
|
||||
|
||||
// Importing over an existing plugin replaces it. Set the current install aside as a
|
||||
// rollback until the new copy is in place, so a failed move can't leave nothing behind.
|
||||
// The backup is a dotfile so discovery ignores it during the swap; temp, target and
|
||||
// backup all sit under plugins/, so each move is a same-filesystem rename.
|
||||
$rollback = null;
|
||||
if (File::isDirectory($target)) {
|
||||
$rollback = plugin_path('.' . $pluginName . '.bak');
|
||||
File::deleteDirectory($rollback);
|
||||
throw_unless(File::moveDirectory($target, $rollback), new Exception('Could not set the existing plugin aside.'));
|
||||
}
|
||||
|
||||
// Move the folder containing plugin.json to plugins/<id> so the layout is correct
|
||||
// regardless of the archive's internal folder name.
|
||||
if (!File::moveDirectory($source, $target)) {
|
||||
if ($rollback !== null) {
|
||||
File::moveDirectory($rollback, $target);
|
||||
}
|
||||
|
||||
throw new Exception('Could not move plugin into place.');
|
||||
}
|
||||
|
||||
if ($rollback !== null) {
|
||||
File::deleteDirectory($rollback);
|
||||
}
|
||||
|
||||
return $pluginName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a plugin.json inside an extracted archive, either at its root or within a single
|
||||
* top-level directory. Returns the absolute path to the manifest, or null if none exists.
|
||||
*/
|
||||
private function locatePluginManifest(string $path): ?string
|
||||
{
|
||||
$rootManifest = join_paths($path, 'plugin.json');
|
||||
if (File::exists($rootManifest)) {
|
||||
return $rootManifest;
|
||||
}
|
||||
|
||||
foreach (File::directories($path) as $directory) {
|
||||
$manifest = join_paths($directory, 'plugin.json');
|
||||
if (File::exists($manifest)) {
|
||||
return $manifest;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @throws Exception */
|
||||
public function downloadPluginFromUrl(string $url, ?string $expectedId = null): string
|
||||
{
|
||||
$basename = pathinfo($url, PATHINFO_BASENAME);
|
||||
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
|
||||
$tmpPath = $tmpDir->path($basename);
|
||||
|
||||
$content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body();
|
||||
|
||||
// Validate file size to prevent zip bombs
|
||||
$maxSize = config('panel.plugin.max_import_size');
|
||||
$maxSizeLabel = round($maxSize / 1024 / 1024, 2);
|
||||
throw_if(strlen($content) > $maxSize, new InvalidFileUploadException("Zip file too large. ($maxSizeLabel MiB)"));
|
||||
|
||||
throw_unless(file_put_contents($tmpPath, $content), new InvalidFileUploadException('Could not write temporary file.'));
|
||||
|
||||
return $this->downloadPluginFromFile(new UploadedFile($tmpPath, $basename, 'application/zip'), $expectedId);
|
||||
}
|
||||
|
||||
public function deletePlugin(Plugin $plugin): void
|
||||
{
|
||||
File::deleteDirectory(plugin_path($plugin->id));
|
||||
}
|
||||
|
||||
public function enablePlugin(string|Plugin $plugin): void
|
||||
{
|
||||
$this->setStatus($plugin, PluginStatus::Enabled);
|
||||
}
|
||||
|
||||
public function disablePlugin(string|Plugin $plugin): void
|
||||
{
|
||||
$this->setStatus($plugin, PluginStatus::Disabled);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function setMetaData(string|Plugin $plugin, array $data): void
|
||||
{
|
||||
$path = plugin_path($plugin->id, 'plugin.json');
|
||||
|
||||
if (File::exists($path)) {
|
||||
$pluginData = File::json($path, JSON_THROW_ON_ERROR);
|
||||
$metaData = array_merge($pluginData['meta'] ?? [], $data);
|
||||
$pluginData['meta'] = $metaData;
|
||||
|
||||
File::put($path, json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$plugin->update($metaData);
|
||||
}
|
||||
}
|
||||
|
||||
private function setStatus(string|Plugin $plugin, PluginStatus $status, ?string $message = null): void
|
||||
{
|
||||
$this->setMetaData($plugin, [
|
||||
'status' => $status,
|
||||
'status_message' => $message,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $order
|
||||
*
|
||||
* @throws JsonException
|
||||
*/
|
||||
public function updateLoadOrder(array $order): void
|
||||
{
|
||||
foreach ($order as $i => $plugin) {
|
||||
$plugin = Plugin::firstOrFail(str($plugin)->lower()->toString());
|
||||
|
||||
$this->setMetaData($plugin, [
|
||||
'load_order' => $i,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function hasThemePluginEnabled(): bool
|
||||
{
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->isTheme() && $plugin->status === PluginStatus::Enabled) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
public function getPluginLanguages(): array
|
||||
{
|
||||
$languages = [];
|
||||
|
||||
$plugins = Plugin::orderBy('load_order')->get();
|
||||
foreach ($plugins as $plugin) {
|
||||
if ($plugin->status !== PluginStatus::Enabled || !$plugin->isLanguage()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$languages = array_merge($languages, collect(File::directories(plugin_path($plugin->id, 'lang')))->map(fn ($path) => basename($path))->toArray());
|
||||
}
|
||||
|
||||
return array_unique($languages);
|
||||
}
|
||||
|
||||
public function isDevModeActive(): bool
|
||||
{
|
||||
return config('panel.plugin.dev_mode', false);
|
||||
}
|
||||
|
||||
private function handlePluginException(Plugin $plugin, Exception $exception): void
|
||||
{
|
||||
throw_if($this->isDevModeActive(), ($exception));
|
||||
|
||||
report($exception);
|
||||
|
||||
$this->setStatus($plugin, PluginStatus::Errored, $exception->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Integration\Services;
|
||||
|
||||
use App\Services\Helpers\PluginService;
|
||||
use App\Tests\Integration\IntegrationTestCase;
|
||||
use Exception;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Spatie\TemporaryDirectory\TemporaryDirectory;
|
||||
use ZipArchive;
|
||||
|
||||
class PluginServiceTest extends IntegrationTestCase
|
||||
{
|
||||
private PluginService $service;
|
||||
|
||||
/** @var string[] */
|
||||
private array $importedPlugins = [];
|
||||
|
||||
/** @var TemporaryDirectory[] */
|
||||
private array $uploadDirs = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = $this->app->make(PluginService::class);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Clean up anything the service touched in the real plugins directory: installed
|
||||
// plugins, their rollback backups, and any leftover extraction working directories.
|
||||
foreach ($this->importedPlugins as $id) {
|
||||
File::deleteDirectory(plugin_path($id));
|
||||
File::deleteDirectory(plugin_path('.' . $id . '.bak'));
|
||||
}
|
||||
|
||||
foreach (File::glob(plugin_path('.import-*')) as $leftover) {
|
||||
File::deleteDirectory($leftover);
|
||||
}
|
||||
|
||||
// TemporaryDirectory::make() does not clean up on its own; remove the upload fixtures.
|
||||
foreach ($this->uploadDirs as $dir) {
|
||||
$dir->delete();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_import_uses_plugin_json_id_not_the_upload_filename(): void
|
||||
{
|
||||
// Zip contains a top-level folder "test-import-plugin", but the upload is renamed as
|
||||
// a browser would on a duplicate download.
|
||||
$file = $this->makeUpload('test-import-plugin(1).zip', [
|
||||
'test-import-plugin/plugin.json' => $this->manifest('test-import-plugin'),
|
||||
]);
|
||||
|
||||
$this->importedPlugins[] = 'test-import-plugin';
|
||||
|
||||
$id = $this->service->downloadPluginFromFile($file);
|
||||
|
||||
$this->assertSame('test-import-plugin', $id);
|
||||
$this->assertFileExists(plugin_path('test-import-plugin', 'plugin.json'));
|
||||
// The (1) suffixed folder must never be created.
|
||||
$this->assertDirectoryDoesNotExist(plugin_path('test-import-plugin(1)'));
|
||||
}
|
||||
|
||||
public function test_import_handles_flat_zip_without_a_top_level_folder(): void
|
||||
{
|
||||
$file = $this->makeUpload('renamed(2).zip', [
|
||||
'plugin.json' => $this->manifest('test-flat-plugin'),
|
||||
]);
|
||||
|
||||
$this->importedPlugins[] = 'test-flat-plugin';
|
||||
|
||||
$id = $this->service->downloadPluginFromFile($file);
|
||||
|
||||
$this->assertSame('test-flat-plugin', $id);
|
||||
$this->assertFileExists(plugin_path('test-flat-plugin', 'plugin.json'));
|
||||
}
|
||||
|
||||
public function test_import_lowercases_the_manifest_id(): void
|
||||
{
|
||||
$file = $this->makeUpload('Test-Upper.zip', [
|
||||
'Test-Upper/plugin.json' => $this->manifest('Test-Upper'),
|
||||
]);
|
||||
|
||||
$this->importedPlugins[] = 'test-upper';
|
||||
|
||||
$id = $this->service->downloadPluginFromFile($file);
|
||||
|
||||
$this->assertSame('test-upper', $id);
|
||||
$this->assertFileExists(plugin_path('test-upper', 'plugin.json'));
|
||||
}
|
||||
|
||||
public function test_import_cleans_up_its_extraction_directory(): void
|
||||
{
|
||||
$this->importedPlugins[] = 'test-cleanup-plugin';
|
||||
|
||||
$this->service->downloadPluginFromFile($this->makeUpload('test-cleanup-plugin.zip', [
|
||||
'test-cleanup-plugin/plugin.json' => $this->manifest('test-cleanup-plugin'),
|
||||
]));
|
||||
|
||||
// The temporary ".import-*" working directory must not linger under plugins/.
|
||||
$this->assertSame([], File::glob(plugin_path('.import-*')));
|
||||
}
|
||||
|
||||
public function test_import_without_a_manifest_fails_loudly(): void
|
||||
{
|
||||
$file = $this->makeUpload('not-a-plugin.zip', [
|
||||
'readme.txt' => 'nothing to see here',
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest'));
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_with_a_non_string_id_fails_loudly(): void
|
||||
{
|
||||
$file = $this->makeUpload('bad-id.zip', [
|
||||
'plugin.json' => json_encode(['id' => []]),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest'));
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_with_a_missing_id_fails_loudly(): void
|
||||
{
|
||||
$file = $this->makeUpload('no-id.zip', [
|
||||
'plugin.json' => json_encode(['name' => 'No Id']),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest'));
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_with_an_empty_id_fails_loudly(): void
|
||||
{
|
||||
$file = $this->makeUpload('empty-id.zip', [
|
||||
'plugin.json' => json_encode(['id' => '']),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest'));
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_ignores_a_plugin_json_nested_too_deep(): void
|
||||
{
|
||||
// locatePluginManifest only looks at the root and one level deep; anything deeper is
|
||||
// not treated as a plugin.
|
||||
$file = $this->makeUpload('deep.zip', [
|
||||
'a/b/plugin.json' => $this->manifest('too-deep'),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest'));
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_zip_with_a_path_traversal_entry(): void
|
||||
{
|
||||
// A malicious archive entry that would write outside the extraction directory must be
|
||||
// rejected before extraction, even if it also carries a valid plugin.json.
|
||||
$file = $this->makeUpload('evil.zip', [
|
||||
'../evil.txt' => 'pwned',
|
||||
'evil/plugin.json' => $this->manifest('evil'),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Zip file contains invalid path traversal sequences.');
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_zip_larger_than_the_configured_max(): void
|
||||
{
|
||||
config()->set('panel.plugin.max_import_size', 10);
|
||||
|
||||
$file = $this->makeUpload('too-big.zip', [
|
||||
'too-big/plugin.json' => $this->manifest('too-big'),
|
||||
]);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage('Zip file too large');
|
||||
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
}
|
||||
|
||||
public function test_import_rejects_a_zip_that_expands_beyond_the_configured_max(): void
|
||||
{
|
||||
// Clean up in case a regression lets the archive through and it actually installs.
|
||||
$this->importedPlugins[] = 'zip-bomb';
|
||||
|
||||
config()->set('panel.plugin.max_import_size', 4096);
|
||||
|
||||
$file = $this->makeUpload('zip-bomb.zip', [
|
||||
'zip-bomb/plugin.json' => $this->manifest('zip-bomb'),
|
||||
'zip-bomb/payload.txt' => str_repeat('a', 100_000),
|
||||
]);
|
||||
|
||||
// The archive compresses down small enough to clear the compressed-size check, so only
|
||||
// the uncompressed total catches it.
|
||||
$this->assertLessThan(4096, $file->getSize());
|
||||
|
||||
$thrown = null;
|
||||
|
||||
try {
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
} catch (Exception $exception) {
|
||||
$thrown = $exception;
|
||||
}
|
||||
|
||||
$this->assertNotNull($thrown, 'Expected the oversized archive to be rejected.');
|
||||
$this->assertStringContainsString('Zip file contents too large', $thrown->getMessage());
|
||||
|
||||
// Nothing should have been written: no install, and no leftover extraction directory.
|
||||
$this->assertDirectoryDoesNotExist(plugin_path('zip-bomb'));
|
||||
$this->assertSame([], File::glob(plugin_path('.import-*')));
|
||||
}
|
||||
|
||||
public function test_import_from_url_downloads_and_installs_the_plugin(): void
|
||||
{
|
||||
$this->importedPlugins[] = 'test-url-plugin';
|
||||
|
||||
$bytes = file_get_contents($this->makeUpload('whatever.zip', [
|
||||
'test-url-plugin/plugin.json' => $this->manifest('test-url-plugin'),
|
||||
])->getPathname());
|
||||
|
||||
Http::fake(['*' => Http::response($bytes)]);
|
||||
|
||||
$id = $this->service->downloadPluginFromUrl('https://example.test/download/test-url-plugin.zip');
|
||||
|
||||
$this->assertSame('test-url-plugin', $id);
|
||||
$this->assertFileExists(plugin_path('test-url-plugin', 'plugin.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id an id that is a non-empty string but not a safe directory slug
|
||||
*/
|
||||
#[DataProvider('unsafeIds')]
|
||||
public function test_import_rejects_an_unsafe_plugin_id(string $id): void
|
||||
{
|
||||
// A clean entry name (so it passes the archive path-traversal check) whose manifest
|
||||
// still carries a dangerous id must be rejected before anything is moved into place.
|
||||
$file = $this->makeUpload('sneaky.zip', [
|
||||
'plugin.json' => json_encode(['id' => $id]),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
$this->fail('Expected the import to be rejected for id: ' . $id);
|
||||
} catch (Exception $e) {
|
||||
$this->assertSame(trans('admin/plugin.notifications.import_invalid_id'), $e->getMessage());
|
||||
}
|
||||
|
||||
// Nothing should have escaped the plugins directory.
|
||||
$this->assertDirectoryDoesNotExist(base_path('evil'));
|
||||
$this->assertDirectoryDoesNotExist(base_path('plugins/../evil'));
|
||||
}
|
||||
|
||||
/** @return array<string, array{string}> */
|
||||
public static function unsafeIds(): array
|
||||
{
|
||||
return [
|
||||
'parent traversal' => ['../../evil'],
|
||||
'single parent' => ['../evil'],
|
||||
'contains slash' => ['foo/bar'],
|
||||
'leading dot' => ['.hidden'],
|
||||
'just dots' => ['..'],
|
||||
'contains space' => ['foo bar'],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_import_over_an_existing_plugin_replaces_it(): void
|
||||
{
|
||||
$this->importedPlugins[] = 'test-replace-plugin';
|
||||
|
||||
$this->service->downloadPluginFromFile($this->makeUpload('test-replace-plugin.zip', [
|
||||
'test-replace-plugin/plugin.json' => $this->manifest('test-replace-plugin', '1.0.0'),
|
||||
]));
|
||||
|
||||
$this->assertSame('1.0.0', $this->installedVersion('test-replace-plugin'));
|
||||
|
||||
// Re-importing the same id is allowed and replaces the existing install.
|
||||
$id = $this->service->downloadPluginFromFile($this->makeUpload('test-replace-plugin.zip', [
|
||||
'test-replace-plugin/plugin.json' => $this->manifest('test-replace-plugin', '2.0.0'),
|
||||
]));
|
||||
|
||||
// The new copy is in place and no rollback backup is left behind.
|
||||
$this->assertSame('test-replace-plugin', $id);
|
||||
$this->assertSame('2.0.0', $this->installedVersion('test-replace-plugin'));
|
||||
$this->assertDirectoryDoesNotExist(plugin_path('.test-replace-plugin.bak'));
|
||||
}
|
||||
|
||||
public function test_import_succeeds_when_the_id_matches_the_expected_id(): void
|
||||
{
|
||||
$this->importedPlugins[] = 'test-expected-plugin';
|
||||
|
||||
$id = $this->service->downloadPluginFromFile($this->makeUpload('test-expected-plugin.zip', [
|
||||
'test-expected-plugin/plugin.json' => $this->manifest('test-expected-plugin'),
|
||||
]), 'test-expected-plugin');
|
||||
|
||||
$this->assertSame('test-expected-plugin', $id);
|
||||
$this->assertFileExists(plugin_path('test-expected-plugin', 'plugin.json'));
|
||||
}
|
||||
|
||||
public function test_import_rejects_an_archive_for_a_different_plugin_than_expected(): void
|
||||
{
|
||||
// Stand in for an update whose archive actually claims to be a *different* plugin than
|
||||
// the one being updated. It must be rejected before anything is written, so the update
|
||||
// can neither reinstall the wrong plugin nor overwrite an unrelated one.
|
||||
$this->importedPlugins[] = 'test-target-plugin';
|
||||
$this->importedPlugins[] = 'test-other-plugin';
|
||||
|
||||
$this->service->downloadPluginFromFile($this->makeUpload('test-target-plugin.zip', [
|
||||
'test-target-plugin/plugin.json' => $this->manifest('test-target-plugin', '1.0.0'),
|
||||
]));
|
||||
|
||||
$file = $this->makeUpload('update.zip', [
|
||||
'test-other-plugin/plugin.json' => $this->manifest('test-other-plugin', '9.9.9'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service->downloadPluginFromFile($file, 'test-target-plugin');
|
||||
$this->fail('Expected a mismatched update to be rejected.');
|
||||
} catch (Exception $e) {
|
||||
$this->assertSame(
|
||||
trans('admin/plugin.notifications.import_id_mismatch', ['expected' => 'test-target-plugin', 'actual' => 'test-other-plugin']),
|
||||
$e->getMessage()
|
||||
);
|
||||
}
|
||||
|
||||
// The intended plugin is untouched and the archive's plugin was never written.
|
||||
$this->assertSame('1.0.0', $this->installedVersion('test-target-plugin'));
|
||||
$this->assertDirectoryDoesNotExist(plugin_path('test-other-plugin'));
|
||||
}
|
||||
|
||||
public function test_import_keeps_the_existing_plugin_when_the_move_fails(): void
|
||||
{
|
||||
$this->importedPlugins[] = 'test-clean-plugin';
|
||||
|
||||
$this->service->downloadPluginFromFile($this->makeUpload('test-clean-plugin.zip', [
|
||||
'test-clean-plugin/plugin.json' => $this->manifest('test-clean-plugin', '1.0.0'),
|
||||
]));
|
||||
|
||||
// Force only the move into plugins/<id> to fail, standing in for a filesystem-level
|
||||
// failure mid-replace. The set-aside and restore moves (to/from the .bak dir) run for
|
||||
// real so we can prove the original install is restored intact.
|
||||
$real = new Filesystem();
|
||||
File::partialMock()
|
||||
->shouldReceive('moveDirectory')
|
||||
->andReturnUsing(function ($from, $to) use ($real) {
|
||||
if (str_ends_with($to, '.bak') || str_ends_with($from, '.bak')) {
|
||||
return $real->moveDirectory($from, $to);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$file = $this->makeUpload('test-clean-plugin.zip', [
|
||||
'test-clean-plugin/plugin.json' => $this->manifest('test-clean-plugin', '2.0.0'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->service->downloadPluginFromFile($file);
|
||||
$this->fail('Expected the import to fail.');
|
||||
} catch (Exception $e) {
|
||||
$this->assertSame('Could not move plugin into place.', $e->getMessage());
|
||||
}
|
||||
|
||||
// The original 1.0.0 install must survive — not the failed 2.0.0 replacement — and the
|
||||
// backup directory must be gone.
|
||||
$this->assertSame('1.0.0', $this->installedVersion('test-clean-plugin'));
|
||||
$this->assertDirectoryDoesNotExist(plugin_path('.test-clean-plugin.bak'));
|
||||
}
|
||||
|
||||
private function installedVersion(string $id): string
|
||||
{
|
||||
return File::json(plugin_path($id, 'plugin.json'))['version'];
|
||||
}
|
||||
|
||||
private function manifest(string $id, string $version = '1.0.0'): string
|
||||
{
|
||||
return json_encode(['id' => $id, 'version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a zip on disk from the given [path => contents] map and wrap it in an UploadedFile.
|
||||
*
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function makeUpload(string $clientName, array $entries): UploadedFile
|
||||
{
|
||||
$tmpDir = TemporaryDirectory::make();
|
||||
$this->uploadDirs[] = $tmpDir;
|
||||
$zipPath = $tmpDir->path('upload.zip');
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
foreach ($entries as $path => $contents) {
|
||||
$zip->addFromString($path, $contents);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
// test mode (last arg) bypasses the is_uploaded_file() check.
|
||||
return new UploadedFile($zipPath, $clientName, 'application/zip', null, true);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user