diff --git a/.gitignore b/.gitignore index 2eddb0df63..6f0aad4e82 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /config/connexions.yml .DS_Store /vendor +/plugins composer.phar behat.yml /datas diff --git a/CHANGELOG.md b/CHANGELOG.md index a151f62b45..ca1705783c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Add bin/developer console for developement purpose. - Add possibility to delete a basket from the workzone basket browser. - Add localized labels for databox documentary fields. + - Add plugin architecture for third party modules and customization. * 3.7.12 (2013-05-13) diff --git a/bin/console b/bin/console index e8eb5cf321..48ac46beb3 100755 --- a/bin/console +++ b/bin/console @@ -24,8 +24,10 @@ use Alchemy\Phrasea\Command\RecordAdd; use Alchemy\Phrasea\Command\RescanTechnicalDatas; use Alchemy\Phrasea\Command\UpgradeDBDatas; use Alchemy\Phrasea\CLI; +use Alchemy\Phrasea\Command\Plugin\AddPlugin; +use Alchemy\Phrasea\Command\Plugin\RemovePlugin; -require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/autoload.php'; try { $app = new CLI(" @@ -90,6 +92,9 @@ try { $app->command(new RescanTechnicalDatas('records:rescan-technical-datas')); $app->command(new BuildMissingSubdefs('records:build-missing-subdefs')); + $app->command(new AddPlugin()); + $app->command(new RemovePlugin()); + $result_code = is_int($app->run()) ? : 1; } catch (\Exception $e) { $result_code = 1; diff --git a/bin/developer b/bin/developer index 849f3be29d..94126f12f8 100755 --- a/bin/developer +++ b/bin/developer @@ -36,7 +36,7 @@ use Doctrine\ORM\Tools\Console\Command\ConvertMappingCommand; use Doctrine\ORM\Tools\Console\Command\RunDqlCommand; use Doctrine\ORM\Tools\Console\Command\ValidateSchemaCommand; -require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/autoload.php'; try { $cli = new CLI(" diff --git a/composer.json b/composer.json index 9594c84fae..d831160c28 100644 --- a/composer.json +++ b/composer.json @@ -16,8 +16,10 @@ "facebook/php-sdk" : "~3.0", "gedmo/doctrine-extensions" : "~2.3.0", "google-plus/api-client" : "~0.6.1", + "guzzle/guzzle" : "~3.0", "imagine/imagine" : "dev-ColorSpaces@dev", "jms/serializer" : "~0.10", + "justinrainbow/json-schema" : "~1.3", "mediavorus/mediavorus" : "~0.3", "media-alchemyst/media-alchemyst" : "~0.2.3", "monolog/monolog" : "~1.3.0", @@ -27,6 +29,7 @@ "neutron/silex-filesystem-provider": "dev-master", "neutron/sphinxsearch-api" : "~2.0.6", "neutron/recaptcha" : "~0.1.0", + "neutron/temporary-filesystem" : "~2.0", "php-xpdf/php-xpdf" : "~0.1.1", "phpexiftool/phpexiftool" : "~0.2, >=0.2.2", "silex/silex" : "~1.0.0", diff --git a/composer.lock b/composer.lock index 64ce9978ac..08b7c8e15f 100644 --- a/composer.lock +++ b/composer.lock @@ -3,7 +3,7 @@ "This file locks the dependencies of your project to a known state", "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file" ], - "hash": "7fd45681e93a7fe1d0f4e691818ad4e2", + "hash": "06eddf18808e8091de82ec3b5d6b699e", "packages": [ { "name": "BadFaith/BadFaith", @@ -1091,6 +1091,64 @@ ], "time": "2013-03-28 16:41:24" }, + { + "name": "justinrainbow/json-schema", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "1.3.1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/1.3.1", + "reference": "1.3.1", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "autoload": { + "psr-0": { + "JsonSchema": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch", + "homepage": "http://wiedler.ch/igor/" + }, + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com", + "homepage": "http://digitalkaoz.net" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "time": "2013-02-21 04:49:21" + }, { "name": "media-alchemyst/media-alchemyst", "version": "0.2.5", @@ -1540,6 +1598,43 @@ ], "time": "2012-10-23 02:05:12" }, + { + "name": "neutron/temporary-filesystem", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/romainneutron/Temporary-Filesystem.git", + "reference": "2.0.1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/romainneutron/Temporary-Filesystem/zipball/2.0.1", + "reference": "2.0.1", + "shasum": "" + }, + "require": { + "symfony/filesystem": ">=2.0,<3.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Neutron": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Neutron", + "email": "imprec@gmail.com", + "homepage": "http://www.lickmychip.com/" + } + ], + "description": "Symfony filesystem extension to handle temporary files", + "time": "2013-04-09 08:38:21" + }, { "name": "php-ffmpeg/php-ffmpeg", "version": "0.2.4", @@ -2722,21 +2817,21 @@ }, { "name": "behat/mink", - "version": "v1.4.3", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/Behat/Mink.git", - "reference": "v1.4.3" + "reference": "v1.5.0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/Mink/zipball/v1.4.3", - "reference": "v1.4.3", + "url": "https://api.github.com/repos/Behat/Mink/zipball/v1.5.0", + "reference": "v1.5.0", "shasum": "" }, "require": { "php": ">=5.3.1", - "symfony/css-selector": ">=2.0,<2.4-dev" + "symfony/css-selector": ">=2.0,<3.0" }, "suggest": { "behat/mink-browserkit-driver": "extremely fast headless driver for Symfony\\Kernel-based apps (Sf2, Silex)", @@ -2747,7 +2842,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-develop": "1.4.x-dev" + "dev-develop": "1.5.x-dev" } }, "autoload": { @@ -2773,24 +2868,24 @@ "testing", "web" ], - "time": "2013-03-02 15:53:18" + "time": "2013-04-13 23:39:27" }, { "name": "behat/mink-browserkit-driver", - "version": "v1.0.5", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/Behat/MinkBrowserKitDriver.git", - "reference": "v1.0.5" + "reference": "v1.1.0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/MinkBrowserKitDriver/zipball/v1.0.5", - "reference": "v1.0.5", + "url": "https://api.github.com/repos/Behat/MinkBrowserKitDriver/zipball/v1.1.0", + "reference": "v1.1.0", "shasum": "" }, "require": { - "behat/mink": ">=1.4.3,<1.5", + "behat/mink": ">=1.5,<1.6", "php": ">=5.3.1", "symfony/browser-kit": ">=2.0,<3.0", "symfony/dom-crawler": ">=2.0,<3.0" @@ -2801,7 +2896,7 @@ "type": "mink-driver", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -2828,30 +2923,29 @@ "browser", "testing" ], - "time": "2013-04-13 12:17:15" + "time": "2013-04-13 23:46:30" }, { "name": "behat/mink-extension", - "version": "v1.0.1", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/Behat/MinkExtension.git", - "reference": "v1.0.1" + "reference": "v1.1.0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/v1.0.1", - "reference": "v1.0.1", + "url": "https://api.github.com/repos/Behat/MinkExtension/zipball/v1.1.0", + "reference": "v1.1.0", "shasum": "" }, "require": { - "behat/behat": ">=2.4,<2.5-dev", - "behat/mink": ">=1.4,<1.5-dev", + "behat/behat": ">=2.4.5,<2.5", + "behat/mink": ">=1.4.3,<1.6-dev", "php": ">=5.3.2" }, "require-dev": { - "behat/mink-goutte-driver": "1.0.*", - "fabpot/goutte": "1.0.*@dev" + "behat/mink-goutte-driver": ">=1.0,<2.0" }, "type": "behat-extension", "extra": { @@ -2883,7 +2977,7 @@ "test", "web" ], - "time": "2013-02-19 23:49:16" + "time": "2013-05-23 12:32:57" }, { "name": "behat/mink-goutte-driver", @@ -2938,27 +3032,27 @@ }, { "name": "behat/mink-selenium2-driver", - "version": "v1.0.6", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/Behat/MinkSelenium2Driver.git", - "reference": "v1.0.6" + "reference": "v1.1.0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Behat/MinkSelenium2Driver/zipball/v1.0.6", - "reference": "v1.0.6", + "url": "https://api.github.com/repos/Behat/MinkSelenium2Driver/zipball/v1.1.0", + "reference": "v1.1.0", "shasum": "" }, "require": { - "behat/mink": ">=1.4.3,<1.5", + "behat/mink": ">=1.5,<1.6", "instaclick/php-webdriver": ">=1.0.12.0,<1.1", "php": ">=5.3.1" }, "type": "mink-driver", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-master": "1.1.x-dev" } }, "autoload": { @@ -2992,7 +3086,7 @@ "testing", "webdriver" ], - "time": "2013-04-13 12:56:28" + "time": "2013-04-13 23:51:01" }, { "name": "doctrine/data-fixtures", @@ -3037,7 +3131,7 @@ ], "authors": [ { - "name": "Jonathan H. Wage", + "name": "Jonathan Wage", "email": "jonwage@gmail.com", "homepage": "http://www.jwage.com/" } diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index 8d29579711..2672f79248 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -89,9 +89,11 @@ use Alchemy\Phrasea\Core\Provider\NotificationDelivererServiceProvider; use Alchemy\Phrasea\Core\Provider\ORMServiceProvider; use Alchemy\Phrasea\Core\Provider\PhraseanetServiceProvider; use Alchemy\Phrasea\Core\Provider\PhraseaVersionServiceProvider; +use Alchemy\Phrasea\Core\Provider\PluginServiceProvider; use Alchemy\Phrasea\Core\Provider\RegistrationServiceProvider; use Alchemy\Phrasea\Core\Provider\SearchEngineServiceProvider; use Alchemy\Phrasea\Core\Provider\TaskManagerServiceProvider; +use Alchemy\Phrasea\Core\Provider\TemporaryFilesystemServiceProvider; use Alchemy\Phrasea\Core\Provider\TokensServiceProvider; use Alchemy\Phrasea\Core\Provider\UnicodeServiceProvider; use Alchemy\Phrasea\Exception\InvalidArgumentException; @@ -241,6 +243,7 @@ class Application extends SilexApplication $this->register(new InstallerServiceProvider()); $this->register(new PhraseanetServiceProvider()); $this->register(new PhraseaVersionServiceProvider()); + $this->register(new PluginServiceProvider()); $this->register(new PHPExiftoolServiceProvider()); $this->register(new ReCaptchaServiceProvider()); @@ -262,6 +265,7 @@ class Application extends SilexApplication $this->register(new ServiceControllerServiceProvider()); $this->register(new SwiftmailerServiceProvider()); $this->register(new TaskManagerServiceProvider()); + $this->register(new TemporaryFilesystemServiceProvider()); $this->register(new TokensServiceProvider()); $this->register(new TwigServiceProvider(), array( 'twig.options' => array( @@ -397,6 +401,10 @@ class Application extends SilexApplication return $data[1]; }; + + call_user_func(function ($app) { + require $app['plugins.directory'] . '/services.php'; + }, $this); } /** diff --git a/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php b/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php new file mode 100644 index 0000000000..e241c92d98 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Plugin/AbstractPluginCommand.php @@ -0,0 +1,41 @@ +write("Validating plugins..."); + foreach ($this->container['plugins.explorer'] as $directory) { + $manifests[] = $manifest = $this->container['plugins.plugins-validator']->validatePlugin($directory); + } + $output->writeln(" OK"); + + return $manifests; + } + + protected function updateConfigFiles(InputInterface $input, OutputInterface $output) + { + $manifests = $this->validatePlugins($input, $output); + + $output->write("Updating config files..."); + $this->container['plugins.autoloader-generator']->write($manifests); + $output->writeln(" OK"); + } +} diff --git a/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php b/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php new file mode 100644 index 0000000000..ceaa403933 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Plugin/AddPlugin.php @@ -0,0 +1,61 @@ +setDescription('Installs a plugin to Phraseanet') + ->addArgument('source', InputArgument::REQUIRED, 'The source is a folder'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $source = $input->getArgument('source'); + + $temporaryDir = $this->container['temporary-filesystem']->createTemporaryDirectory(); + + $output->write("Importing $source..."); + $this->container['plugins.importer']->import($source, $temporaryDir); + $output->writeln(" OK"); + + $output->write("Validating plugin..."); + $manifest = $this->container['plugins.plugins-validator']->validatePlugin($temporaryDir); + $output->writeln(" OK found ".$manifest->getName().""); + + $targetDir = $this->container['plugins.directory'] . DIRECTORY_SEPARATOR . $manifest->getName(); + + $output->write("Setting up composer..."); + $this->container['plugins.composer-installer']->install($temporaryDir); + $output->writeln(" OK"); + + $output->write("Installing plugin ".$manifest->getName()."..."); + $this->container['filesystem']->mirror($temporaryDir, $targetDir); + $output->writeln(" OK"); + + $output->write("Removing temporary directory..."); + $this->container['filesystem']->remove($temporaryDir); + $output->writeln(" OK"); + + $this->updateConfigFiles($input, $output); + + return 0; + } +} diff --git a/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php b/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php new file mode 100644 index 0000000000..8d26f5e22d --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Plugin/RemovePlugin.php @@ -0,0 +1,43 @@ +setDescription('Removes a plugin given its name') + ->addArgument('name', InputArgument::REQUIRED, 'The name of the plugin'); + } + + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $name = $input->getArgument('name'); + + $path = $this->container['plugins.directory'] . DIRECTORY_SEPARATOR . $name; + + $output->write("Removing $name..."); + $this->container['filesystem']->remove($path); + $output->writeln(" OK"); + + $this->updateConfigFiles($input, $output); + + return 0; + } +} diff --git a/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php new file mode 100644 index 0000000000..9c77ef5729 --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Provider/PluginServiceProvider.php @@ -0,0 +1,86 @@ +share(function (Application $app) { + return new JsonValidator(); + }); + + $app['plugins.manifest-validator'] = $app->share(function (Application $app) { + return ManifestValidator::create($app); + }); + + $app['plugins.plugins-validator'] = $app->share(function (Application $app) { + return new PluginValidator($app['plugins.manifest-validator']); + }); + + $app['plugins.import-strategy'] = $app->share(function (Application $app) { + return new ImportStrategy(); + }); + + $app['plugins.autoloader-generator'] = $app->share(function (Application $app) { + return new AutoloaderGenerator($app['plugins.directory']); + }); + + $app['plugins.guzzle'] = $app->share(function (Application $app) { + return new Guzzle(); + }); + + $app['plugins.composer-installer'] = $app->share(function (Application $app) { + $phpBinary = $app['phraseanet.registry']->get('php_binary'); + if (!is_executable($phpBinary)) { + $finder = new ExecutableFinder(); + $phpBinary = $finder->find('php'); + } + + return new ComposerInstaller($app['plugins.directory'], $app['plugins.guzzle'], $phpBinary); + }); + $app['plugins.explorer'] = $app->share(function (Application $app) { + return new PluginsExplorer($app['plugins.directory']); + }); + + $app['plugins.importer'] = $app->share(function (Application $app) { + return new Importer($app['plugins.import-strategy'], array( + 'plugins.importer.folder-importer' => $app['plugins.importer.folder-importer'], + )); + }); + + $app['plugins.importer.folder-importer'] = $app->share(function (Application $app) { + return new FolderImporter($app['filesystem']); + }); + } + + public function boot(Application $app) + { + } +} diff --git a/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php new file mode 100644 index 0000000000..33ebf9d38a --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Provider/TemporaryFilesystemServiceProvider.php @@ -0,0 +1,30 @@ +share(function (Application $app) { + return new TemporaryFilesystem($app['filesystem']); + }); + } + + public function boot(Application $app) + { + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php b/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php new file mode 100644 index 0000000000..a4606f63a5 --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Exception/ComposerInstallException.php @@ -0,0 +1,18 @@ +errors = $errors; + parent::__construct($message); + } + + public function getErrors() + { + return $this->errors; + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php b/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php new file mode 100644 index 0000000000..b00aff0543 --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Exception/PluginValidationException.php @@ -0,0 +1,18 @@ +fs = $fs; + } + + /** + * {@inheritdoc} + */ + public function import($source, $target) + { + try { + $this->fs->mirror($source, $target); + } catch (FsException $e) { + try { + $this->fs->remove($target); + } catch (FsException $e) { + + } + + throw new ImportFailureException(sprintf('Unable to import from `%s` to `%s`', $source, $target), $e->getCode(), $e); + } + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php b/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php new file mode 100644 index 0000000000..3c58814645 --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Importer/ImportStrategy.php @@ -0,0 +1,27 @@ +importers = $importers; + $this->strategy = $strategy; + } + + /** + * + * @param string $source + * @param string $target + * + * @throws ImportFailureException + */ + public function import($source, $target) + { + $strategy = $this->strategy->detect($source); + + if (!isset($this->importers[$strategy])) { + throw new ImportFailureException(sprintf('Unable to get an import for source `%s`', $source)); + } + + $this->importers[$strategy]->import($source, $target); + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php b/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php new file mode 100644 index 0000000000..1a87e708ae --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Importer/ImporterInterface.php @@ -0,0 +1,25 @@ +pluginDirectory = $pluginDirectory; + } + + public function write($manifests) + { + $this + ->doWrite('autoload.php', $this->createLoader($manifests)) + ->doWrite('services.php', $this->createServices($manifests)); + + return $this; + } + + private function doWrite($file, $data) + { + if (false === file_put_contents($this->pluginDirectory . DIRECTORY_SEPARATOR . $file, $data)) { + throw new RegistrationFailureException(sprintf('Failed to write %s', $file)); + } + + return $this; + } + + private function createLoader($manifests) + { + $buffer = <<getName() . DIRECTORY_SEPARATOR . "vendor" . DIRECTORY_SEPARATOR . "autoload.php"; + $buffer .= <<getServices() as $service) { + $class = $service['class']; + $buffer .= <<register($class::create(\$app)); +EOF; + } + } + + $buffer .= <<guzzle = $guzzle; + $this->pluginsDirectory = $pluginsDirectory; + $this->phpExecutable = $phpExecutable; + $this->composer = $this->pluginsDirectory . DIRECTORY_SEPARATOR . 'composer.phar'; + } + + public function install($directory) + { + $process = $this->createProcessBuilder() + ->add('install') + ->add('--working-dir') + ->add($directory) + ->add('--no-dev') + ->add('--optimize-autoloader') + ->getProcess(); + + try { + $process->run(); + } catch (ProcessException $e) { + throw new ComposerInstallException(sprintf('Unable to composer install %s', $directory), $e->getCode(), $e); + } + + if (!$process->isSuccessful()) { + throw new ComposerInstallException(sprintf('Unable to composer install %s', $directory)); + } + } + + /** + * @return ProcessBuilder + */ + private function createProcessBuilder() + { + if (!file_exists($this->composer)) { + $this->installComposer(); + } else { + $process = ProcessBuilder::create(array( + $this->phpExecutable, $this->composer, 'self-update' + ))->getProcess(); + $process->run(); + } + + return ProcessBuilder::create(array($this->phpExecutable, $this->composer)); + } + + private function installComposer() + { + $installer = $this->pluginsDirectory . DIRECTORY_SEPARATOR . 'installer'; + $handle = fopen($installer, 'w+'); + + $request = $this->guzzle->get('https://getcomposer.org/installer', null, $handle); + + try { + $response = $request->send(); + fclose($handle); + } catch (GuzzleException $e) { + fclose($handle); + throw new ComposerInstallException('Unable to download composer install script.'); + } + + if (200 !== $response->getStatusCode()) { + @unlink($installer); + throw new ComposerInstallException('Unable to download composer install script.'); + } + + $dir = getcwd(); + if (!@chdir($this->pluginsDirectory)) { + throw new ComposerInstallException('Unable to move to plugins directory for composer install.'); + } + + $process = ProcessBuilder::create(array($this->phpExecutable, $installer))->getProcess(); + + try { + $process->run(); + @unlink($installer); + } catch (ProcessException $e) { + @unlink($installer); + throw new ComposerInstallException('Unable run composer install script.'); + } + + if (!@chdir($dir)) { + throw new ComposerInstallException('Unable to move to plugins directory for composer install.'); + } + + if (!$process->isSuccessful()) { + throw new ComposerInstallException('Composer install failed.'); + } + + if (!file_exists($this->composer)) { + throw new ComposerInstallException('Composer install failed.'); + } + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php b/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php new file mode 100644 index 0000000000..ba8d5296ab --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Management/PluginsExplorer.php @@ -0,0 +1,47 @@ +pluginsDirectory = $pluginsDirectory; + } + + public function getIterator() + { + return $this->getFinder()->getIterator(); + } + + public function count() + { + return $this->getFinder()->count(); + } + + private function getFinder() + { + $finder = Finder::create(); + + return $finder + ->ignoreDotFiles(true) + ->ignoreVCS(true) + ->useBestAdapter() + ->directories() + ->in($this->pluginsDirectory) + ->depth(0); + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php b/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php new file mode 100644 index 0000000000..b7dde714fb --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/PluginProviderInterface.php @@ -0,0 +1,29 @@ +data = $data; + } + + public function getName() + { + return $this->get('name'); + } + + public function getDescription() + { + return $this->get('description'); + } + + public function getKeywords() + { + return $this->get('keywords'); + } + + public function getAuthors() + { + return $this->get('authors'); + } + + public function getHomepage() + { + return $this->get('homepage'); + } + + public function getLicense() + { + return $this->get('license'); + } + + public function getVersion() + { + return $this->get('version'); + } + + public function getMinimumPhraseanetVersion() + { + return $this->get('minimum-phraseanet-version'); + } + + public function getMaximumPhraseanetVersion() + { + return $this->get('maximum-phraseanet-version'); + } + + public function getServices() + { + return $this->get('services'); + } + + public function getExtra() + { + return $this->get('extra'); + } + + private function get($key) + { + return isset($this->data[$key]) ? $this->data[$key] : null; + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php b/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php new file mode 100644 index 0000000000..ec60088ea5 --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Schema/ManifestValidator.php @@ -0,0 +1,89 @@ +validator = $validator; + $this->version = $version; + $this->schemaData = $schemaData; + } + + public function validate($data) + { + if (!is_object($data)) { + throw new InvalidArgumentException('Json Schema must be an object'); + } + + $this->validator->reset(); + $this->validator->check($data, $this->schemaData); + + if (!$this->validator->isValid()) { + $errors = array(); + foreach ((array) $this->validator->getErrors() as $error) { + $errors[] = ($error['property'] ? $error['property'].' : ' : '').$error['message']; + } + throw new JsonValidationException('Manifest file does not match the expected JSON schema', $errors); + } + + if (!preg_match('/^[a-z0-9-_]+$/i', $data->name)) { + throw new JsonValidationException('Does not match the expected JSON schema', array('"name" must not contains only alphanumeric caracters')); + } + + if (isset($data->{'minimum-phraseanet-version'})) { + if (true !== version_compare($this->version->getNumber(), $data->{'minimum-phraseanet-version'}, '>=')) { + throw new JsonValidationException(sprintf( + 'Version incomptibility : Minimum Phraseanet version required is %s, current version is %s', + $data->{'minimum-phraseanet-version'}, + $this->version->getNumber() + )); + } + } + + if (isset($data->{'maximum-phraseanet-version'})) { + if (true !== version_compare($this->version->getNumber(), $data->{'maximum-phraseanet-version'}, '<')) { + throw new JsonValidationException(sprintf( + 'Version incomptibility : Maximum Phraseanet version required is %s, current version is %s', + $data->{'maximum-phraseanet-version'}, + $this->version->getNumber() + )); + } + } + } + + public static function create(Application $app) + { + $data = @json_decode(@file_get_contents($app['plugins.schema'])); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new InvalidArgumentException(sprintf('Unable to read %s', $app['plugins.schema'])); + } + + return new static($app['plugins.json-validator'], $data, $app['phraseanet.version']); + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php b/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php new file mode 100644 index 0000000000..0b0cadbdde --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/Schema/PluginValidator.php @@ -0,0 +1,80 @@ +manifestValidator = $manifestValidator; + } + + public function validatePlugin($directory) + { + $this->ensureComposer($directory); + $this->ensureManifest($directory); + + $manifest = $directory . DIRECTORY_SEPARATOR . 'manifest.json'; + $data = @json_decode(@file_get_contents($manifest)); + + if (JSON_ERROR_NONE !== json_last_error()) { + throw new PluginValidationException(sprintf('Unable to parse file %s', $manifest)); + } + + try { + $this->manifestValidator->validate($data); + } catch (JsonValidationException $e) { + throw new PluginValidationException('Manifest file is invalid', $e->getCode(), $e); + } + + return new Manifest($this->objectToArray($data)); + } + + private function ensureManifest($directory) + { + $manifest = $directory . DIRECTORY_SEPARATOR . 'manifest.json'; + $this->ensureFile($manifest); + } + + private function ensureComposer($directory) + { + $composer = $directory . DIRECTORY_SEPARATOR . 'composer.json'; + $this->ensureFile($composer); + } + + private function ensureFile($file) + { + if (!file_exists($file) || !is_file($file) || !is_readable($file)) { + throw new PluginValidationException(sprintf('Required file %s is not present.', $file)); + } + } + + private function objectToArray($data) + { + if (is_object($data)) { + $data = get_object_vars($data); + } + + if (is_array($data)) { + return array_map(array($this, 'objectToArray'), $data); + } + + return $data; + } +} diff --git a/lib/Alchemy/Phrasea/Plugin/resources/example.json b/lib/Alchemy/Phrasea/Plugin/resources/example.json new file mode 100644 index 0000000000..5f0116601e --- /dev/null +++ b/lib/Alchemy/Phrasea/Plugin/resources/example.json @@ -0,0 +1,22 @@ +{ + "name": "Class Connector", + "description" : "A custom class connector", + "keywords" : ["connector"], + "authors" : [ + { + "name" : "Author name", + "homepage" : "http://example.com", + "email" : "email@example.com" + } + ], + "homepage" : "http://example.com/project/example", + "license" : "MIT", + "version" : "0.1", + "minimum-phraseanet-version": "3.8", + "maximum-phraseanet-version": "3.9", + "services" : [ + { + "class": "Vendor\\Connector\\PluginServiceInterface" + } + ] +} diff --git a/lib/autoload.php b/lib/autoload.php new file mode 100644 index 0000000000..b62f6f9c5c --- /dev/null +++ b/lib/autoload.php @@ -0,0 +1,3 @@ +getMock('Symfony\Component\Console\Input\InputInterface'); + $input->expects($this->once()) + ->method('getArgument') + ->with($this->equalTo('source')) + ->will($this->returnValue($source)); + + $output = $this->getMock('Symfony\Component\Console\Output\OutputInterface'); + + $command = new AddPlugin(); + $command->setContainer(self::$DI['app']); + + $manifest = $this->createManifestMock(); + $manifest->expects($this->any()) + ->method('getName') + ->will($this->returnValue($source)); + + self::$DI['app']['temporary-filesystem'] = $this->createTemporaryFilesystemMock(); + self::$DI['app']['plugins.autoloader-generator'] = $this->createPluginsAutoloaderGeneratorMock(); + self::$DI['app']['plugins.explorer'] = array(self::$DI['app']['plugins.directory'].'/TestPlugin'); + self::$DI['app']['plugins.plugins-validator'] = $this->createPluginsValidatorMock(); + self::$DI['app']['filesystem'] = $this->createFilesystemMock(); + self::$DI['app']['plugins.composer-installer'] = $this->createComposerInstallerMock(); + self::$DI['app']['plugins.importer'] = $this->createPluginsImporterMock(); + + self::$DI['app']['temporary-filesystem']->expects($this->once()) + ->method('createTemporaryDirectory') + ->will($this->returnValue('tempdir')); + + self::$DI['app']['plugins.importer']->expects($this->once()) + ->method('import') + ->with($source, 'tempdir'); + + // the plugin is checked when updating config files + self::$DI['app']['plugins.plugins-validator']->expects($this->at(0)) + ->method('validatePlugin') + ->with('tempdir') + ->will($this->returnValue($manifest)); + + self::$DI['app']['plugins.plugins-validator']->expects($this->at(1)) + ->method('validatePlugin') + ->with(self::$DI['app']['plugins.directory'].'/TestPlugin') + ->will($this->returnValue($manifest)); + + self::$DI['app']['plugins.composer-installer']->expects($this->once()) + ->method('install') + ->with('tempdir'); + + self::$DI['app']['filesystem']->expects($this->at(0)) + ->method('mirror') + ->with('tempdir', self::$DI['app']['plugins.directory'].'/TestPlugin'); + + self::$DI['app']['filesystem']->expects($this->at(1)) + ->method('remove') + ->with('tempdir'); + + self::$DI['app']['plugins.autoloader-generator']->expects($this->once()) + ->method('write') + ->with(array($manifest)); + + $result = $command->execute($input, $output); + + $this->assertSame(0, $result); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php b/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php new file mode 100644 index 0000000000..d0affce512 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Command/Plugin/PluginCommandTestCase.php @@ -0,0 +1,62 @@ +getMockBuilder('Neutron\TemporaryFilesystem\TemporaryFilesystem') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createPluginsImporterMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Importer\Importer') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createPluginsValidatorMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Schema\PluginValidator') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createManifestMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Schema\Manifest') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createComposerInstallerMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\ComposerInstaller') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createFilesystemMock() + { + return $this->getMockBuilder('Symfony\Component\Filesystem\Filesystem') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createPluginsExplorerMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\PluginsExplorer') + ->disableOriginalConstructor() + ->getMock(); + } + + protected function createPluginsAutoloaderGeneratorMock() + { + return $this->getMockBuilder('Alchemy\Phrasea\Plugin\Management\AutoloaderGenerator') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php b/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php new file mode 100644 index 0000000000..bf81a8ec74 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Command/Plugin/RemovePluginTest.php @@ -0,0 +1,33 @@ +getMock('Symfony\Component\Console\Input\InputInterface'); + $input->expects($this->once()) + ->method('getArgument') + ->with($this->equalTo('name')) + ->will($this->returnValue($name)); + + $output = $this->getMock('Symfony\Component\Console\Output\OutputInterface'); + + $command = new RemovePlugin(); + $command->setContainer(self::$DI['app']); + + self::$DI['app']['filesystem'] = $this->createFilesystemMock(); + self::$DI['app']['filesystem']->expects($this->once()) + ->method('remove') + ->with(self::$DI['app']['plugins.directory'].'/'.$name); + + $result = $command->execute($input, $output); + + $this->assertSame(0, $result); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php new file mode 100644 index 0000000000..df94de41b7 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/PluginServiceProviderTest.php @@ -0,0 +1,129 @@ +register(new PluginServiceProvider()); + + $this->assertFileExists($app['plugins.schema']); + $this->assertTrue(is_file($app['plugins.schema'])); + } + + public function testPluginDirIsDefined() + { + $app = new Application(); + $app->register(new PluginServiceProvider()); + + $this->assertFileExists($app['plugins.directory']); + $this->assertTrue(is_dir($app['plugins.directory'])); + } + + public function testInstallerUsesPhpConf() + { + $finder = new ExecutableFinder(); + $php = $finder->find('php'); + + if (null === $php) { + $this->markTestSkipped('Unable to detect PHP binary'); + } + + $app = new Application(); + $app['phraseanet.registry'] = $this->createRegistryMock(); + $app['phraseanet.registry']->expects($this->once()) + ->method('get') + ->with('php_binary') + ->will($this->returnValue($php)); + + $app->register(new PluginServiceProvider()); + + $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Management\ComposerInstaller', $app['plugins.composer-installer']); + } + + public function testInstallerCanDetectPhpConf() + { + $app = new Application(); + $app['phraseanet.registry'] = $this->createRegistryMock(); + $app['phraseanet.registry']->expects($this->once()) + ->method('get') + ->with('php_binary') + ->will($this->returnValue(null)); + + $app->register(new PluginServiceProvider()); + + $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Management\ComposerInstaller', $app['plugins.composer-installer']); + } + + private function createRegistryMock() + { + return $this->getMockBuilder('registry') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php new file mode 100644 index 0000000000..89b39e1c47 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/TemporaryFilesystemServiceProviderTest.php @@ -0,0 +1,16 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0 class loader + * + * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + */ +class ClassLoader +{ + private $prefixes = array(); + private $fallbackDirs = array(); + private $useIncludePath = false; + private $classMap = array(); + + public function getPrefixes() + { + return call_user_func_array('array_merge', $this->prefixes); + } + + public function getFallbackDirs() + { + return $this->fallbackDirs; + } + + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of classes, merging with any others previously set. + * + * @param string $prefix The classes prefix + * @param array|string $paths The location(s) of the classes + * @param bool $prepend Prepend the location(s) + */ + public function add($prefix, $paths, $prepend = false) + { + if (!$prefix) { + if ($prepend) { + $this->fallbackDirs = array_merge( + (array) $paths, + $this->fallbackDirs + ); + } else { + $this->fallbackDirs = array_merge( + $this->fallbackDirs, + (array) $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixes[$first][$prefix])) { + $this->prefixes[$first][$prefix] = (array) $paths; + + return; + } + if ($prepend) { + $this->prefixes[$first][$prefix] = array_merge( + (array) $paths, + $this->prefixes[$first][$prefix] + ); + } else { + $this->prefixes[$first][$prefix] = array_merge( + $this->prefixes[$first][$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of classes, replacing any others previously set. + * + * @param string $prefix The classes prefix + * @param array|string $paths The location(s) of the classes + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirs = (array) $paths; + + return; + } + $this->prefixes[substr($prefix, 0, 1)][$prefix] = (array) $paths; + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + } + + /** + * Unregisters this instance as an autoloader. + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return bool|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + include $file; + + return true; + } + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // work around for PHP 5.3.0 - 5.3.2 https://bugs.php.net/50731 + if ('\\' == $class[0]) { + $class = substr($class, 1); + } + + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $classPath = strtr(substr($class, 0, $pos), '\\', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $className = substr($class, $pos + 1); + } else { + // PEAR-like class name + $classPath = null; + $className = $class; + } + + $classPath .= strtr($className, '_', DIRECTORY_SEPARATOR) . '.php'; + + $first = $class[0]; + if (isset($this->prefixes[$first])) { + foreach ($this->prefixes[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) { + return $dir . DIRECTORY_SEPARATOR . $classPath; + } + } + } + } + } + + foreach ($this->fallbackDirs as $dir) { + if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) { + return $dir . DIRECTORY_SEPARATOR . $classPath; + } + } + + if ($this->useIncludePath && $file = stream_resolve_include_path($classPath)) { + return $file; + } + + return $this->classMap[$class] = false; + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000000..d3ef7827a3 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_classmap.php @@ -0,0 +1,10 @@ + $baseDir . '/src/Vendor/PluginService.php', +); diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php new file mode 100644 index 0000000000..43ccccf9a9 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_namespaces.php @@ -0,0 +1,10 @@ + array($baseDir . '/src'), +); diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php new file mode 100644 index 0000000000..10cf2abce2 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/PluginDirInstalled/TestPlugin/vendor/composer/autoload_real.php @@ -0,0 +1,43 @@ + $path) { + $loader->set($namespace, $path); + } + + $classMap = require __DIR__ . '/autoload_classmap.php'; + if ($classMap) { + $loader->addClassMap($classMap); + } + + $loader->register(true); + + return $loader; + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json new file mode 100644 index 0000000000..9e71751641 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/composer.json @@ -0,0 +1,10 @@ +{ + "name" : "test/test", + "description" : "test file", + "license" : "MIT", + "autoload": { + "psr-0": { + "Vendor" : "src" + } + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json new file mode 100644 index 0000000000..b4b5f747ca --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "Test Plugin", + "description" : "A custom class connector", +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php new file mode 100644 index 0000000000..19b9dfd44a --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/WrongPlugins/TestPluginInvalidManifest/src/Vendor/PluginService.php @@ -0,0 +1,24 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Autoload; + +/** + * ClassLoader implements a PSR-0 class loader + * + * See https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-0.md + * + * $loader = new \Composer\Autoload\ClassLoader(); + * + * // register classes with namespaces + * $loader->add('Symfony\Component', __DIR__.'/component'); + * $loader->add('Symfony', __DIR__.'/framework'); + * + * // activate the autoloader + * $loader->register(); + * + * // to enable searching the include path (eg. for PEAR packages) + * $loader->setUseIncludePath(true); + * + * In this example, if you try to use a class in the Symfony\Component + * namespace or one of its children (Symfony\Component\Console for instance), + * the autoloader will first look for the class under the component/ + * directory, and it will then fallback to the framework/ directory if not + * found before giving up. + * + * This class is loosely based on the Symfony UniversalClassLoader. + * + * @author Fabien Potencier + * @author Jordi Boggiano + */ +class ClassLoader +{ + private $prefixes = array(); + private $fallbackDirs = array(); + private $useIncludePath = false; + private $classMap = array(); + + public function getPrefixes() + { + return call_user_func_array('array_merge', $this->prefixes); + } + + public function getFallbackDirs() + { + return $this->fallbackDirs; + } + + public function getClassMap() + { + return $this->classMap; + } + + /** + * @param array $classMap Class to filename map + */ + public function addClassMap(array $classMap) + { + if ($this->classMap) { + $this->classMap = array_merge($this->classMap, $classMap); + } else { + $this->classMap = $classMap; + } + } + + /** + * Registers a set of classes, merging with any others previously set. + * + * @param string $prefix The classes prefix + * @param array|string $paths The location(s) of the classes + * @param bool $prepend Prepend the location(s) + */ + public function add($prefix, $paths, $prepend = false) + { + if (!$prefix) { + if ($prepend) { + $this->fallbackDirs = array_merge( + (array) $paths, + $this->fallbackDirs + ); + } else { + $this->fallbackDirs = array_merge( + $this->fallbackDirs, + (array) $paths + ); + } + + return; + } + + $first = $prefix[0]; + if (!isset($this->prefixes[$first][$prefix])) { + $this->prefixes[$first][$prefix] = (array) $paths; + + return; + } + if ($prepend) { + $this->prefixes[$first][$prefix] = array_merge( + (array) $paths, + $this->prefixes[$first][$prefix] + ); + } else { + $this->prefixes[$first][$prefix] = array_merge( + $this->prefixes[$first][$prefix], + (array) $paths + ); + } + } + + /** + * Registers a set of classes, replacing any others previously set. + * + * @param string $prefix The classes prefix + * @param array|string $paths The location(s) of the classes + */ + public function set($prefix, $paths) + { + if (!$prefix) { + $this->fallbackDirs = (array) $paths; + + return; + } + $this->prefixes[substr($prefix, 0, 1)][$prefix] = (array) $paths; + } + + /** + * Turns on searching the include path for class files. + * + * @param bool $useIncludePath + */ + public function setUseIncludePath($useIncludePath) + { + $this->useIncludePath = $useIncludePath; + } + + /** + * Can be used to check if the autoloader uses the include path to check + * for classes. + * + * @return bool + */ + public function getUseIncludePath() + { + return $this->useIncludePath; + } + + /** + * Registers this instance as an autoloader. + * + * @param bool $prepend Whether to prepend the autoloader or not + */ + public function register($prepend = false) + { + spl_autoload_register(array($this, 'loadClass'), true, $prepend); + } + + /** + * Unregisters this instance as an autoloader. + */ + public function unregister() + { + spl_autoload_unregister(array($this, 'loadClass')); + } + + /** + * Loads the given class or interface. + * + * @param string $class The name of the class + * @return bool|null True if loaded, null otherwise + */ + public function loadClass($class) + { + if ($file = $this->findFile($class)) { + include $file; + + return true; + } + } + + /** + * Finds the path to the file where the class is defined. + * + * @param string $class The name of the class + * + * @return string|false The path if found, false otherwise + */ + public function findFile($class) + { + // work around for PHP 5.3.0 - 5.3.2 https://bugs.php.net/50731 + if ('\\' == $class[0]) { + $class = substr($class, 1); + } + + if (isset($this->classMap[$class])) { + return $this->classMap[$class]; + } + + if (false !== $pos = strrpos($class, '\\')) { + // namespaced class name + $classPath = strtr(substr($class, 0, $pos), '\\', DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $className = substr($class, $pos + 1); + } else { + // PEAR-like class name + $classPath = null; + $className = $class; + } + + $classPath .= strtr($className, '_', DIRECTORY_SEPARATOR) . '.php'; + + $first = $class[0]; + if (isset($this->prefixes[$first])) { + foreach ($this->prefixes[$first] as $prefix => $dirs) { + if (0 === strpos($class, $prefix)) { + foreach ($dirs as $dir) { + if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) { + return $dir . DIRECTORY_SEPARATOR . $classPath; + } + } + } + } + } + + foreach ($this->fallbackDirs as $dir) { + if (file_exists($dir . DIRECTORY_SEPARATOR . $classPath)) { + return $dir . DIRECTORY_SEPARATOR . $classPath; + } + } + + if ($this->useIncludePath && $file = stream_resolve_include_path($classPath)) { + return $file; + } + + return $this->classMap[$class] = false; + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php new file mode 100644 index 0000000000..af4ad5805a --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Fixtures/vendor/composer/autoload_classmap.php @@ -0,0 +1,9 @@ + $path) { + $loader->set($namespace, $path); + } + + $classMap = require __DIR__ . '/autoload_classmap.php'; + if ($classMap) { + $loader->addClassMap($classMap); + } + + $loader->register(true); + + return $loader; + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php new file mode 100644 index 0000000000..5e04521a26 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/FolderImporterTest.php @@ -0,0 +1,44 @@ +createFilesystemMock(); + + $source = 'test-plugin'; + $target = __DIR__; + + $fs->expects($this->once()) + ->method('mirror') + ->with($source, $target); + + $importer = new FolderImporter($fs); + $importer->import($source, $target); + } + + /** + * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException + */ + public function testImportFailed() + { + $fs = $this->createFilesystemMock(); + + $source = 'test-plugin'; + $target = __DIR__; + + $fs->expects($this->once()) + ->method('mirror') + ->with($source, $target) + ->will($this->throwException(new IOException('Error'))); + + $importer = new FolderImporter($fs); + $importer->import($source, $target); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php new file mode 100644 index 0000000000..c394bb0cca --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImportStrategyTest.php @@ -0,0 +1,44 @@ +assertEquals('plugins.importer.folder-importer', $importer->detect($source)); + } + + /** + * @dataProvider provideInvalidFolderSources + * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException + */ + public function testDetectFailure($source) + { + $importer = new ImportStrategy(); + $importer->detect($source); + } + + public function provideFolderSources() + { + return array( + array(__DIR__), + array(dirname(__DIR__)), + ); + } + + public function provideInvalidFolderSources() + { + return array( + array('/path/to/invalid/dir'), + array(__FILE__), + ); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php new file mode 100644 index 0000000000..aa0bbd9f0e --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Importer/ImporterTest.php @@ -0,0 +1,51 @@ +getMock('Alchemy\Phrasea\Plugin\Importer\ImportStrategy'); + $strategy->expects($this->once()) + ->method('detect') + ->with($source) + ->will($this->returnValue('elephant')); + + $importerInterface = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImporterInterface'); + $importerInterface->expects($this->once()) + ->method('import') + ->with($source, $target); + + $importer = new Importer($strategy, array('elephant' => $importerInterface)); + $importer->import($source, $target); + } + + /** + * @expectedException Alchemy\Phrasea\Plugin\Exception\ImportFailureException + */ + public function testImportFailure() + { + $source = 'here'; + $target = 'there'; + + $strategy = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImportStrategy'); + $strategy->expects($this->once()) + ->method('detect') + ->with($source) + ->will($this->returnValue('elephant')); + + $importerInterface = $this->getMock('Alchemy\Phrasea\Plugin\Importer\ImporterInterface'); + $importerInterface->expects($this->never()) + ->method('import'); + + $importer = new Importer($strategy, array('rhinoceros' => $importerInterface)); + $importer->import($source, $target); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php new file mode 100644 index 0000000000..267cba71dd --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/AutoloaderGeneratorTest.php @@ -0,0 +1,63 @@ +cleanup($files); + + $generator = new AutoloaderGenerator($pluginsDir); + $generator->write(array(new Manifest(json_decode(file_get_contents($pluginDir . '/manifest.json'), true)))); + + $finder = new ExecutableFinder(); + $php = $finder->find('php'); + + if (null === $php) { + $this->markTestSkipped('Php executable not found.'); + } + + foreach ($files as $file ) { + $this->assertFileExists($file); + $process = ProcessBuilder::create(array($php, '-l', $file))->getProcess(); + $process->run(); + $this->assertTrue($process->isSuccessful(), basename($file) . ' is valid'); + } + + // test autoload + $this->assertFalse(class_exists('Vendor\PluginService')); + $loader = require $pluginsDir . '/autoload.php'; + $this->assertInstanceOf('Composer\Autoload\ClassLoader', $loader); + $this->assertTrue(class_exists('Vendor\PluginService')); + + // load services + $app = new Application(); + $retrievedApp = require $pluginsDir . '/services.php'; + + $this->assertSame($app, $retrievedApp); + $this->assertEquals('hello world', $app['plugin-test']); + + $this->cleanup($files); + } + + private function cleanup($files) + { + foreach ($files as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php new file mode 100644 index 0000000000..abe7bd7671 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/ComposerInstallerTest.php @@ -0,0 +1,40 @@ +find('php'); + + $vendorDir = __DIR__ . '/../Fixtures/PluginDir/TestPlugin/vendor'; + $installFile = __DIR__ . '/installer'; + $composer = __DIR__ . '/composer.phar'; + + $fs->remove(array($composer, $installFile, $vendorDir)); + + if (null === $php) { + $this->markTestSkipped('Unable to find PHP executable.'); + } + + $installer = new ComposerInstaller(__DIR__, new Guzzle(), $php); + $installer->install(__DIR__ . '/../Fixtures/PluginDir/TestPlugin'); + + $this->assertFileExists($composer); + unlink($composer); + + $this->assertFileNotExists($installFile); + $this->assertFileExists($vendorDir); + + $fs->remove($vendorDir); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php new file mode 100644 index 0000000000..f5565c9d23 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Management/PluginsExplorerTest.php @@ -0,0 +1,29 @@ +assertCount(1, $explorer); + } + + public function testGetIterator() + { + $explorer = new PluginsExplorer(__DIR__ . '/../Fixtures/PluginDir'); + + $dirs = array(); + + foreach ($explorer as $dir) { + $dirs[] = (string) realpath($dir); + } + + $this->assertSame(array(realpath(__DIR__ . '/../Fixtures/PluginDir/TestPlugin')), $dirs); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php b/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php new file mode 100644 index 0000000000..e1cd027f17 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/PluginTestCase.php @@ -0,0 +1,33 @@ +getMock('Symfony\Component\Filesystem\Filesystem'); + } + + protected function getSchema() + { + return file_get_contents($this->getSchemaPath()); + } + + protected function getSchemaPath() + { + return __DIR__ . '/../../../../../lib/conf.d/plugin-schema.json'; + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php new file mode 100644 index 0000000000..07bdc72495 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestTest.php @@ -0,0 +1,30 @@ +assertEquals('TestPlugin', $manifest->getName()); + $this->assertEquals('A custom class connector', $manifest->getDescription()); + $this->assertEquals(array('connector'), $manifest->getKeywords()); + $this->assertEquals(array(array( + 'name' => 'Author name', + 'homepage' => 'http://example.com', + 'email' => 'email@example.com', + )), $manifest->getAuthors()); + $this->assertEquals('http://example.com/project/example', $manifest->getHomepage()); + $this->assertEquals('MIT', $manifest->getLicense()); + $this->assertEquals('0.1', $manifest->getVersion()); + $this->assertEquals('3.8', $manifest->getMinimumPhraseanetVersion()); + $this->assertEquals('3.9', $manifest->getMaximumPhraseanetVersion()); + $this->assertEquals(array(array('class' => 'Vendor\PluginService')), $manifest->getServices()); + $this->assertEquals(array('property' => 'value'), $manifest->getExtra()); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php new file mode 100644 index 0000000000..5fb5a1bc99 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/ManifestValidatorTest.php @@ -0,0 +1,80 @@ +createValidator(); + $validator->validate(json_decode(file_get_contents($file))); + } + + public function provideGoodManifestFiles() + { + return array( + array(__DIR__ . '/../Fixtures/manifest-good-big.json'), + array(__DIR__ . '/../Fixtures/manifest-good-minimal.json'), + ); + } + + /** + * @expectedException Alchemy\Phrasea\Plugin\Exception\JsonValidationException + * @dataProvider provideWrongManifestFiles + */ + public function testValidateWrongOnes($file) + { + $validator = $this->createValidator(); + $validator->validate(json_decode(file_get_contents($file))); + } + + public function provideWrongManifestFiles() + { + return array( + array(__DIR__ . '/../Fixtures/manifest-wrong1.json'), + array(__DIR__ . '/../Fixtures/manifest-wrong2.json'), + array(__DIR__ . '/../Fixtures/manifest-wrong3.json'), + array(__DIR__ . '/../Fixtures/manifest-wrong4.json'), + array(__DIR__ . '/../Fixtures/manifest-wrong5-min-version.json'), + array(__DIR__ . '/../Fixtures/manifest-wrong6-max-version.json'), + ); + } + + /** + * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException + */ + public function testValidateInvalidData() + { + $validator = $this->createValidator(); + $validator->validate(array()); + } + + /** + * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException + */ + public function testConstructWithInvalidSchema() + { + new ManifestValidator(new JsonSchemaValidator(), array(), self::$DI['app']['phraseanet.version']); + } + + public function testCreate() + { + $validator = ManifestValidator::create(self::$DI['app']); + + $this->assertInstanceOf('Alchemy\Phrasea\Plugin\Schema\ManifestValidator', $validator); + } + + private function createValidator() + { + $schema = json_decode($this->getSchema()); + + return new ManifestValidator(new JsonSchemaValidator(), $schema, self::$DI['app']['phraseanet.version']); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php new file mode 100644 index 0000000000..b477faa68f --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Plugin/Schema/PluginValidatorTest.php @@ -0,0 +1,47 @@ +createManifestValidator()); + $validator->validatePlugin($directory); + } + + /** + * @dataProvider providePluginDirs + */ + public function testValidatePlugin($directory) + { + $validator = new PluginValidator($this->createManifestValidator()); + $validator->validatePlugin($directory); + } + + public function providePluginDirs() + { + return array( + array(__DIR__ . '/../Fixtures/PluginDir/TestPlugin'), + ); + } + + public function provideInvalidPluginDirs() + { + return array( + array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginInvalidManifest'), + array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginMissingComposer'), + array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginMissingManifest'), + array(__DIR__ . '/../Fixtures/WrongPlugins/TestPluginWrongManifest'), + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 6aff6af6ba..5d043317fd 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,5 +1,5 @@ add('Alchemy\\Tests', __DIR__); $loader->add('', __DIR__ . "/classes"); diff --git a/www/api.php b/www/api.php index a0159a2140..2f87fc6873 100644 --- a/www/api.php +++ b/www/api.php @@ -14,7 +14,7 @@ * @license http://opensource.org/licenses/gpl-3.0 GPLv3 * @link www.phraseanet.com */ -require_once __DIR__ . '/../vendor/autoload.php'; +require_once __DIR__ . '/../lib/autoload.php'; $app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Api.php'; diff --git a/www/index.php b/www/index.php index a8794dd050..e9c76071b0 100644 --- a/www/index.php +++ b/www/index.php @@ -14,7 +14,7 @@ * @license http://opensource.org/licenses/gpl-3.0 GPLv3 * @link www.phraseanet.com */ -require_once __DIR__ . "/../vendor/autoload.php"; +require_once __DIR__ . "/../lib/autoload.php"; $environment = 'prod'; $app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Root.php'; diff --git a/www/index_dev.php b/www/index_dev.php index d4064c8b54..54a8856d07 100644 --- a/www/index_dev.php +++ b/www/index_dev.php @@ -14,7 +14,7 @@ * @license http://opensource.org/licenses/gpl-3.0 GPLv3 * @link www.phraseanet.com */ -require_once __DIR__ . "/../vendor/autoload.php"; +require_once __DIR__ . "/../lib/autoload.php"; $environment = 'dev'; $app = require __DIR__ . '/../lib/Alchemy/Phrasea/Application/Root.php';