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 00000000..c1d5e2aa Binary files /dev/null and b/tests/static/test.jpeg differ