diff --git a/bin/setup b/bin/setup index 8ed77ab5b8..a96f724d73 100755 --- a/bin/setup +++ b/bin/setup @@ -21,6 +21,8 @@ use Alchemy\Phrasea\Command\UpgradeDBDatas; use Alchemy\Phrasea\Command\Setup\Install; use Alchemy\Phrasea\Command\Setup\LessCompiler; use Alchemy\Phrasea\Command\Setup\JavascriptBuilder; +use Alchemy\Phrasea\Command\Setup\XSendFileMappingNginxDumper; +use Alchemy\Phrasea\Command\Setup\XSendFileMappingApacheDumper; use Alchemy\Phrasea\CLI; use Alchemy\Phrasea\Command\Setup\CheckEnvironment; @@ -66,6 +68,8 @@ try { $app->command(new Install('system:install')); $app->command(new LessCompiler()); $app->command(new JavascriptBuilder()); + $app->command(new XSendFileMappingNginxDumper()); + $app->command(new XSendFileMappingApacheDumper()); $result_code = is_int($app->run()) ? : 1; } catch (\Exception $e) { diff --git a/config/configuration.sample.yml b/config/configuration.sample.yml index e151fe0734..6e233eb492 100644 --- a/config/configuration.sample.yml +++ b/config/configuration.sample.yml @@ -130,3 +130,9 @@ registration-fields: - name: geonameid required: true +xsendfile: + enable: false + mapping: + - + directory: '' + mount-point: '' diff --git a/hudson/_GV.php b/hudson/_GV.php index c4a14423c3..b9011abf85 100644 --- a/hudson/_GV.php +++ b/hudson/_GV.php @@ -26,9 +26,6 @@ define('GV_dailymotion_client_secret', ''); define('GV_client_navigator', false); define('GV_base_datapath_noweb', '/tmp/'); define('GV_phrasea_sort', ''); -define('GV_modxsendfile', false); -define('GV_X_Accel_Redirect', ''); -define('GV_X_Accel_Redirect_mount_point', 'noweb'); define('GV_h264_streaming', false); define('GV_mod_auth_token_directory', ''); define('GV_mod_auth_token_directory_path', ''); diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index a0f3c3a6de..ed1bf00209 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -99,6 +99,7 @@ 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\Core\Provider\XSendFileMappingServiceProvider; use Alchemy\Phrasea\Exception\InvalidArgumentException; use Alchemy\Phrasea\Twig\JSUniqueID; use Alchemy\Phrasea\Twig\Camelize; @@ -306,6 +307,12 @@ class Application extends SilexApplication $this->register(new ValidatorServiceProvider()); $this->register(new XPDFServiceProvider()); + $this->register(new XSendFileMappingServiceProvider(), array( + 'xsendfile.mapping' => array( + $this['root.path'] . '/tmp/download/' => '/download/', + $this['root.path'] . '/tmp/lazaret/' => '/lazaret/' + ) + )); $this->register(new FileServeServiceProvider()); $this['phraseanet.exception_handler'] = $this->share(function ($app) { diff --git a/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingApacheDumper.php b/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingApacheDumper.php new file mode 100644 index 0000000000..5010efd61a --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingApacheDumper.php @@ -0,0 +1,51 @@ +setDescription('Dump XSendFile mapping for Apache web server'); + } + + /** + * {@inheritdoc} + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $mapper = $this->container['phraseanet.xsendfile-mapping']; + + $output->writeln('Apache XSendfile configuration'); + $output->writeln(''); + $output->writeln(''); + $output->writeln(' '); + $output->writeln(' XSendFile on'); + foreach ($this->container['phraseanet.xsendfile-mapping']->getMapping() as $entry) { + $output->writeln(' XSendFilePath ' . $mapper->sanitizePath($entry['directory'])); + } + $output->writeln(' '); + $output->writeln(''); + $output->writeln(''); + + return 1; + } +} diff --git a/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingNginxDumper.php b/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingNginxDumper.php new file mode 100644 index 0000000000..91ffca120d --- /dev/null +++ b/lib/Alchemy/Phrasea/Command/Setup/XSendFileMappingNginxDumper.php @@ -0,0 +1,48 @@ +setDescription('Dump xsendfile mapping for Nginx and Apache web server'); + } + + /** + * {@inheritdoc} + */ + protected function doExecute(InputInterface $input, OutputInterface $output) + { + $mapper = $this->container['phraseanet.xsendfile-mapping']; + $output->writeln('Nginx XSendfile configuration'); + $output->writeln(''); + foreach ($this->container['phraseanet.xsendfile-mapping']->getMapping() as $entry) { + $output->writeln(' location ' . $mapper->sanitizeMountPoint($entry['mount-point']) . ' {'); + $output->writeln(' internal;'); + $output->writeln(' alias ' . $mapper->sanitizePath($entry['directory'])); + $output->writeln(' }'); + $output->writeln(''); + } + + return 1; + } +} diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/XSendFileSuscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/XSendFileSuscriber.php new file mode 100644 index 0000000000..2cbe9ea688 --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Event/Subscriber/XSendFileSuscriber.php @@ -0,0 +1,43 @@ +app = $app; + } + + public static function getSubscribedEvents() + { + return array( + KernelEvents::REQUEST => array('applyHeaders', 16), + ); + } + + public function applyHeaders(GetResponseEvent $event) + { + if ($this->app['phraseanet.configuration']['xsendfile']['enable']) { + $request = $event->getRequest(); + $request->headers->set('X-Sendfile-Type', 'X-Accel-Redirect'); + $request->headers->set('X-Accel-Mapping', (string) $this->app['phraseanet.xsendfile-mapping']); + } + } +} diff --git a/lib/Alchemy/Phrasea/Core/Provider/XSendFileMappingServiceProvider.php b/lib/Alchemy/Phrasea/Core/Provider/XSendFileMappingServiceProvider.php new file mode 100644 index 0000000000..48cda29b10 --- /dev/null +++ b/lib/Alchemy/Phrasea/Core/Provider/XSendFileMappingServiceProvider.php @@ -0,0 +1,47 @@ +share(function($app) { + $mapping = array(); + foreach($app['xsendfile.mapping'] as $path => $mountPoint) { + $mapping[] = array( + 'directory' => $path, + 'mount-point' => $mountPoint, + ); + } + + return Mapping::create($app, $mapping); + }); + } + + public function boot(Application $app) + { + } +} diff --git a/lib/Alchemy/Phrasea/Response/ServeFileResponseFactory.php b/lib/Alchemy/Phrasea/Response/ServeFileResponseFactory.php index ce53c6d324..980686c7ef 100644 --- a/lib/Alchemy/Phrasea/Response/ServeFileResponseFactory.php +++ b/lib/Alchemy/Phrasea/Response/ServeFileResponseFactory.php @@ -13,32 +13,17 @@ namespace Alchemy\Phrasea\Response; use Alchemy\Phrasea\Response\DeliverDataInterface; use Alchemy\Phrasea\Application; -use Psr\Log\LoggerInterface, use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\BinaryFileResponse; class ServeFileResponseFactory implements DeliverDataInterface { private $xSendFileEnable = false; - private $mappings; private $unicode; - private $logger; - public function __construct($enableXSendFile, $xAccelMappings, \unicode $unicode, LoggerInterface $logger = null) + public function __construct($enableXSendFile, \unicode $unicode) { - $this->logger = $logger; - $this->xSendFileEnable = $enableXSendFile; - - $mappings = array(); - - foreach ($xAccelMappings as $path => $mountPoint) { - if (is_dir($path) && '' !== $mountPoint) { - $mappings[$this->sanitizeXAccelPath($path)] = $this->sanitizeXAccelMountPoint($mountPoint); - } - } - - $this->mappings = $mappings; $this->unicode = $unicode; } @@ -49,12 +34,9 @@ class ServeFileResponseFactory implements DeliverDataInterface public static function create(Application $app) { return new self( - $app['phraseanet.registry']->get('GV_modxsendfile'), - array( - $app['phraseanet.registry']->get('GV_X_Accel_Redirect') => $app['phraseanet.registry']->get('GV_X_Accel_Redirect_mount_point'), - $app['root.path'] . '/tmp/download/' => '/download/', - $app['root.path'] . '/tmp/lazaret/' => '/lazaret/' - ), new \unicode(), $app['logger']); + $app['phraseanet.configuration']['xsendfile']['enable'], + $app['unicode'] + ); } /** @@ -66,11 +48,7 @@ class ServeFileResponseFactory implements DeliverDataInterface $response->setContentDisposition($disposition, $this->sanitizeFilename($filename), $this->sanitizeFilenameFallback($filename)); if ($this->isXSendFileEnable()) { - if ($this->isMappedFile($file)) { - $response->headers->set('X-Accel-Redirect', $this->xAccelRedirectMapping($file)); - } else if (null !== $this->logger) { - $this->logger->warning(sprintf('%s is not located under a nginx xAccelPath')); - } + BinaryFileResponse::trustXSendfileTypeHeader(); } if (null !== $mimeType) { @@ -104,16 +82,6 @@ class ServeFileResponseFactory implements DeliverDataInterface return $this->xSendFileEnable; } - private function sanitizeXAccelPath($path) - { - return sprintf('%s/', rtrim($path, '/')); - } - - private function sanitizeXAccelMountPoint($mountPoint) - { - return sprintf('/%s/', rtrim(ltrim($mountPoint, '/'), '/')); - } - private function sanitizeFilename($filename) { return str_replace(array('/', '\\'), '', $filename); @@ -123,20 +91,4 @@ class ServeFileResponseFactory implements DeliverDataInterface { return $this->unicode->remove_nonazAZ09($filename, true, true, true); } - - private function xAccelRedirectMapping($file) - { - return str_replace(array_keys($this->mappings), array_values($this->mappings), $file); - } - - private function isMapped($file) - { - foreach (array_keys($this->mappings) as $path) { - if (false !== strpos($file, $path)) { - return true; - } - } - - return false; - } } diff --git a/lib/Alchemy/Phrasea/XSendFile/Mapping.php b/lib/Alchemy/Phrasea/XSendFile/Mapping.php new file mode 100644 index 0000000000..882a82fc04 --- /dev/null +++ b/lib/Alchemy/Phrasea/XSendFile/Mapping.php @@ -0,0 +1,91 @@ +validate($mapping); + + $this->mapping = $mapping; + } + + public function __toString() + { + $final = array(); + + foreach($this->mapping as $entry) { + if (!is_dir($entry['directory']) || '' === $entry['mount-point']) { + continue; + } + + $final[] = sprintf('%s=%s', $this->sanitizeMountPoint($entry['mount-point']), $this->sanitizePath(realpath($entry['directory']))); + } + + return implode(',', $final); + } + + public function getMapping() + { + return $this->mapping; + } + + public static function create(Application $app, array $mapping = array()) + { + if (isset($app['phraseanet.configuration']['xsendfile']['mapping'])) { + $confMapping = $app['phraseanet.configuration']['xsendfile']['mapping']; + + if (!is_array($confMapping)) { + throw new InvalidArgumentException('XSendFile mapping configuration must be an array'); + } + + foreach($confMapping as $entry) { + $mapping[] = $entry; + } + } + + return new Mapping($mapping); + } + + public function sanitizePath($path) + { + return sprintf('/%s', rtrim(ltrim($path, '/'),'/')); + } + + public function sanitizeMountPoint($mountPoint) + { + return sprintf('/%s', rtrim(ltrim($mountPoint, '/'), '/')); + } + + private function validate(array $mapping) + { + foreach($mapping as $entry) { + if (!is_array($entry)) { + throw new \InvalidArgumentException('XSendFile mapping entry must be an array'); + } + + if (!isset($entry['directory']) && !isset($entry['mount-point'])) { + throw new \InvalidArgumentException('XSendFile mapping entry must contain at least two keys "directory" and "mounbt-point"'); + } + } + } +} diff --git a/lib/classes/API/V1/adapter.php b/lib/classes/API/V1/adapter.php index 51203dcd18..5e8b847a14 100644 --- a/lib/classes/API/V1/adapter.php +++ b/lib/classes/API/V1/adapter.php @@ -351,9 +351,8 @@ class API_V1_adapter extends API_V1_Abstract 'defaultLanguage' => $app['phraseanet.registry']->get('id_GV_default_lng'), 'allowIndexing' => $app['phraseanet.registry']->get('GV_allow_search_engine'), 'modes' => array( - 'XsendFile' => $app['phraseanet.registry']->get('GV_modxsendfile'), - 'nginxXAccelRedirect' => $app['phraseanet.registry']->get('GV_X_Accel_Redirect'), - 'nginxXAccelRedirectMountPoint' => $app['phraseanet.registry']->get('GV_X_Accel_Redirect_mount_point'), + 'XsendFile' => $app['phraseanet.configuration']['xsendfile']['enable'], + 'XsendFileMapping' => $app['phraseanet.configuration']['xsendfile']['mapping'], 'h264Streaming' => $app['phraseanet.registry']->get('GV_h264_streaming'), 'authTokenDirectory' => $app['phraseanet.registry']->get('GV_mod_auth_token_directory'), 'authTokenDirectoryPath' => $app['phraseanet.registry']->get('GV_mod_auth_token_directory_path'), diff --git a/lib/conf.d/_GV_template.inc b/lib/conf.d/_GV_template.inc index 30f715203a..68634f4338 100644 --- a/lib/conf.d/_GV_template.inc +++ b/lib/conf.d/_GV_template.inc @@ -196,26 +196,6 @@ return call_user_func_array(function(Application $app) { ), array( 'section' => _('GV::section:: Executables externes'), 'vars' => array( - array( - 'type' => \registry::TYPE_BOOLEAN, - 'name' => 'GV_modxsendfile', - 'comment' => _('reglages:: mod_xsendfileapache active'), - 'default' => false - ), - array( - 'type' => \registry::TYPE_STRING, - 'name' => 'GV_X_Accel_Redirect', - 'comment' => _('reglages:: Path en acces pour X-Accel-Redirect (NginX Uniquement)'), - 'default' => '', - 'end_slash' => true - ), - array( - 'type' => \registry::TYPE_STRING, - 'name' => 'GV_X_Accel_Redirect_mount_point', - 'comment' => _('reglages:: Point de montage pour X-Accel-Redirect (NginX Uniquement)'), - 'default' => 'noweb', - 'end_slash' => false - ), array( 'type' => \registry::TYPE_BOOLEAN, 'name' => 'GV_h264_streaming', diff --git a/lib/conf.d/configuration.yml b/lib/conf.d/configuration.yml index e1ad1c38f0..fb52350f7d 100644 --- a/lib/conf.d/configuration.yml +++ b/lib/conf.d/configuration.yml @@ -133,3 +133,9 @@ registration-fields: - name: geonameid required: true +xsendfile: + enable: false + mapping: + - + directory: '' + mount-point: '' diff --git a/tests/Alchemy/Tests/Phrasea/Core/Provider/XSendFileMappingServiceProviderTest.php b/tests/Alchemy/Tests/Phrasea/Core/Provider/XSendFileMappingServiceProviderTest.php new file mode 100644 index 0000000000..db61b4f9c8 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/Core/Provider/XSendFileMappingServiceProviderTest.php @@ -0,0 +1,16 @@ +factory = new ServeFileResponseFactory( - false, - array( - __DIR__ . '/../../../../files/' => '/protected/' - ), new \unicode()); + $this->factory = new ServeFileResponseFactory(false, new \unicode()); $response = $this->factory->deliverFile(__DIR__ . '/../../../../files/cestlafete.jpg'); @@ -24,11 +22,7 @@ class ServeFileResponseFactoryTest extends \PhraseanetWebTestCaseAbstract public function testDeliverFileWithFilename() { - $this->factory = new ServeFileResponseFactory( - false, - array( - __DIR__ . '/../../../../files/' => '/protected/' - ), new \unicode()); + $this->factory = new ServeFileResponseFactory(false, new \unicode()); $response = $this->factory->deliverFile(__DIR__ . '/../../../../files/cestlafete.jpg', 'toto.jpg'); @@ -38,11 +32,7 @@ class ServeFileResponseFactoryTest extends \PhraseanetWebTestCaseAbstract public function testDeliverFileWithFilenameAndDisposition() { - $this->factory = new ServeFileResponseFactory( - false, - array( - __DIR__ . '/../../../../files/' => '/protected/' - ), new \unicode()); + $this->factory = new ServeFileResponseFactory(false, new \unicode()); $response = $this->factory->deliverFile(__DIR__ . '/../../../../files/cestlafete.jpg', 'toto.jpg', 'attachment'); @@ -52,13 +42,18 @@ class ServeFileResponseFactoryTest extends \PhraseanetWebTestCaseAbstract public function testDeliverFileWithFilenameAndDispositionAndXSendFile() { - $this->factory = new ServeFileResponseFactory( - true, + $this->factory = new ServeFileResponseFactory(true, new \unicode()); + $request = Request::create('/'); + $request->headers->set('X-SendFile-Type', 'X-Accel-Redirect'); + $request->headers->set('X-Accel-Mapping', (string) new Mapping(array( array( - __DIR__ . '/../../../../files/' => '/protected/' - ), new \unicode()); + 'directory' => __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ) + ))); $response = $this->factory->deliverFile(__DIR__ . '/../../../../files/cestlafete.jpg', 'toto.jpg', 'attachment'); + $response->prepare($request); $this->assertInstanceOf("Symfony\Component\HttpFoundation\Response", $response); $this->assertEquals('attachment; filename="toto.jpg"', $response->headers->get('content-disposition')); @@ -67,13 +62,18 @@ class ServeFileResponseFactoryTest extends \PhraseanetWebTestCaseAbstract public function testDeliverFileWithFilenameAndDispositionAndXSendFileAndNoTrailingSlashes() { - $this->factory = new ServeFileResponseFactory( - true, + $this->factory = new ServeFileResponseFactory(true, new \unicode()); + $request = Request::create('/'); + $request->headers->set('X-SendFile-Type', 'X-Accel-Redirect'); + $request->headers->set('X-Accel-Mapping', (string) new Mapping(array( array( - __DIR__ . '/../../../../files' => 'protected' - ), new \unicode()); + 'directory' => __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ) + ))); $response = $this->factory->deliverFile(__DIR__ . '/../../../../files/cestlafete.jpg', 'toto.jpg', 'attachment'); + $response->prepare($request); $this->assertInstanceOf("Symfony\Component\HttpFoundation\Response", $response); $this->assertEquals('attachment; filename="toto.jpg"', $response->headers->get('content-disposition')); @@ -85,37 +85,36 @@ class ServeFileResponseFactoryTest extends \PhraseanetWebTestCaseAbstract */ public function testDeliverUnexistingFile() { - $this->factory = new ServeFileResponseFactory( - true, - array( - __DIR__ . '/../../../../files' => 'protected' - ), new \unicode()); + $this->factory = new ServeFileResponseFactory(true, new \unicode()); $this->factory->deliverFile(__DIR__ . '/../../../../files/does_not_exists.jpg', 'toto.jpg', 'attachment'); } public function testDeliverFileWithFilenameAndDispositionAndXSendFileButFileNotInXAccelMapping() { - $this->factory = new ServeFileResponseFactory( - true, + $this->factory = new ServeFileResponseFactory(true, new \unicode()); + $request = Request::create('/'); + $request->headers->set('X-SendFile-Type', 'X-Accel-Redirect'); + $request->headers->set('X-Accel-Mapping', (string) new Mapping(array( array( - __DIR__ . '/../../../../files' => 'protected' - ), new \unicode()); + 'directory' => __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ) + ))); - $response = $this->factory->deliverFile(__DIR__ . '/../../../../classes/PhraseanetPHPUnitAbstract.php', 'PhraseanetPHPUnitAbstract.php', 'attachment'); + $file = __DIR__ . '/../../../../classes/PhraseanetPHPUnitAbstract.php'; + + $response = $this->factory->deliverFile($file, 'PhraseanetPHPUnitAbstract.php', 'attachment'); + $response->prepare($request); $this->assertInstanceOf("Symfony\Component\HttpFoundation\Response", $response); $this->assertEquals('attachment; filename="PhraseanetPHPUnitAbstract.php"', $response->headers->get('content-disposition')); - $this->assertEquals(null, $response->headers->get('x-accel-redirect')); + $this->assertEquals(realpath($file), $response->headers->get('x-accel-redirect')); } public function testDeliverDatas() { - $this->factory = new ServeFileResponseFactory( - false, - array( - __DIR__ . '/../../../../files/' => '/protected/' - ), new \unicode()); + $this->factory = new ServeFileResponseFactory(false, new \unicode()); $data = 'Sex,Name,Birthday M,Alphonse,1932 diff --git a/tests/Alchemy/Tests/Phrasea/XSendFile/MappingTest.php b/tests/Alchemy/Tests/Phrasea/XSendFile/MappingTest.php new file mode 100644 index 0000000000..1bfdd43214 --- /dev/null +++ b/tests/Alchemy/Tests/Phrasea/XSendFile/MappingTest.php @@ -0,0 +1,59 @@ + __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ) + )); + + $this->assertEquals('/protected/=/home/nlegoff/workspace/Phraseanet/tests/files/', (string) $mapping); + } + + public function testMultiMapping() + { + $mapping = new Mapping(array( + array( + 'directory' => __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ), + array( + 'directory' => __DIR__ . '/../../../../', + 'mount-point' => '/uploads/' + ), + )); + + $this->assertEquals('/protected/=/home/nlegoff/workspace/Phraseanet/tests/files/,/uploads/=/home/nlegoff/workspace/Phraseanet/tests/', (string) $mapping); + } + + public function testMultiMappingWithANotExsistingDir() + { + $mapping = new Mapping(array( + array( + 'directory' => __DIR__ . '/../../../../files/', + 'mount-point' => '/protected/' + ), + array( + 'directory' => __DIR__ . '/../../../../do_not_exists', + 'mount-point' => '/test/' + ), + )); + + $this->assertEquals('/protected/=/home/nlegoff/workspace/Phraseanet/tests/files/', (string) $mapping); + } + + public function testEmptyMapping() + { + $mapping = new Mapping(array()); + + $this->assertEquals('', (string) $mapping); + } +} \ No newline at end of file