diff --git a/bin/console b/bin/console index 449afd50d7..4fb292526e 100755 --- a/bin/console +++ b/bin/console @@ -19,6 +19,8 @@ namespace KonsoleKommander; use Alchemy\Phrasea\Command\Plugin\ListPlugin; use Alchemy\Phrasea\Command\Setup\H264ConfigurationDumper; use Alchemy\Phrasea\Command\Setup\H264MappingGenerator; +use Alchemy\Phrasea\Command\Setup\StaticConfigurationDumper; +use Alchemy\Phrasea\Command\Setup\StaticMappingGenerator; use Alchemy\Phrasea\Core\Version; use Alchemy\Phrasea\Command\BuildMissingSubdefs; use Alchemy\Phrasea\Command\CreateCollection; @@ -105,6 +107,9 @@ $cli->command(new H264MappingGenerator()); $cli->command(new XSendFileConfigurationDumper()); $cli->command(new XSendFileMappingGenerator()); +$cli->command(new StaticConfigurationDumper()); +$cli->command(new StaticMappingGenerator()); + $cli->loadPlugins(); exit(is_int($cli->run()) ? : 1); diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml index 8ea3bb4151..5402a00fe6 100644 --- a/config/configuration.sample.yml +++ b/config/configuration.sample.yml @@ -157,8 +157,12 @@ api_cors: max_age: 0 hosts: [] session: - idle: 0 - lifetime: 604800 # 1 week + idle: 0 + lifetime: 604800 # 1 week +static-file: + enabled: false + type: nginx + symlink-directory: '' crossdomain: site-control: 'master-only' allow-access-from: diff --git a/lib/Alchemy/Phrasea/Command/Setup/StaticConfigurationDumper.php b/lib/Alchemy/Phrasea/Command/Setup/StaticConfigurationDumper.php new file mode 100644 index 0000000000..31c1ed223c --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/StaticConfigurationDumper.php @@ -0,0 +1,60 @@ +setDescription('Dump the virtual host configuration depending on Phraseanet configuration'); + } + + /** + * {@inheritdoc} + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $output->writeln(''); + + if ($this->container['phraseanet.xsendfile-factory']->isXSendFileModeEnabled()) { + throw new \LogicException('XSendFile mode is already activated'); + } + + if (!$this->container['phraseanet.static-file-factory']->isStaticFileModeEnabled()) { + $output->writeln('Static file support is disabled'); + $ret = 1; + } else { + $output->writeln('Static file support is enabled'); + $ret = 0; + } + + try { + $configuration = $this->container['phraseanet.static-file-factory']->getMode(true, true)->getVirtualHostConfiguration(); + $output->writeln('Static file configuration seems OK'); + $output->writeln($configuration); + } catch (RuntimeException $e) { + $output->writeln('Static file configuration seems invalid'); + $ret = 1; + } + + $output->writeln(''); + + return $ret; + } +} diff --git a/lib/Alchemy/Phrasea/Command/Setup/StaticMappingGenerator.php b/lib/Alchemy/Phrasea/Command/Setup/StaticMappingGenerator.php new file mode 100644 index 0000000000..1ecaff8592 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/StaticMappingGenerator.php @@ -0,0 +1,64 @@ +addOption('write', 'w', null, 'Writes the configuration') + ->addOption('enabled', 'e', null, 'Set the enable toggle to `true`') + ->addArgument('type', InputArgument::REQUIRED, 'The configuration type, either `nginx` or `apache`') + ->setDescription('Generates Phraseanet Static file configuration'); + } + + /** + * {@inheritdoc} + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $enabled = $input->getOption('enabled'); + $type = strtolower($input->getArgument('type')); + + $factory = new StaticFileFactory($this->container['monolog'], true, $type, $this->container['phraseanet.thumb-symlinker']); + $mode = $factory->getMode(true); + + $conf = array( + 'enabled' => $enabled, + 'type' => $type, + 'mapping' => $mode->getMapping(), + ); + + if ($input->getOption('write')) { + $output->write("Writing configuration ..."); + $this->container['phraseanet.configuration']['static-file'] = $conf; + $output->writeln(" OK"); + $output->writeln(""); + $output->write("It is now strongly recommended to use static-file:dump-configuration command to upgrade your virtual-host"); + } else { + $output->writeln("Configuration will not be written, use --write option to write it"); + $output->writeln(""); + $output->writeln(Yaml::dump(array('static-file' => $conf), 4)); + } + + return 0; + } +} diff --git a/lib/Alchemy/Phrasea/Core/Provider/FileServeServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/FileServeServiceProvider.php index a22ab5d29d..8ed200e297 100644 --- a/lib/Alchemy/Phrasea/Core/Provider/FileServeServiceProvider.php +++ b/lib/Alchemy/Phrasea/Core/Provider/FileServeServiceProvider.php @@ -14,6 +14,7 @@ namespace Alchemy\Phrasea\Core\Provider; use Alchemy\Phrasea\Core\Event\Subscriber\XSendFileSubscriber; use Alchemy\Phrasea\Http\H264PseudoStreaming\H264Factory; use Alchemy\Phrasea\Http\ServeFileResponseFactory; +use Alchemy\Phrasea\Http\StaticFile\StaticFileFactory; use Alchemy\Phrasea\Http\XSendFile\XSendFileFactory; use Silex\Application; use Silex\ServiceProviderInterface; @@ -33,10 +34,18 @@ class FileServeServiceProvider implements ServiceProviderInterface return H264Factory::create($app); }); + $app['phraseanet.static-file-factory'] = $app->share(function ($app) { + return StaticFileFactory::create($app); + }); + $app['phraseanet.h264'] = $app->share(function ($app) { return $app['phraseanet.h264-factory']->createMode(false); }); + $app['phraseanet.static-file'] = $app->share(function ($app) { + return $app['phraseanet.static-file-factory']->getMode(false); + }); + $app['phraseanet.file-serve'] = $app->share(function (Application $app) { return ServeFileResponseFactory::create($app); }); diff --git a/lib/Alchemy/Phrasea/Core/Provider/PhraseanetServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/PhraseanetServiceProvider.php index 1719745b3a..7a4caaa0d6 100644 --- a/lib/Alchemy/Phrasea/Core/Provider/PhraseanetServiceProvider.php +++ b/lib/Alchemy/Phrasea/Core/Provider/PhraseanetServiceProvider.php @@ -11,6 +11,8 @@ namespace Alchemy\Phrasea\Core\Provider; +use Alchemy\Phrasea\Http\StaticFile\Symlink\SymLinker; +use Alchemy\Phrasea\Http\StaticFile\Symlink\SymLinkerEncoder; use Alchemy\Phrasea\Metadata\PhraseanetMetadataReader; use Alchemy\Phrasea\Metadata\PhraseanetMetadataSetter; use Alchemy\Phrasea\Security\Firewall; @@ -41,6 +43,14 @@ class PhraseanetServiceProvider implements ServiceProviderInterface return $events; }); + $app['phraseanet.thumb-symlinker'] = $app->share(function (SilexApplication $app) { + return SymLinker::create($app); + }); + + $app['phraseanet.thumb-symlinker-encoder'] = $app->share(function (SilexApplication $app) { + return SymLinkerEncoder::create($app); + }); + $app['phraseanet.metadata-reader'] = $app->share(function (SilexApplication $app) { $reader = new PhraseanetMetadataReader(); diff --git a/lib/Alchemy/Phrasea/Core/Version.php b/lib/Alchemy/Phrasea/Core/Version.php index 209e36a2e8..cdffc01ed5 100644 --- a/lib/Alchemy/Phrasea/Core/Version.php +++ b/lib/Alchemy/Phrasea/Core/Version.php @@ -11,14 +11,9 @@ namespace Alchemy\Phrasea\Core; -/** - * - * @license http://opensource.org/licenses/gpl-3.0 GPLv3 - * @link www.phraseanet.com - */ class Version { - protected static $number = '3.8.6-alpha.2'; + protected static $number = '3.8.6-alpha.3'; protected static $name = 'Falcarius'; public static function getNumber() diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/AbstractStaticMode.php b/lib/Alchemy/Phrasea/Http/StaticFile/AbstractStaticMode.php new file mode 100644 index 0000000000..a87237450c --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/AbstractStaticMode.php @@ -0,0 +1,50 @@ +symlinker = $symlinker; + + parent::__construct($mapping); + } + + /** + * {@inheritdoc} + */ + public function getUrl($pathFile) + { + $this->ensureSymlink($pathFile); + + return Url::factory(sprintf('%s/%s', $this->mapping['mount-point'], $this->symlinker->getSymlinkBasePath($pathFile))); + } + + /** + * Creates a link if it does not exists + * + * @param $pathFile + */ + private function ensureSymlink($pathFile) + { + if (false === $this->symlinker->hasSymlink($pathFile)) { + $this->symlinker->symlink($pathFile); + } + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php b/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php new file mode 100644 index 0000000000..f200550987 --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php @@ -0,0 +1,52 @@ +mapping = $mapping; + } + + /** + * {@inheritdoc} + */ + public function getVirtualHostConfiguration() + { + $output = "\n"; + $output .= " Alias ".$this->mapping['mount-point']." ".$this->mapping['directory']."\n"; + $output .= "\n"; + $output .= " mapping['directory'].">\n"; + $output .= " Order allow,deny\n"; + $output .= " Allow from all\n"; + $output .= " \n"; + $output .= "\n"; + + return $output; + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/Nginx.php b/lib/Alchemy/Phrasea/Http/StaticFile/Nginx.php new file mode 100644 index 0000000000..a105f93336 --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/Nginx.php @@ -0,0 +1,48 @@ +mapping = $mapping; + } + + /** + * {@inheritdoc} + */ + public function getVirtualHostConfiguration() + { + $output = "\n"; + $output .= " location " . $this->mapping['mount-point']. " {\n"; + $output .= " alias ".$this->mapping['directory'].";\n"; + $output .= " }\n"; + + return $output; + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/NullMode.php b/lib/Alchemy/Phrasea/Http/StaticFile/NullMode.php new file mode 100644 index 0000000000..9a9f40608c --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/NullMode.php @@ -0,0 +1,30 @@ +logger = $logger; + $this->enabled = (Boolean) $enabled; + $this->type = strtolower($type); + $this->symlinker = $symlinker; + + $this->mapping = array( + 'mount-point' => $symlinker->getDefaultAlias(), + 'directory' => $symlinker->getSymlinkDir() + ); + } + + /** + * Creates a new instance of StaticFileFactory Factory according to the application + * configuration. + * + * @param Application $app + * @return StaticFileFactory + */ + public static function create(Application $app) + { + $conf = $app['phraseanet.configuration']['static-file']; + + return new self($app['monolog'], $conf['enabled'], $conf['type'], $app['phraseanet.thumb-symlinker']); + } + + /** + * Returns a new instance of ModeInterface + * + * @param bool $throwException + * @param bool $forceMode + * + * @return Apache|Nginx|NullMode + * @throws InvalidArgumentException + */ + public function getMode($throwException = false, $forceMode = false) + { + if (false === $this->enabled && true !== $forceMode) { + return new NullMode(); + } + + switch ($this->type) { + case 'nginx': + return new Nginx($this->mapping, $this->symlinker); + break; + case 'apache': + case 'apache2': + return new Apache($this->mapping, $this->symlinker); + default: + $this->logger->error('Invalid static file configuration.'); + if ($throwException) { + throw new InvalidArgumentException(sprintf( + 'Invalid static file type value "%s"', + $this->type + )); + } + + return new NullMode(); + } + } + + /** + * @return bool + */ + public function isStaticFileModeEnabled() + { + return $this->enabled; + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/StaticFileModeInterface.php b/lib/Alchemy/Phrasea/Http/StaticFile/StaticFileModeInterface.php new file mode 100644 index 0000000000..33a9ad31ee --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/StaticFileModeInterface.php @@ -0,0 +1,25 @@ +encoder = $encoder; + $this->fs = $fs; + $this->symlinkDir = rtrim($symlinkDir, '/'); + } + + public function getSymlinkDir() + { + return $this->symlinkDir; + } + + public function getDefaultAlias() + { + return sprintf('/%s', self::ALIAS); + } + + public function symlink($pathFile) + { + $this->fs->symlink($pathFile, $this->getSymlinkPath($pathFile)) ; + } + + public function getSymlink($pathFile) + { + return $this->encoder->encode($pathFile); + } + + public function getSymlinkBasePath($pathFile) + { + $symlinkName = $this->getSymlink($pathFile); + + return sprintf('%s/%s/%s', + substr($symlinkName, 0, 2), + substr($symlinkName, 2, 2), + substr($symlinkName, 4) + ); + } + + public function getSymlinkPath($pathFile) + { + return sprintf( + '%s/%s', + $this->symlinkDir, + $this->getSymlinkBasePath($pathFile) + ); + } + + public function hasSymlink($pathFile) + { + return file_exists($this->getSymlinkPath($pathFile)); + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/Symlink/SymLinkerEncoder.php b/lib/Alchemy/Phrasea/Http/StaticFile/Symlink/SymLinkerEncoder.php new file mode 100644 index 0000000000..b659d4e912 --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/Symlink/SymLinkerEncoder.php @@ -0,0 +1,38 @@ +key = $key; + } + + public function encode($pathFile) + { + return hash_hmac('sha512', $pathFile , $this->key); + } +} diff --git a/lib/classes/databox.php b/lib/classes/databox.php index 51f7977a5a..fd2f61a2b3 100644 --- a/lib/classes/databox.php +++ b/lib/classes/databox.php @@ -445,6 +445,21 @@ class databox extends base public function unmount_databox() { + if ($this->app['phraseanet.static-file-factory']->isStaticFileModeEnabled()) { + $sql = "SELECT path, file FROM subdef WHERE `name`='thumbnail'"; + $stmt = $this->get_connection()->prepare($sql); + $stmt->execute(); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); + foreach ($rows as $row) { + $pathfile = $this->app['phraseanet.thumb-symlinker']->getSymlinkPath(sprintf( + '%s/%s', + rtrim($row['path'], '/'), + $row['file'] + )); + $this->app['filesystem']->remove($pathfile); + } + } foreach ($this->get_collections() as $collection) { $collection->unmount_collection($this->app); } diff --git a/lib/classes/media/subdef.php b/lib/classes/media/subdef.php index 08e264ab71..563880e7dc 100644 --- a/lib/classes/media/subdef.php +++ b/lib/classes/media/subdef.php @@ -730,6 +730,10 @@ class media_subdef extends media_abstract implements cache_cacheableInterface $subdef->get_permalink()->delete_data_from_cache(); } + if ($name === 'thumbnail' && $app['phraseanet.static-file-factory']->isStaticFileModeEnabled()) { + $app['phraseanet.thumb-symlinker']->symlink($subdef->get_pathfile()); + } + unset($media); return $subdef; @@ -742,10 +746,18 @@ class media_subdef extends media_abstract implements cache_cacheableInterface */ protected function generate_url() { - if ( ! $this->is_physically_present()) { + if (!$this->is_physically_present()) { return; } + if ($this->get_name() === 'thumbnail') { + if ($this->app['phraseanet.static-file-factory']->isStaticFileModeEnabled() && null !== $url = $this->app['phraseanet.static-file']->getUrl($this->get_pathfile())) { + $this->url = $url; + + return; + } + } + if ($this->app['phraseanet.h264-factory']->isH264Enabled() && in_array($this->mime, array('video/mp4'))) { if (null !== $url = $this->app['phraseanet.h264']->getUrl($this->get_pathfile())) { $this->url = $url; @@ -758,6 +770,7 @@ class media_subdef extends media_abstract implements cache_cacheableInterface . "/" . $this->record->get_record_id() . "/" . $this->get_name() . "/"); + return; } diff --git a/lib/classes/patch/386alpha3a.php b/lib/classes/patch/386alpha3a.php new file mode 100644 index 0000000000..58350db477 --- /dev/null +++ b/lib/classes/patch/386alpha3a.php @@ -0,0 +1,65 @@ +release; + } + + /** + * {@inheritdoc} + */ + public function require_all_upgrades() + { + return false; + } + + /** + * {@inheritdoc} + */ + public function concern() + { + return $this->concern; + } + + /** + * {@inheritdoc} + */ + public function apply(base $appbox, Application $app) + { + $config = $app['phraseanet.configuration']->getConfig(); + + $config['static-file'] = array( + 'enabled' => false, + 'type' => '', + 'symlink-directory' => '', + ); + + $app['phraseanet.configuration']->setConfig($config); + + return true; + } +} diff --git a/lib/classes/record/adapter.php b/lib/classes/record/adapter.php index 31e528cfd0..03ebc4bde4 100644 --- a/lib/classes/record/adapter.php +++ b/lib/classes/record/adapter.php @@ -1514,6 +1514,10 @@ class record_adapter implements record_Interface, cache_cacheableInterface if (!$subdef->is_physically_present()) continue; + if ($subdef->get_name() === 'thumbnail' && $this->app['phraseanet.static-file-factory']->isStaticFileModeEnabled()) { + $this->app['filesystem']->remove($this->app['phraseanet.thumb-symlinker']->getSymlinkPath($subdef->get_pathfile())); + } + $ftodel[] = $subdef->get_pathfile(); $watermark = $subdef->get_path() . 'watermark_' . $subdef->get_file(); if (file_exists($watermark)) diff --git a/lib/conf.d/configuration.yml b/lib/conf.d/configuration.yml index d11f93caaf..3f5dfc2cac 100644 --- a/lib/conf.d/configuration.yml +++ b/lib/conf.d/configuration.yml @@ -163,6 +163,10 @@ session: idle: 0 # 1 week lifetime: 604800 +static-file: + enabled: false + type: nginx + symlink-directory: '' crossdomain: allow-access-from: - diff --git a/templates/web/prod/results/record.html.twig b/templates/web/prod/results/record.html.twig index f2c96a2175..330df541f8 100644 --- a/templates/web/prod/results/record.html.twig +++ b/templates/web/prod/results/record.html.twig @@ -59,9 +59,10 @@ {% if rollover_gif %} {% set extraclass = 'rollover-gif-out' %} {% endif %} - {{thumbnail.format(record.get_thumbnail(), th_size, th_size, extraclass, true, true)}} + {% set lazyload = not app['phraseanet.static-file-factory'].isStaticFileModeEnabled %} + {{thumbnail.format(record.get_thumbnail(), th_size, th_size, extraclass, true, lazyload)}} {% if rollover_gif %} - {{thumbnail.format(rollover_gif, th_size, th_size, 'rollover-gif-hover', true, true)}} + {{thumbnail.format(rollover_gif, th_size, th_size, 'rollover-gif-hover', true, lazyload)}} {% endif %} diff --git a/tests/Alchemy/Tests/Phrasea/Http/StaticFile/ApacheModeTest.php b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/ApacheModeTest.php new file mode 100644 index 0000000000..39a90c1a42 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/ApacheModeTest.php @@ -0,0 +1,37 @@ + __DIR__, + 'mount-point' => '/thumbs' + ), self::$DI['app']['phraseanet.thumb-symlinker']); + $conf = $mode->getVirtualHostConfiguration(); + $this->assertRegExp('#'.__DIR__ . '#', $conf); + } + + /** + * @dataProvider provideMappings + * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException + */ + public function testInvalidMapping($mapping) + { + new Apache($mapping, self::$DI['app']['phraseanet.thumb-symlinker']); + } + + public function provideMappings() + { + return array( + array(array('Directory' => __DIR__)), + array(array('wrong-key' => __DIR__, 'mount-point' => '/')), + array(array('directory' => __DIR__, 'wrong-key' => '/')), + ); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Http/StaticFile/NginxModeTest.php b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/NginxModeTest.php new file mode 100644 index 0000000000..1f89f488d0 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/NginxModeTest.php @@ -0,0 +1,37 @@ + __DIR__, + 'mount-point' => '/thumbs' + ), self::$DI['app']['phraseanet.thumb-symlinker']); + $conf = $mode->getVirtualHostConfiguration(); + $this->assertRegExp('#'.__DIR__ . '#', $conf); + } + + /** + * @dataProvider provideMappings + * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException + */ + public function testInvalidMapping($mapping) + { + new Nginx($mapping, self::$DI['app']['phraseanet.thumb-symlinker']); + } + + public function provideMappings() + { + return array( + array(array('Directory' => __DIR__)), + array(array('wrong-key' => __DIR__, 'mount-point' => '/')), + array(array('directory' => __DIR__, 'wrong-key' => '/')), + ); + } +} diff --git a/tests/Alchemy/Tests/Phrasea/Http/StaticFile/StaticFileFactoryTest.php b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/StaticFileFactoryTest.php new file mode 100644 index 0000000000..450ad0e7d2 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Http/StaticFile/StaticFileFactoryTest.php @@ -0,0 +1,54 @@ +assertInstanceOf('Alchemy\Phrasea\Http\StaticFile\StaticFileFactory', $factory); + } + + public function testFactoryWithStaticFileEnable() + { + $logger = $this->getMock('Psr\Log\LoggerInterface'); + + $factory = new StaticFileFactory($logger, true, 'nginx', self::$DI['app']['phraseanet.thumb-symlinker']); + $this->assertInstanceOf('Alchemy\Phrasea\Http\StaticFile\AbstractStaticMode', $factory->getMode()); + } + + public function testFactoryWithStaticFileDisabled() + { + $logger = $this->getMock('Psr\Log\LoggerInterface'); + + $factory = new StaticFileFactory($logger, false, 'nginx', self::$DI['app']['phraseanet.thumb-symlinker']); + $this->assertInstanceOf('Alchemy\Phrasea\Http\StaticFile\NullMode', $factory->getMode()); + $this->assertFalse($factory->isStaticFileModeEnabled()); + } + + /** + * @expectedException Alchemy\Phrasea\Exception\InvalidArgumentException + */ + public function testFactoryWithWrongTypeThrowsAnExceptionIfRequired() + { + $logger = $this->getMock('Psr\Log\LoggerInterface'); + + $factory = new StaticFileFactory($logger, true, 'wrong-type', self::$DI['app']['phraseanet.thumb-symlinker']); + $factory->getMode(true); + } + + public function testFactoryWithWrongTypeDoesNotThrowsAnException() + { + $logger = $this->getMock('Psr\Log\LoggerInterface'); + + $logger->expects($this->once()) + ->method('error') + ->with($this->isType('string')); + + $factory = new StaticFileFactory($logger, true, 'wrong-type', self::$DI['app']['phraseanet.thumb-symlinker']); + $this->assertInstanceOf('Alchemy\Phrasea\Http\StaticFile\NullMode', $factory->getMode(false)); + } +}