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))