diff --git a/composer.json b/composer.json index 84bed4fa74..e40850c45c 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "alchemy/oauth2php": "1.0.0", "alchemy/phlickr": "0.2.9", "alchemy/rest-bundle": "master@dev", - "alchemy/symfony-cors": "master@dev", + "alchemy/symfony-cors": "^0.1.0", "alchemy/task-manager": "2.0.x-dev@dev", "alchemy/zippy": "0.2.x-dev@dev", "beberlei/assert": "^2.3", diff --git a/composer.lock b/composer.lock index 39d96132ca..b23c1eb501 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "9ad403d1241ac6e7c1d6a8ee1a88b562", - "content-hash": "afa161367fc67b099314aecbff0ab9d8", + "hash": "060bc97259e610db725a2f75af4cccc0", + "content-hash": "691d34f60fc60114d275eab15a3e9de5", "packages": [ { "name": "alchemy-fr/tcpdf-clone", @@ -15,6 +15,12 @@ "url": "https://github.com/alchemy-fr/tcpdf-clone.git", "reference": "2ba0248a7187f1626df6c128750650416267f0e7" }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/alchemy-fr/tcpdf-clone/zipball/2ba0248a7187f1626df6c128750650416267f0e7", + "reference": "2ba0248a7187f1626df6c128750650416267f0e7", + "shasum": "" + }, "require": { "php": ">=5.3.0" }, @@ -61,6 +67,10 @@ "qrcode", "tcpdf" ], + "support": { + "source": "https://github.com/alchemy-fr/tcpdf-clone/tree/6.0.039", + "issues": "https://github.com/alchemy-fr/tcpdf-clone/issues" + }, "time": "2013-10-13 16:11:17" }, { @@ -104,7 +114,7 @@ "homepage": "http://www.lickmychip.com/" }, { - "name": "Nicolas Le Goff", + "name": "nlegoff", "email": "legoff.n@gmail.com" }, { @@ -304,7 +314,7 @@ }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/alchemy-fr/rest-bundle/zipball/7b1c88c02ab8c0d4e997fd61c13c8fd4c3ce5216", + "url": "https://api.github.com/repos/alchemy-fr/rest-bundle/zipball/a0e2c2b8a1d2c9c405fc9663b698f56a20831946", "reference": "7b1c88c02ab8c0d4e997fd61c13c8fd4c3ce5216", "shasum": "" }, @@ -347,11 +357,11 @@ }, { "name": "alchemy/symfony-cors", - "version": "dev-master", + "version": "0.1.0", "source": { "type": "git", "url": "https://github.com/alchemy-fr/symfony-cors", - "reference": "88f2d4089e252153946e283ac89631e78b4d73a5" + "reference": "8aafaff26e29fdd57358062d20724109a349def5" }, "require": { "symfony/http-kernel": "^2.3.0|^3.0.0" @@ -361,6 +371,11 @@ "silex/silex": "^1.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.1.x-dev" + } + }, "autoload": { "psr-4": { "Alchemy\\Cors\\": "src/Component/", @@ -383,7 +398,7 @@ } ], "description": "A library that adds CORS services to Silex/Symfony Applications", - "time": "2015-10-12 11:04:31" + "time": "2015-11-20 17:55:08" }, { "name": "alchemy/task-manager", @@ -1473,12 +1488,12 @@ "source": { "type": "git", "url": "https://github.com/igorw/evenement.git", - "reference": "v1.0.0" + "reference": "fa966683e7df3e5dd5929d984a44abfbd6bafe8d" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/igorw/evenement/zipball/fa966683e7df3e5dd5929d984a44abfbd6bafe8d", - "reference": "v1.0.0", + "reference": "fa966683e7df3e5dd5929d984a44abfbd6bafe8d", "shasum": "" }, "require": { @@ -1505,7 +1520,7 @@ "keywords": [ "event-dispatcher" ], - "time": "2012-05-30 08:01:08" + "time": "2012-05-30 15:01:08" }, { "name": "facebook/php-sdk", @@ -2631,7 +2646,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-develop": "0.7-dev" + "dev-develop": "0.6-dev" } }, "autoload": { @@ -3355,7 +3370,7 @@ ], "authors": [ { - "name": "Stephen Clay", + "name": "Steve Clay", "email": "steve@mrclay.org", "homepage": "http://www.mrclay.org/", "role": "Developer" @@ -3541,21 +3556,21 @@ "source": { "type": "git", "url": "https://github.com/romainneutron/Imagine-Silex-Service-Provider.git", - "reference": "0.1.2" + "reference": "a8a7862ae90419f2b23746cd8436c2310e4eb084" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/romainneutron/Imagine-Silex-Service-Provider/zipball/a8a7862ae90419f2b23746cd8436c2310e4eb084", - "reference": "0.1.2", + "reference": "a8a7862ae90419f2b23746cd8436c2310e4eb084", "shasum": "" }, "require": { "imagine/imagine": "*", "php": ">=5.3.3", - "silex/silex": ">=1.0,<2.0" + "silex/silex": "~1.0" }, "require-dev": { - "symfony/browser-kit": ">=2.0,<3.0" + "symfony/browser-kit": "~2.0" }, "type": "library", "autoload": { @@ -4189,7 +4204,9 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" } ], "description": "Pimple is a simple Dependency Injection Container for PHP 5.3", @@ -4318,7 +4335,7 @@ }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/583b8eba0bcc0ac9aa8e7309e5313c068dbfbe30", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/e9478830dd1d19ee598ecfb2a6b5e6a4f8f168c2", "reference": "583b8eba0bcc0ac9aa8e7309e5313c068dbfbe30", "shasum": "" }, @@ -4823,7 +4840,7 @@ }, { "name": "Phraseanet Team", - "email": "support@alchemy.fr", + "email": "info@alchemy.fr", "homepage": "http://www.phraseanet.com/" } ], @@ -6215,7 +6232,6 @@ "minimum-stability": "stable", "stability-flags": { "alchemy/rest-bundle": 20, - "alchemy/symfony-cors": 20, "alchemy/task-manager": 20, "alchemy/zippy": 20, "goodby/csv": 20, diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index 9a2553ceb3..ca6b5a6ab5 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -11,6 +11,8 @@ namespace Alchemy\Phrasea; +use Alchemy\Cors\Options\DefaultProvider; +use Alchemy\CorsProvider\CorsServiceProvider; use Alchemy\Geonames\GeonamesServiceProvider; use Alchemy\Phrasea\Application\Helper\AclAware; use Alchemy\Phrasea\Application\Helper\ApplicationBoxAware; @@ -267,6 +269,29 @@ class Application extends SilexApplication $this->register(new PluginServiceProvider()); $this->register(new PhraseaEventServiceProvider()); $this->register(new ContentNegotiationServiceProvider()); + $this->register(new CorsServiceProvider(), [ + 'alchemy_cors.debug' => $this['debug'], + 'alchemy_cors.cache_path' => function (Application $app) { + return rtrim($app['cache.path'], '/\\') . '/alchemy_cors.cache.php'; + }, + ]); + $this['phraseanet.api_cors.options_provider'] = function (Application $app) { + $paths = []; + + if (isset($app['phraseanet.configuration']['api_cors'])) { + $config = $app['phraseanet.configuration']['api_cors']; + + if (isset($config['enabled']) && $config['enabled']) { + unset($config['enabled']); + + $paths['/api/v\d+/'] = $config; + } + } + + return new DefaultProvider($paths, []); + }; + + $this['alchemy_cors.options_providers'][] = 'phraseanet.api_cors.options_provider'; $this->register(new LocaleServiceProvider()); $this->setupEventDispatcher(); $this['phraseanet.exception_handler'] = $this->share(function ($app) { diff --git a/lib/Alchemy/Phrasea/Application/Api.php b/lib/Alchemy/Phrasea/Application/Api.php index 44c57c02e1..b329603eb9 100644 --- a/lib/Alchemy/Phrasea/Application/Api.php +++ b/lib/Alchemy/Phrasea/Application/Api.php @@ -20,7 +20,6 @@ use Alchemy\Phrasea\ControllerProvider\MediaAccessor; use Alchemy\Phrasea\ControllerProvider\Minifier; use Alchemy\Phrasea\ControllerProvider\Permalink; use Alchemy\Phrasea\Core\Event\ApiResultEvent; -use Alchemy\Phrasea\Core\Event\Subscriber\ApiCorsSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\ApiExceptionHandlerSubscriber; use Alchemy\Phrasea\Core\Event\Subscriber\ApiOauth2ErrorsSubscriber; use Alchemy\Phrasea\Core\PhraseaEvents; @@ -30,6 +29,7 @@ use Monolog\Processor\WebProcessor; use Silex\Application as SilexApplication; use Silex\Provider\WebProfilerServiceProvider; use Sorien\Provider\DoctrineProfilerServiceProvider; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -85,7 +85,7 @@ return call_user_func(function ($environment = PhraseaApplication::ENV_PROD) { )); }, PhraseaApplication::EARLY_EVENT); - $app->after(function(Request $request, Response $response) use ($app) { + $app->after(function(Request $request, Response $response) { if ($request->getRequestFormat(Result::FORMAT_JSON) === Result::FORMAT_JSONP && !$response->isOk() && !$response->isServerError()) { $response->setStatusCode(200); } @@ -148,7 +148,7 @@ return call_user_func(function ($environment = PhraseaApplication::ENV_PROD) { } } - $app['dispatcher'] = $app->share($app->extend('dispatcher', function ($dispatcher, PhraseaApplication $app) { + $app['dispatcher'] = $app->share($app->extend('dispatcher', function (EventDispatcherInterface $dispatcher, PhraseaApplication $app) { $dispatcher->addSubscriber(new ApiOauth2ErrorsSubscriber($app['phraseanet.exception_handler'], $app['translator'])); return $dispatcher; @@ -156,7 +156,6 @@ return call_user_func(function ($environment = PhraseaApplication::ENV_PROD) { $app->after(function (Request $request, Response $response) use ($app) { $app['dispatcher']->dispatch(PhraseaEvents::API_RESULT, new ApiResultEvent($request, $response)); }); - $app['dispatcher']->addSubscriber(new ApiCorsSubscriber($app)); return $app; }, isset($environment) ? $environment : PhraseaApplication::ENV_PROD); diff --git a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php b/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php deleted file mode 100644 index 92d0567d73..0000000000 --- a/lib/Alchemy/Phrasea/Core/Event/Subscriber/ApiCorsSubscriber.php +++ /dev/null @@ -1,207 +0,0 @@ -app = $app; - } - - public static function getSubscribedEvents() - { - return [ - KernelEvents::REQUEST => ['onKernelRequest', 128], - ]; - } - - public function onKernelRequest(GetResponseEvent $event) - { - if (!$this->app['phraseanet.configuration']['api_cors']['enabled']) { - return; - } - if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) { - return; - } - $request = $event->getRequest(); - - if (!preg_match('{api/v(\d+)}i', $request->getPathInfo() ?: '/')) { - return; - } - - // skip if not a CORS request - if (!$request->headers->has('Origin') || $request->headers->get('Origin') == $request->getSchemeAndHttpHost()) { - return; - } - - $options = array_merge([ - 'allow_credentials'=> false, - 'allow_origin'=> [], - 'allow_headers'=> [], - 'allow_methods'=> [], - 'expose_headers'=> [], - 'max_age'=> 0, - 'hosts'=> [], - ], $this->app['phraseanet.configuration']['api_cors']); - - // skip if the host is not matching - if (!$this->checkHost($request, $options)) { - return; - } - // perform preflight checks - if ('OPTIONS' === $request->getMethod()) { - $event->setResponse($this->getPreflightResponse($request, $options)); - - return; - } - if (!$this->checkOrigin($request, $options)) { - $response = new Response('', 403, ['Access-Control-Allow-Origin' => 'null']); - $event->setResponse($response); - - return; - } - - $this->app['dispatcher']->addListener(KernelEvents::RESPONSE, [$this, 'onKernelResponse']); - $this->options = $options; - } - - public function onKernelResponse(FilterResponseEvent $event) - { - if (HttpKernelInterface::MASTER_REQUEST !== $event->getRequestType()) { - return; - } - - $response = $event->getResponse(); - $request = $event->getRequest(); - // add CORS response headers - $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); - if ($this->options['allow_credentials']) { - $response->headers->set('Access-Control-Allow-Credentials', 'true'); - } - if ($this->options['expose_headers']) { - $response->headers->set('Access-Control-Expose-Headers', strtolower(implode(', ', $this->options['expose_headers']))); - } - } - - protected function getPreflightResponse(Request $request, array $options) - { - $response = new Response(); - - if ($options['allow_credentials']) { - $response->headers->set('Access-Control-Allow-Credentials', 'true'); - } - if ($options['allow_methods']) { - $response->headers->set('Access-Control-Allow-Methods', implode(', ', $options['allow_methods'])); - } - if ($options['allow_headers']) { - $headers = in_array('*', $options['allow_headers']) - ? $request->headers->get('Access-Control-Request-Headers') - : implode(', ', array_map('strtolower', $options['allow_headers'])); - $response->headers->set('Access-Control-Allow-Headers', $headers); - } - if ($options['max_age']) { - $response->headers->set('Access-Control-Max-Age', $options['max_age']); - } - - if (!$this->checkOrigin($request, $options)) { - $response->headers->set('Access-Control-Allow-Origin', 'null'); - - return $response; - } - - $response->headers->set('Access-Control-Allow-Origin', $request->headers->get('Origin')); - - // check request method - if (!in_array(strtoupper($request->headers->get('Access-Control-Request-Method')), $options['allow_methods'], true)) { - $response->setStatusCode(405); - - return $response; - } - - /** - * We have to allow the header in the case-set as we received it by the client. - * Firefox f.e. sends the LINK method as "Link", and we have to allow it like this or the browser will deny the - * request. - */ - if (!in_array($request->headers->get('Access-Control-Request-Method'), $options['allow_methods'], true)) { - $options['allow_methods'][] = $request->headers->get('Access-Control-Request-Method'); - $response->headers->set('Access-Control-Allow-Methods', implode(', ', $options['allow_methods'])); - } - - // check request headers - $headers = $request->headers->get('Access-Control-Request-Headers'); - if ($options['allow_headers'] !== true && $headers) { - $headers = trim(strtolower($headers)); - foreach (preg_split('{, *}', $headers) as $header) { - if (in_array($header, self::$simpleHeaders, true)) { - continue; - } - if (!in_array($header, $options['allow_headers'], true)) { - $response->setStatusCode(400); - $response->setContent('Unauthorized header '.$header); - break; - } - } - } - - return $response; - } - - protected function checkOrigin(Request $request, array $options) - { - // check origin - $origin = $request->headers->get('Origin'); - if (in_array('*', $options['allow_origin']) || in_array($origin, $options['allow_origin'])) { - return true; - } - - return false; - } - - protected function checkHost(Request $request, array $options) - { - if (count($options['hosts']) === 0) { - return true; - } - - foreach ($options['hosts'] as $hostRegexp) { - if (preg_match('{'.$hostRegexp.'}i', $request->getHost())) { - return true; - } - } - - return false; - } -} diff --git a/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php b/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php deleted file mode 100644 index 84cd2d7eb9..0000000000 --- a/tests/Alchemy/Tests/Phrasea/Core/Event/Subscriber/ApiCorsSubscriberTest.php +++ /dev/null @@ -1,106 +0,0 @@ -request(['enabled' => true, 'hosts' => ['http://api.domain.com']]); - $this->assertArrayNotHasKey('access-control-allow-origin', $response->headers->all()); - - $response = $this->request(['enabled' => true, 'hosts' => ['localhost']]); - $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); - } - - public function testExposeHeaders() - { - $response = $this->request( - ['enabled' => true, 'allow_origin' => ['*'], 'expose_headers' => ['HTTP_X_CUSTOM']], - 'GET' - ); - $this->assertArrayHasKey('access-control-expose-headers', $response->headers->all()); - $this->assertEquals('http_x_custom', $response->headers->get('access-control-expose-headers')); - } - - public function testAllowMethods() - { - $response = $this->request( - ['enabled' => true, 'allow_origin' => ['*'], 'allow_methods' => ['GET', 'POST', 'PUT']], - 'OPTIONS' - ); - $this->assertArrayHasKey('access-control-allow-methods', $response->headers->all()); - $this->assertEquals(implode(', ', ['GET', 'POST', 'PUT']), $response->headers->get('access-control-allow-methods')); - } - - public function testAllowHeaders() - { - $response = $this->request( - ['enabled' => true, 'allow_origin' => ['*'], 'allow_headers' => ['HTTP_X_CUSTOM']], - 'OPTIONS' - ); - $this->assertArrayHasKey('access-control-allow-headers', $response->headers->all()); - $this->assertEquals('http_x_custom', $response->headers->get('access-control-allow-headers')); - } - - public function testCORSIsEnable() - { - $response = $this->request(['enabled' => true]); - $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); - } - - public function testCORSIsDisable() - { - $response = $this->request(['enabled' => false]); - $this->assertArrayNotHasKey('access-control-allow-origin', $response->headers->all()); - } - - public function testAllowOrigin() - { - $response = $this->request(['enabled' => true, 'allow_origin' => ['*']]); - $this->assertArrayHasKey('access-control-allow-origin', $response->headers->all()); - $this->assertEquals($this->origin, $response->headers->get('access-control-allow-origin')); - } - - public function testCredentialIsEnabled() - { - $response = $this->request(['enabled' => true, 'allow_credentials' => true, 'allow_origin' => ['*']]); - $this->assertArrayHasKey('access-control-allow-credentials', $response->headers->all()); - } - - /** - * @param array $conf - * @param string $method - * @param array $extraHeaders - * - * @return \Symfony\Component\HttpFoundation\Response - */ - private function request(array $conf, $method = 'GET', array $extraHeaders = []) - { - $app = new Application(Application::ENV_TEST); - $app['phraseanet.configuration']['api_cors'] = $conf; - $app['dispatcher']->addSubscriber(new ApiCorsSubscriber($app)); - $app->get('/api/v1/test-route', function () { - return ''; - }); - $client = new Client($app); - $client->request($method, '/api/v1/test-route', - [], - [], - array_merge( - $extraHeaders, - [ - 'HTTP_Origin' => $this->origin, - ] - ) - ); - - return $client->getResponse(); - } -}