From 28f9bdbc8cd6abda647c4f4f73ac0b2873c82939 Mon Sep 17 00:00:00 2001 From: dannylamb Date: Wed, 7 Feb 2018 14:49:57 -0400 Subject: [PATCH] Media source update (#74) * Route to PUT file contents for a Media * README update * Updating permissions * Updating thumbnails * Touching up tests --- README.md | 19 +- islandora.routing.yml | 10 + islandora.services.yml | 4 + src/Controller/MediaSourceController.php | 130 ++++++++++++ src/MediaSource/MediaSourceService.php | 196 ++++++++++++++++++ .../IslandoraFunctionalTestBase.php | 1 + .../Functional/MediaSourceControllerTest.php | 158 ++++++++++++++ tests/static/test.jpeg | Bin 0 -> 6852 bytes 8 files changed, 516 insertions(+), 2 deletions(-) create mode 100644 src/Controller/MediaSourceController.php create mode 100644 src/MediaSource/MediaSourceService.php create mode 100644 tests/src/Functional/MediaSourceControllerTest.php create mode 100644 tests/static/test.jpeg diff --git a/README.md b/README.md index 4c42dc4c..6758cb5c 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,23 @@ CLAW's core Islandora module for Drupal 8.x ## Installation For a fully automated install, see [claw-playbook](https://github.com/Islandora-Devops/claw-playbook). If you're installing -manually, the REST configuration for both Nodes and Media need to be enabled. `jwt_auth` and `jsonld` formats needs to be set -for both, with Media additionally needing the `json` format. +manually, the REST configuration for both Nodes and Media need to be enabled with `jwt_auth` for authentication and both +`json` and `jsonld` formats. + +## REST API + +Islandora has a light, mostly RESTful HTTP API that relies heavily on Drupal's core Rest module. + +### /media/{media}/source + +You can PUT content to the `/media/{media}/source` endpoint to update the File associated with a Media. The `Content-Type` +header is expected, as well as a `Content-Disposition` header of the form `attachment; filename="your_filename"` to indicate +the name to give the file. Requests with empty bodies or no `Content-Length` header will be rejected. + +Example usage: +``` +curl -u admin:islandora -v -X PUT -H 'Content-Type: image/png' -H 'Content-Disposition: attachment; filename="my_image.png"' --data-binary @my_image.png localhost:8000/media/1/source +``` ## Maintainers diff --git a/islandora.routing.yml b/islandora.routing.yml index d7963c65..52c089b8 100644 --- a/islandora.routing.yml +++ b/islandora.routing.yml @@ -23,3 +23,13 @@ islandora.jsonldcontext: _controller: '\Drupal\islandora\Controller\JsonLdContextController::content' requirements: _permission: 'access content' + +islandora.media_source_update: + path: '/media/{media}/source' + defaults: + _controller: '\Drupal\islandora\Controller\MediaSourceController::put' + methods: [PUT] + requirements: + _permission: 'update media' + options: + _auth: ['basic_auth', 'cookie', 'jwt_auth'] diff --git a/islandora.services.yml b/islandora.services.yml index 17088087..53f02e03 100644 --- a/islandora.services.yml +++ b/islandora.services.yml @@ -39,3 +39,7 @@ services: arguments: ['@current_route_match'] tags: - { name: 'context_provider' } + islandora.media_source_service: + class: Drupal\islandora\MediaSource\MediaSourceService + factory: ['Drupal\islandora\MediaSource\MediaSourceService', create] + arguments: ['@entity_type.manager', '@stream_wrapper_manager'] diff --git a/src/Controller/MediaSourceController.php b/src/Controller/MediaSourceController.php new file mode 100644 index 00000000..fc677ab4 --- /dev/null +++ b/src/Controller/MediaSourceController.php @@ -0,0 +1,130 @@ +service = $service; + $this->database = $database; + } + + /** + * Controller's create method for dependecy injection. + * + * @param \Symfony\Component\DependencyInjection\ContainerInterface $container + * The App Container. + * + * @return \Drupal\islandora\Controller\MediaSourceController + * Controller instance. + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('islandora.media_source_service'), + $container->get('database') + ); + } + + /** + * Updates a source file for a Media. + * + * @param \Drupal\media_entity\MediaInterface $media + * The media whose source file you want to update. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * 204 on success. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function put(MediaInterface $media, Request $request) { + // Since we update both the Media and its File, do this in a transaction. + $transaction = $this->database->startTransaction(); + + try { + $content_type = $request->headers->get('Content-Type', ""); + + if (empty($content_type)) { + throw new BadRequestHttpException("Missing Content-Type header"); + } + + $content_length = $request->headers->get('Content-Length', 0); + + if ($content_length <= 0) { + throw new BadRequestHttpException("Missing Content-Length"); + } + + $content_disposition = $request->headers->get('Content-Disposition', ""); + + if (empty($content_disposition)) { + throw new BadRequestHttpException("Missing Content-Disposition header"); + } + + $matches = []; + if (!preg_match('/attachment; filename="(.*)"/', $content_disposition, $matches)) { + throw new BadRequestHttpException("Malformed Content-Disposition header"); + } + $filename = $matches[1]; + + $this->service->updateSourceField( + $media, + $request->getContent(TRUE), + $content_type, + $content_length, + $filename + ); + + return new Response("", 204); + } + catch (HttpException $e) { + $transaction->rollBack(); + throw $e; + } + catch (\Exception $e) { + $transaction->rollBack(); + throw new HttpException(500, $e->getMessage()); + } + } + +} diff --git a/src/MediaSource/MediaSourceService.php b/src/MediaSource/MediaSourceService.php new file mode 100644 index 00000000..4e830a3c --- /dev/null +++ b/src/MediaSource/MediaSourceService.php @@ -0,0 +1,196 @@ +mediaBundleStorage = $media_bundle_storage; + $this->fieldConfigStorage = $field_config_storage; + $this->streamWrapperManager = $stream_wrapper_manager; + } + + /** + * Factory. + * + * @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager + * The entity type manager. + * @param \Drupal\Core\StreamWrapper\StreamWrapperManager $stream_wrapper_manager + * Stream wrapper manager. + * + * @return \Drupal\islandora\MediaSource\MediaSourceService + * MediaSourceService instance. + */ + public static function create( + EntityTypeManager $entity_type_manager, + StreamWrapperManager $stream_wrapper_manager + ) { + return new static( + $entity_type_manager->getStorage('media_bundle'), + $entity_type_manager->getStorage('field_config'), + $stream_wrapper_manager + ); + } + + /** + * Gets the name of a source field for a Media. + * + * @param string $media_bundle + * Media bundle whose source field you are searching for. + * + * @return string|null + * Field name if it exists in configuration, else NULL. + */ + public function getSourceFieldName($media_bundle) { + $bundle = $this->mediaBundleStorage->load($media_bundle); + $type_configuration = $bundle->getTypeConfiguration(); + + if (!isset($type_configuration['source_field'])) { + return NULL; + } + + return $type_configuration['source_field']; + } + + /** + * Gets a list of valid file extensions for a field. + * + * @param string $entity_type + * Entity type (node, media, etc...). + * @param string $bundle + * Bundle the field belongs to. + * @param string $field + * The field whose valid extensions you're looking for. + * + * @return string + * Space delimited string containing valid extensions. + */ + public function getFileFieldExtensions($entity_type, $bundle, $field) { + $field_config = $this->fieldConfigStorage->load("$entity_type.$bundle.$field"); + if (!$field_config) { + return ""; + } + return $field_config->getSetting('file_extensions'); + } + + /** + * Updates a media's source field with the supplied resource. + * + * @param \Drupal\media_entity\MediaInterface $media + * The media to update. + * @param resource $resource + * New file contents as a resource. + * @param string $mimetype + * New mimetype of contents. + * @param string $content_length + * New size of contents. + * @param string $filename + * New filename for contents. + * + * @throws HttpException + */ + public function updateSourceField( + MediaInterface $media, + $resource, + $mimetype, + $content_length, + $filename + ) { + // Get the source field for the media type. + $source_field = $this->getSourceFieldName($media->bundle()); + + if (empty($source_field)) { + throw new NotFoundHttpException("Source field not set for {$media->bundle()} media"); + } + + // Get the file from the media. + $files = $media->get($source_field)->referencedEntities(); + $file = reset($files); + + // Set relevant fields on file. + $file->setMimeType($mimetype); + $file->setFilename($filename); + $file->setSize($content_length); + + // Validate file extension. + $entity_type = $media->getEntityTypeId(); + $bundle = $media->bundle(); + $valid_extensions = $this->getFileFieldExtensions($entity_type, $bundle, $source_field); + $errors = file_validate_extensions($file, $valid_extensions); + + if (!empty($errors)) { + throw new BadRequestHttpException("Invalid file extension. Valid types are :$valid_extensions"); + } + + // Copy the contents over using streams. + $uri = $file->getFileUri(); + $file_stream_wrapper = $this->streamWrapperManager->getViaUri($uri); + $path = ""; + $file_stream_wrapper->stream_open($uri, 'w', STREAM_REPORT_ERRORS, $path); + $file_stream = $file_stream_wrapper->stream_cast(STREAM_CAST_AS_STREAM); + if (stream_copy_to_stream($resource, $file_stream) === FALSE) { + throw new HttpException(500, "The file could not be copied into $uri"); + } + $file->save(); + + // Set fields provided by type plugin and mapped in bundle configuration + // for the media. + foreach ($media->bundle->entity->field_map as $source => $destination) { + if ($media->hasField($destination) && $value = $media->getType()->getField($media, $source)) { + $media->set($destination, $value); + } + } + + // Flush the image cache for the image so thumbnails get regenerated. + image_path_flush($uri); + + $media->save(); + } + +} diff --git a/tests/src/Functional/IslandoraFunctionalTestBase.php b/tests/src/Functional/IslandoraFunctionalTestBase.php index 04d2068a..34f7646e 100644 --- a/tests/src/Functional/IslandoraFunctionalTestBase.php +++ b/tests/src/Functional/IslandoraFunctionalTestBase.php @@ -103,6 +103,7 @@ class IslandoraFunctionalTestBase extends BrowserTestBase { $this->getSession()->getPage()->fillField('edit-field-image-0-alt', 'alt text'); $this->getSession()->getPage()->pressButton(t('Save and publish')); $this->assertResponse(200); + return $this->getUrl(); } /** diff --git a/tests/src/Functional/MediaSourceControllerTest.php b/tests/src/Functional/MediaSourceControllerTest.php new file mode 100644 index 00000000..c44701df --- /dev/null +++ b/tests/src/Functional/MediaSourceControllerTest.php @@ -0,0 +1,158 @@ +container->get('entity_type.manager')->getStorage('rest_resource_config')->create([ + 'id' => 'entity.media', + 'granularity' => 'resource', + 'configuration' => [ + 'methods' => ['GET'], + 'authentication' => ['basic_auth'], + 'formats' => ['json'], + ], + 'status' => TRUE, + ]); + $media_rest_resource->save(TRUE); + + $this->container->get('router.builder')->rebuildIfNeeded(); + } + + /** + * @covers \Drupal\islandora\Controller\MediaSourceController::put + */ + public function testMediaSourceUpdate() { + $account = $this->drupalCreateUser([ + 'view media', + 'create media', + 'update media', + ]); + $this->drupalLogin($account); + + // Make a media and give it a png. + $url = $this->createThumbnailWithFile(); + + // Hack out the guzzle client. + $client = $this->getSession()->getDriver()->getClient()->getClient(); + + // GET the media to stash its original values for comparison later. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + ]; + $response = $client->request('GET', $url . '?_format=json', $options); + $media = json_decode($response->getBody(), TRUE); + + $mid = $media['mid'][0]['value']; + $original_mimetype = $media['field_mimetype'][0]['value']; + $original_width = $media['field_width'][0]['value']; + $original_height = $media['field_height'][0]['value']; + $original_image = file_get_contents($media['field_image'][0]['url']); + + $media_update_url = Url::fromRoute('islandora.media_source_update', ['media' => $mid]) + ->setAbsolute() + ->toString(); + + $image = file_get_contents(__DIR__ . '/../../static/test.jpeg'); + + // Update without Content-Type header should fail with 400. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + 'headers' => [ + 'Content-Disposition' => 'attachment; filename="test.jpeg"', + ], + 'body' => $image, + ]; + $response = $client->request('PUT', $media_update_url, $options); + $this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}"); + + // Update without Content-Disposition header should fail with 400. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + 'headers' => [ + 'Content-Type' => 'image/jpeg', + ], + 'body' => $image, + ]; + $response = $client->request('PUT', $media_update_url, $options); + $this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}"); + + // Update with malformed Content-Disposition header should fail with 400. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + 'headers' => [ + 'Content-Type' => 'image/jpeg', + 'Content-Disposition' => 'attachment; garbage="test.jpeg"', + ], + 'body' => $image, + ]; + $response = $client->request('PUT', $media_update_url, $options); + $this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}"); + + // Update without body should fail with 400. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + 'headers' => [ + 'Content-Type' => 'image/jpeg', + 'Content-Disposition' => 'attachment; filename="test.jpeg"', + ], + ]; + $response = $client->request('PUT', $media_update_url, $options); + $this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}"); + + // Should be successful. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + 'headers' => [ + 'Content-Type' => 'image/jpeg', + 'Content-Disposition' => 'attachment; filename="test.jpeg"', + ], + 'body' => $image, + ]; + $response = $client->request('PUT', $media_update_url, $options); + $this->assertTrue($response->getStatusCode() == 204, "Expected 204, received {$response->getStatusCode()}"); + + // GET the media again and compare image and metadata. + $options = [ + 'auth' => [$account->getUsername(), $account->pass_raw], + 'http_errors' => FALSE, + ]; + $response = $client->request('GET', $url . '?_format=json', $options); + $updated = json_decode($response->getBody(), TRUE); + + $updated_mimetype = $updated['field_mimetype'][0]['value']; + $updated_width = $updated['field_width'][0]['value']; + $updated_height = $updated['field_height'][0]['value']; + $updated_image = file_get_contents($updated['field_image'][0]['url']); + + $this->assertTrue($original_mimetype != $updated_mimetype, "Mimetypes should be updated with media source update"); + $this->assertTrue($original_width != $updated_width, "Height should be updated with media source update"); + $this->assertTrue($original_height != $updated_height, "Width should be updated with media source update"); + $this->assertTrue($original_image != $updated_image, "Width should be updated with media source update"); + + $this->assertTrue($updated_mimetype == "image/jpeg", "Invalid mimetype. Expected image/jpeg, received $updated_mimetype"); + $this->assertTrue($updated_width == 295, "Invalid width. Expected 295, received $updated_width"); + $this->assertTrue($updated_height == 70, "Invalid height. Expected 70, received $updated_height"); + $this->assertTrue($updated_image == file_get_contents(__DIR__ . '/../../static/test.jpeg'), "Updated image not the same as PUT body."); + } + +} diff --git a/tests/static/test.jpeg b/tests/static/test.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c1d5e2aa099f76bcf860ddf3010ce73b34821be2 GIT binary patch literal 6852 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 literal 0 HcmV?d00001