diff --git a/lib/Alchemy/Phrasea/Application.php b/lib/Alchemy/Phrasea/Application.php index 495c67806c..15c1894161 100644 --- a/lib/Alchemy/Phrasea/Application.php +++ b/lib/Alchemy/Phrasea/Application.php @@ -69,6 +69,7 @@ use Alchemy\Phrasea\Controller\Thesaurus\Thesaurus; use Alchemy\Phrasea\Controller\Thesaurus\Xmlhttp as ThesaurusXMLHttp; use Alchemy\Phrasea\Controller\User\Notifications; use Alchemy\Phrasea\Controller\User\Preferences; +use Alchemy\Phrasea\ControllerProvider\MediaAccessor; use Alchemy\Phrasea\Core\PhraseaExceptionHandler; use Alchemy\Phrasea\Core\Provider\AccountServiceProvider; use Alchemy\Phrasea\Core\Provider\AuthenticationManagerServiceProvider; @@ -411,10 +412,12 @@ class Application extends SilexApplication $this['log.channels'] = array('monolog', 'task-manager.logger'); $this->register(new LocaleServiceProvider()); + $this->register(new MediaAccessor()); $this->mount('/include/minify/', new Minifier()); $this->mount('/permalink/', new Permalink()); $this->mount('/lightbox/', new Lightbox()); + $this->mount($this['controller.media_accessor.route_prefix'], new MediaAccessor()); $app['plugins.directory'] = $app->share(function () { $dir = __DIR__ . '/../../../plugins'; diff --git a/lib/Alchemy/Phrasea/Controller/MediaAccessorController.php b/lib/Alchemy/Phrasea/Controller/MediaAccessorController.php new file mode 100644 index 0000000000..e724d34797 --- /dev/null +++ b/lib/Alchemy/Phrasea/Controller/MediaAccessorController.php @@ -0,0 +1,120 @@ +app = $app; + } + + /** + * @return \appbox + */ + public function getApplicationBox() + { + return $this->app['phraseanet.appbox']; + } + + /** + * @param int $id + * @return \databox + */ + public function findDataboxById($id) + { + $appbox = $this->getApplicationBox(); + + return $appbox->get_databox($id); + } + /** + * @param array|\ArrayAccess $keyStorage + * @return $this + */ + public function setKeyStorage($keyStorage) + { + if (!is_array($keyStorage) && !$keyStorage instanceof \ArrayAccess) { + throw new \InvalidArgumentException(sprintf( + 'expects $keyStorage to be an array or an instance of ArrayAccess, got %s', + is_object($keyStorage) ? get_class($keyStorage) : gettype($keyStorage) + )); + } + $this->keyStorage = $keyStorage; + + return $this; + } + + /** + * @param array $allowedAlgorithms + * @return $this + */ + public function setAllowedAlgorithms(array $allowedAlgorithms) + { + $this->allowedAlgorithms = $allowedAlgorithms; + + return $this; + } + + public function showAction(Request $request, $token) + { + try { + $token = JWT::decode($token, $this->keyStorage, $this->allowedAlgorithms); + } catch (\Exception $exception) { + throw new BadRequestHttpException('invalid token', $exception); + } + + if (! isset($token->sdef) || !is_array($token->sdef) || count($token->sdef) !== 3) { + throw new BadRequestHttpException('sdef should be a sub-definition identifier.'); + } + list ($sbas_id, $record_id, $subdef) = $token->sdef; + + try { + $databox = $this->findDataboxById($sbas_id); + $record = $databox->get_record($record_id); + $subDefinition = $record->get_subdef($subdef); + $permalink = $subDefinition->get_permalink(); + } catch (\Exception $exception) { + throw new NotFoundHttpException('Media was not found', $exception); + } + + $subRequest = Request::create( + (string) $permalink->get_url(), + 'GET', + [], + $request->cookies->all(), + [], + $request->server->all() + ); + + if ($request->query->has('download')) { + $subRequest->query->set('download', $request->query->get('download')); + } + + $response = $this->app->handle($subRequest, HttpKernelInterface::SUB_REQUEST, false); + // Remove Caption link header as it contains permalink token. + $response->headers->remove('link'); + + return $response; + } +} diff --git a/lib/Alchemy/Phrasea/ControllerProvider/MediaAccessor.php b/lib/Alchemy/Phrasea/ControllerProvider/MediaAccessor.php new file mode 100644 index 0000000000..14b2d76949 --- /dev/null +++ b/lib/Alchemy/Phrasea/ControllerProvider/MediaAccessor.php @@ -0,0 +1,63 @@ +share(function (Application $app) { + /** @var EntityManager $manager */ + $manager = $app['EM']; + + return $manager->getRepository('Entities\Secret'); + }); + + $app['provider.secrets'] = $app->share(function (Application $app) { + return new DefaultSecretProvider($app['repo.secrets'], $app['random.medium']); + }); + + $app['controller.media_accessor'] = $app->share(function (Application $app) { + $controller = new MediaAccessorController($app); + + $controller + ->setAllowedAlgorithms(['HS256']) + ->setKeyStorage($app['provider.secrets']); + + return $controller; + }); + + $app['controller.media_accessor.route_prefix'] = '/medias'; + } + + public function boot(Application $app) + { + // Intentionally left empty + } + + public function connect(Application $app) + { + /** @var ControllerCollection $controllers */ + $controllers = $app['controllers_factory']; + + $controllers->get('/{token}', 'controller.media_accessor:showAction') + ->bind('media_accessor'); + + return $controllers; + } +} diff --git a/lib/Alchemy/Phrasea/Model/Provider/DefaultSecretProvider.php b/lib/Alchemy/Phrasea/Model/Provider/DefaultSecretProvider.php new file mode 100644 index 0000000000..006e5197db --- /dev/null +++ b/lib/Alchemy/Phrasea/Model/Provider/DefaultSecretProvider.php @@ -0,0 +1,71 @@ +repository = $repository; + $this->generator = $generator; + } + + public function getSecretForUser($userId) + { + $secret = $this->repository->findOneBy(['creatorId' => $userId], ['created' => 'DESC']); + if ($secret) { + return $secret; + } + + $token = $this->generator->generateString(64, Generator::CHAR_ALNUM | Generator::CHAR_SYMBOLS); + + $secret = new Secret($userId, $token); + $this->repository->save($secret); + + return $secret; + } + + public function offsetExists($offset) + { + return null !== $this->repository->find($offset); + } + + public function offsetGet($offset) + { + $secret = $this->repository->find($offset); + if (!$secret instanceof Secret) { + throw new \RuntimeException('Undefined index: ' . $offset); + } + + return $secret->getToken(); + } + + public function offsetSet($offset, $value) + { + throw new \LogicException('This ArrayAccess is non mutable.'); + } + + public function offsetUnset($offset) + { + throw new \LogicException('This ArrayAccess is non mutable.'); + } +} diff --git a/lib/Alchemy/Phrasea/Model/Provider/SecretProvider.php b/lib/Alchemy/Phrasea/Model/Provider/SecretProvider.php new file mode 100644 index 0000000000..c8d2508535 --- /dev/null +++ b/lib/Alchemy/Phrasea/Model/Provider/SecretProvider.php @@ -0,0 +1,22 @@ +_em->persist($secret); + $this->_em->flush($secret); + } +} diff --git a/lib/classes/API/V1/adapter.php b/lib/classes/API/V1/adapter.php index be9475f21e..60fdc8476c 100644 --- a/lib/classes/API/V1/adapter.php +++ b/lib/classes/API/V1/adapter.php @@ -15,12 +15,14 @@ use Alchemy\Phrasea\Account\CollectionRequestMapper; use Alchemy\Phrasea\Account\Command\UpdateAccountCommand; use Alchemy\Phrasea\Account\Command\UpdatePasswordCommand; use Alchemy\Phrasea\Form\Login\PhraseaRenewPasswordForm; +use Alchemy\Phrasea\Model\Provider\SecretProvider; use Alchemy\Phrasea\SearchEngine\SearchEngineOptions; use Alchemy\Phrasea\SearchEngine\SearchEngineSuggestion; use Alchemy\Phrasea\Application; use Alchemy\Phrasea\Border\File; use Alchemy\Phrasea\Border\Attribute\Status; use Alchemy\Phrasea\Border\Manager as BorderManager; +use Firebase\JWT\JWT; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Alchemy\Phrasea\Core\PhraseaEvents; @@ -583,7 +585,7 @@ class API_V1_adapter extends API_V1_Abstract $record->substitute_subdef($request->get('name'), $media, $app, $adapt); foreach ($record->get_embedable_medias() as $name => $media) { if ($name == $request->get('name') && - null !== ($subdef = $this->list_embedable_media($record, $media, $this->app['phraseanet.registry']))) { + null !== ($subdef = $this->listEmbeddableMedia($request, $record, $media, $this->app['phraseanet.registry']))) { $ret[] = $subdef; } } @@ -682,9 +684,9 @@ class API_V1_adapter extends API_V1_Abstract foreach ($search_result->getResults() as $record) { if ($record->is_grouping()) { - $ret['results']['stories'][] = $this->list_story($record, $request->get('_extended')); + $ret['results']['stories'][] = $this->list_story($request, $record, $request->get('_extended')); } else { - $ret['results']['records'][] = $this->list_record($record, $request->get('_extended')); + $ret['results']['records'][] = $this->list_record($request, $record, $request->get('_extended')); } } @@ -712,7 +714,7 @@ class API_V1_adapter extends API_V1_Abstract list($ret, $search_result) = $this->prepare_search_request($request); foreach ($search_result->getResults() as $record) { - $ret['results'][] = $this->list_record($record, $request->get('_extended')); + $ret['results'][] = $this->list_record($request, $record, $request->get('_extended')); } /** @@ -748,7 +750,7 @@ class API_V1_adapter extends API_V1_Abstract $record = $this->app['phraseanet.appbox']->get_databox($databox_id)->get_record($record_id); $stories = array_map(function ($story) use ($that, $request) { - return $that->list_story($story, $request->get('_extended')); + return $that->list_story($request, $story, $request->get('_extended')); }, array_values($record->get_grouping_parents()->get_elements())); $result->set_datas(array( @@ -823,7 +825,7 @@ class API_V1_adapter extends API_V1_Abstract $mimes = $request->get('mimes', array()); foreach ($record->get_embedable_medias($devices, $mimes) as $name => $media) { - if (null !== $subdef = $this->list_embedable_media($record, $media, $this->app['phraseanet.registry'])) { + if (null !== $subdef = $this->listEmbeddableMedia($request, $record, $media, $this->app['phraseanet.registry'])) { $ret[] = $subdef; } } @@ -856,7 +858,7 @@ class API_V1_adapter extends API_V1_Abstract $mimes = $request->get('mimes', array()); foreach ($record->get_embedable_medias($devices, $mimes) as $name => $media) { - if (null !== $subdef = $this->list_embedable_media($record, $media, $this->app['phraseanet.registry'])) { + if (null !== $subdef = $this->listEmbeddableMedia($request, $record, $media, $this->app['phraseanet.registry'])) { $ret[] = $subdef; } } @@ -971,7 +973,7 @@ class API_V1_adapter extends API_V1_Abstract try { $collection = collection::get_from_base_id($this->app, $request->get('base_id')); $record->move_to_collection($collection, $this->app['phraseanet.appbox']); - $result->set_datas(array("record" => $this->list_record($record, $request->get('_extended')))); + $result->set_datas(array("record" => $this->list_record($request, $record, $request->get('_extended')))); } catch (\Exception $e) { $result->set_error_message(API_V1_result::ERROR_BAD_REQUEST, $e->getMessage()); } @@ -993,7 +995,7 @@ class API_V1_adapter extends API_V1_Abstract $databox = $this->app['phraseanet.appbox']->get_databox($databox_id); try { $record = $databox->get_record($record_id); - $result->set_datas(array('record' => $this->list_record($record, $request->get('_extended')))); + $result->set_datas(array('record' => $this->list_record($request, $record, $request->get('_extended')))); } catch (NotFoundHttpException $e) { $result->set_error_message(API_V1_result::ERROR_BAD_REQUEST, _('Record Not Found')); } catch (\Exception $e) { @@ -1017,7 +1019,7 @@ class API_V1_adapter extends API_V1_Abstract $databox = $this->app['phraseanet.appbox']->get_databox($databox_id); try { $story = $databox->get_record($story_id); - $result->set_datas(array('story' => $this->list_story($story, $request->get('_extended')))); + $result->set_datas(array('story' => $this->list_story($request, $story, $request->get('_extended')))); } catch (NotFoundHttpException $e) { $result->set_error_message(API_V1_result::ERROR_BAD_REQUEST, _('Story Not Found')); } catch (\Exception $e) { @@ -1135,7 +1137,7 @@ class API_V1_adapter extends API_V1_Abstract $result->set_datas( array( "basket" => $this->list_basket($Basket), - "basket_elements" => $this->list_basket_content($Basket, $request->get('_extended')) + "basket_elements" => $this->list_basket_content($request, $Basket, $request->get('_extended')) ) ); @@ -1145,17 +1147,17 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve elements of one basket * + * @param Request $request * @param \Entities\Basket $Basket * @param bool $extended - * * @return array */ - protected function list_basket_content(\Entities\Basket $Basket, $extended = false) + protected function list_basket_content(Request $request, \Entities\Basket $Basket, $extended = false) { $ret = array(); foreach ($Basket->getElements() as $basket_element) { - $ret[] = $this->list_basket_element($basket_element, $extended); + $ret[] = $this->list_basket_element($request, $basket_element, $extended); } return $ret; @@ -1164,17 +1166,17 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about a basket element * + * @param Request $request * @param \Entities\BasketElement $basket_element * @param bool $extended - * * @return array */ - protected function list_basket_element(\Entities\BasketElement $basket_element, $extended = false) + protected function list_basket_element(Request $request, \Entities\BasketElement $basket_element, $extended = false) { $ret = array( 'basket_element_id' => $basket_element->getId(), 'order' => $basket_element->getOrd(), - 'record' => $this->list_record($basket_element->getRecord($this->app), $extended), + 'record' => $this->list_record($request, $basket_element->getRecord($this->app), $extended), 'validation_item' => null != $basket_element->getBasket()->getValidation(), ); @@ -1330,7 +1332,13 @@ class API_V1_adapter extends API_V1_Abstract 'feed' => $this->list_publication($feed, $user), 'offset_start' => $offset_start, 'per_page' => $per_page, - 'entries' => $this->list_publications_entries($feed, $request->get('_extended'), $offset_start, $per_page), + 'entries' => $this->list_publications_entries( + $request, + $feed, + $request->get('_extended'), + $offset_start, + $per_page + ), ); $result->set_datas($data); @@ -1363,7 +1371,13 @@ class API_V1_adapter extends API_V1_Abstract 'total_entries' => $feed->get_count_total_entries(), 'offset_start' => $offset_start, 'per_page' => $per_page, - 'entries' => $this->list_publications_entries($feed, $request->get('_extended'), $offset_start, $per_page), + 'entries' => $this->list_publications_entries( + $request, + $feed, + $request->get('_extended'), + $offset_start, + $per_page + ), )); return $result; @@ -1391,7 +1405,7 @@ class API_V1_adapter extends API_V1_Abstract throw new \API_V1_exception_forbidden('You have not access to the parent feed'); } - $data = array('entry' => $this->list_publication_entry($entry),); + $data = array('entry' => $this->list_publication_entry($request, $entry),); $result->set_datas($data); @@ -1424,20 +1438,26 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve all entries of one feed * + * @param Request $request * @param Feed_Abstract $feed - * @param bool $extended - * @param int $offset_start - * @param int $how_many - * + * @param bool $extended + * @param int $offset_start + * @param int $how_many * @return array */ - protected function list_publications_entries(Feed_Abstract $feed, $extended = false, $offset_start = 0, $how_many = 5) + protected function list_publications_entries( + Request $request, + Feed_Abstract $feed, + $extended = false, + $offset_start = 0, + $how_many = 5 + ) { $entries = $feed->get_entries($offset_start, $how_many)->get_entries(); $out = array(); foreach ($entries as $entry) { - $out[] = $this->list_publication_entry($entry, $extended); + $out[] = $this->list_publication_entry($request, $entry, $extended); } return $out; @@ -1446,16 +1466,16 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about one feed entry * + * @param Request $request * @param Feed_Entry_Adapter $entry * @param bool $extended - * * @return array */ - protected function list_publication_entry(Feed_Entry_Adapter $entry, $extended = false) + protected function list_publication_entry(Request $request, Feed_Entry_Adapter $entry, $extended = false) { $items = array(); foreach ($entry->get_content() as $item) { - $items[] = $this->list_publication_entry_item($item, $extended); + $items[] = $this->list_publication_entry_item($request, $item, $extended); } return array( @@ -1477,16 +1497,16 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about one feed entry item * + * @param Request $request * @param Feed_Entry_Item $item * @param bool $extended - * * @return array */ - protected function list_publication_entry_item(Feed_Entry_Item $item, $extended = false) + protected function list_publication_entry_item(Request $request, Feed_Entry_Item $item, $extended = false) { $data = array( 'item_id' => $item->get_id(), - 'record' => $this->list_record($item->get_record(), $extended) + 'record' => $this->list_record($request, $item->get_record(), $extended) ); return $data; @@ -1523,13 +1543,18 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about one sub definition * + * @param Request $request * @param record_adapter $record * @param media_subdef $media * @param registryInterface $registry - * * @return array|null */ - protected function list_embedable_media(\record_adapter $record, media_subdef $media, registryInterface $registry) + protected function listEmbeddableMedia( + Request $request, + \record_adapter $record, + media_subdef $media, + registryInterface $registry + ) { if (!$media->is_physically_present()) { return null; @@ -1551,6 +1576,15 @@ class API_V1_adapter extends API_V1_Abstract $permalink = null; } + $urlTTL = (int)$request->get( + 'subdef_url_ttl', + $registry->get('GV_default_subdef_url_ttl') + ); + if ($urlTTL < 0) { + $urlTTL = -1; + } + $issuer = $this->app['authentication']->getUser(); + return array( 'name' => $media->get_name(), 'substituted' => $media->is_substituted(), @@ -1563,9 +1597,31 @@ class API_V1_adapter extends API_V1_Abstract 'devices' => $media->getDevices(), 'player_type' => $media->get_type(), 'mime_type' => $media->get_mime(), + 'url' => $this->generateSubDefinitionUrl($issuer, $media, $urlTTL), + 'url_ttl' => $urlTTL, ); } + private function generateSubDefinitionUrl(User_Adapter $issuer, media_subdef $subdef, $url_ttl) + { + $payload = [ + 'iat' => time(), + 'iss' => $issuer->get_id(), + 'sdef' => [$subdef->get_sbas_id(), $subdef->get_record_id(), $subdef->get_name()], + ]; + if ($url_ttl >= 0) { + $payload['exp'] = $payload['iat'] + $url_ttl; + } + + /** @var SecretProvider $provider */ + $provider = $this->app['provider.secrets']; + $secret = $provider->getSecretForUser($issuer->get_id()); + + return $this->app->url('media_accessor', [ + 'token' => JWT::encode($payload, $secret->getToken(), 'HS256', $secret->getId()), + ]); + } + /** * Retrieve detailed information about one permalink * @@ -1713,12 +1769,12 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about one record * + * @param Request $request * @param record_adapter $record * @param bool $extended - * * @return array */ - public function list_record(record_adapter $record, $extended = false) + public function list_record(Request $request, record_adapter $record, $extended = false) { $technicalInformation = array(); foreach ($record->get_technical_infos() as $name => $value) { @@ -1739,7 +1795,12 @@ class API_V1_adapter extends API_V1_Abstract 'collection_id' => $record->get_collection_id(), 'base_id' => $record->get_base_id(), 'sha256' => $record->get_sha256(), - 'thumbnail' => $this->list_embedable_media($record, $record->get_thumbnail(), $this->app['phraseanet.registry']), + 'thumbnail' => $this->listEmbeddableMedia( + $request, + $record, + $record->get_thumbnail(), + $this->app['phraseanet.registry'] + ), 'technical_informations' => $technicalInformation, 'phrasea_type' => $record->get_type(), 'uuid' => $record->get_uuid(), @@ -1749,7 +1810,7 @@ class API_V1_adapter extends API_V1_Abstract $subdefs = $caption = array(); foreach ($record->get_embedable_medias(array(), array()) as $name => $media) { - if (null !== $subdef = $this->list_embedable_media($record, $media, $this->app['phraseanet.registry'])) { + if (null !== $subdef = $this->listEmbeddableMedia($request, $record, $media, $this->app['phraseanet.registry'])) { $subdefs[] = $subdef; } } @@ -1778,21 +1839,22 @@ class API_V1_adapter extends API_V1_Abstract /** * Retrieve detailed information about one story * + * @param Request $request * @param record_adapter $story * @param bool $extended - * * @return array * @throws API_V1_exception_notfound + * @throws Exception */ - public function list_story(record_adapter $story, $extended = false) + public function list_story(Request $request, record_adapter $story, $extended = false) { if (!$story->is_grouping()) { throw new \API_V1_exception_notfound('Story not found'); } $that = $this; - $records = array_map(function (\record_adapter $record) use ($that, $extended) { - return $that->list_record($record, $extended); + $records = array_map(function (\record_adapter $record) use ($that, $extended, $request) { + return $that->list_record($request, $record, $extended); }, array_values($story->get_children()->get_elements())); $caption = $story->get_caption(); @@ -1815,7 +1877,12 @@ class API_V1_adapter extends API_V1_Abstract 'updated_on' => $story->get_modification_date()->format(DATE_ATOM), 'created_on' => $story->get_creation_date()->format(DATE_ATOM), 'collection_id' => phrasea::collFromBas($this->app, $story->get_base_id()), - 'thumbnail' => $this->list_embedable_media($story, $story->get_thumbnail(), $this->app['phraseanet.registry']), + 'thumbnail' => $this->listEmbeddableMedia( + $request, + $story, + $story->get_thumbnail(), + $this->app['phraseanet.registry'] + ), 'uuid' => $story->get_uuid(), 'metadatas' => array( '@entity@' => self::OBJECT_TYPE_STORY_METADATA_BAG, diff --git a/lib/conf.d/Doctrine/Entities.Secret.dcm.yml b/lib/conf.d/Doctrine/Entities.Secret.dcm.yml index 8cb4f10c59..69468e2b72 100644 --- a/lib/conf.d/Doctrine/Entities.Secret.dcm.yml +++ b/lib/conf.d/Doctrine/Entities.Secret.dcm.yml @@ -1,5 +1,6 @@ Entities\Secret: type: entity + repositoryClass: Alchemy\Phrasea\Model\Repositories\SecretRepository table: Secrets id: id: diff --git a/tests/Alchemy/Tests/Phrasea/Application/ApiAbstract.php b/tests/Alchemy/Tests/Phrasea/Application/ApiAbstract.php index a8c053321b..d8d4c3358b 100644 --- a/tests/Alchemy/Tests/Phrasea/Application/ApiAbstract.php +++ b/tests/Alchemy/Tests/Phrasea/Application/ApiAbstract.php @@ -1041,7 +1041,7 @@ abstract class ApiAbstract extends \PhraseanetWebTestCaseAbstract /** * @covers \API_V1_adapter::get_record_embed - * @covers \API_V1_adapter::list_embedable_media + * @covers \API_V1_adapter::listEmbeddableMedia * @covers \API_V1_adapter::list_permalink */ public function testRecordsEmbedRoute() @@ -1129,7 +1129,7 @@ abstract class ApiAbstract extends \PhraseanetWebTestCaseAbstract /** * @covers \API_V1_adapter::get_record_embed - * @covers \API_V1_adapter::list_embedable_media + * @covers \API_V1_adapter::listEmbeddableMedia * @covers \API_V1_adapter::list_permalink */ public function testStoriesEmbedRoute()