diff --git a/bin/console b/bin/console index 449afd50d7..21e0e2dd2b 100755 --- a/bin/console +++ b/bin/console @@ -19,6 +19,9 @@ 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\Command\Setup\StaticSymLinkGenerator; use Alchemy\Phrasea\Core\Version; use Alchemy\Phrasea\Command\BuildMissingSubdefs; use Alchemy\Phrasea\Command\CreateCollection; @@ -105,6 +108,10 @@ $cli->command(new H264MappingGenerator()); $cli->command(new XSendFileConfigurationDumper()); $cli->command(new XSendFileMappingGenerator()); +$cli->command(new StaticConfigurationDumper()); +$cli->command(new StaticMappingGenerator()); +$cli->command(new StaticSymLinkGenerator()); + $cli->loadPlugins(); exit(is_int($cli->run()) ? : 1); 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..65766045c7 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/StaticMappingGenerator.php @@ -0,0 +1,103 @@ +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; + } + + private function computeMapping($paths) + { + $paths = array_unique($paths); + + $ret = array(); + + foreach ($paths as $path) { + $ret[$path] = $this->pathsToConf($path); + } + + return $ret; + } + + private function pathsToConf($path) + { + static $n = 0; + $n++; + + return array('mount-point' => 'mp4-videos-'.$n, 'directory' => $path, 'passphrase' => \random::generatePassword(32)); + } + + private function extractPath(\appbox $appbox) + { + $paths = array(); + + foreach ($appbox->get_databoxes() as $databox) { + foreach ($databox->get_subdef_structure() as $group => $subdefs) { + if ('video' !== $group) { + continue; + } + foreach ($subdefs as $subdef) { + $paths[] = $subdef->get_path(); + } + } + } + + return array_filter(array_unique($paths)); + } +} diff --git a/lib/Alchemy/Phrasea/Command/Setup/StaticSymLinkGenerator.php b/lib/Alchemy/Phrasea/Command/Setup/StaticSymLinkGenerator.php new file mode 100644 index 0000000000..bca4689144 --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/StaticSymLinkGenerator.php @@ -0,0 +1,85 @@ +setDescription('Generates Phraseanet Static file symlinks'); + } + + /** + * {@inheritdoc} + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + if (!$this->container['phraseanet.static-file-factory']->isStaticFileModeEnabled()) { + $output->writeln('Static file support is disabled'); + + return 1; + } + + $output->writeln("Removing symlinks ..."); + $this->container['filesystem']->remove($this->container['phraseanet.thumb-symlinker']->getPublicDir()); + $total = 0; + foreach ($this->container['phraseanet.appbox']->get_databoxes() as $databox) { + $sql = 'SELECT count(subdef_id) as total FROM subdef WHERE `name`="thumbnail"'; + $stmt = $databox->get_connection()->prepare($sql); + $stmt->execute(); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); + $total += $row['total']; + } + $output->writeln("Creating symlinks ..."); + $progress = $this->getHelperSet()->get('progress'); + $progress->start($output, $total); + $i = 0; + do { + foreach ($this->container['phraseanet.appbox']->get_databoxes() as $databox) { + $sql = 'SELECT record_id FROM record'; + $stmt = $databox->get_connection()->prepare($sql); + $stmt->execute(); + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $stmt->closeCursor(); + + foreach ($rows as $row) { + $record = $databox->get_record($row['record_id']); + foreach ($record->get_subdefs() as $subdef) { + if ($subdef->get_name() !== 'thumbnail') { + continue; + } + $this->container['phraseanet.thumb-symlinker']->symlink($subdef->get_pathfile()); + $progress->advance(); + $i++; + } + } + } + } while ($i < $total); + + $progress->finish(); + + $output->writeln("OK"); + + 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/Http/StaticFile/AbstractStaticMode.php b/lib/Alchemy/Phrasea/Http/StaticFile/AbstractStaticMode.php new file mode 100644 index 0000000000..50e87bee7b --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/AbstractStaticMode.php @@ -0,0 +1,27 @@ +symlinker = $symlinker; + + parent::__construct($mapping); + } +} diff --git a/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php b/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php new file mode 100644 index 0000000000..f614c8b0a1 --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/Apache.php @@ -0,0 +1,62 @@ +mapping = $mapping; + } + + /** + * {@inheritdoc} + */ + public function getUrl($pathFile) + { + return Url::factory(sprintf('%s/%s', $this->mapping['mount-point'], $this->symlinker->getSymlinkBasePath($pathFile))); + } + + /** + * {@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..5b23398d6d --- /dev/null +++ b/lib/Alchemy/Phrasea/Http/StaticFile/Nginx.php @@ -0,0 +1,58 @@ +mapping = $mapping; + } + + /** + * {@inheritdoc} + */ + public function getUrl($pathFile) + { + return Url::factory(sprintf('%s/%s', $this->mapping['mount-point'], $this->symlinker->getSymlinkBasePath($pathFile))); + } + + /** + * {@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..af0989d954 --- /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->getPublicDir() + ); + } + + /** + * Creates a new instance of XSendFile Factory according to the application + * configuration. + * + * @param Application $app + * @return XSendFileFactory + */ + 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. + * + * @return ModeInterface + * + * @throws InvalidArgumentException if mode type is unknown + */ + 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 Boolean + */ + 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->registry = $registry; + $this->rootPath = $rootPath; + $this->publicDir = sprintf('%s/public/thumbnails', rtrim($this->rootPath, '/')); + } + + public function getPublicDir() + { + return $this->publicDir; + } + + 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->publicDir, + $this->getSymlinkBasePath($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/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/record/adapter.php b/lib/classes/record/adapter.php index 54ed198901..c6375bca7e 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))