Browse Source

Add binary to node (#76)

* First pass at adding media directly to nodes as binaries

* Tests.  Coding standards

* Refined access checking and updated tests

* README update
pull/756/head
dannylamb 7 years ago committed by Jared Whiklo
parent
commit
c1937c051b
  1. 12
      README.md
  2. 10
      islandora.routing.yml
  3. 3
      islandora.services.yml
  4. 105
      src/Controller/MediaSourceController.php
  5. 217
      src/MediaSource/MediaSourceService.php
  6. 223
      tests/src/Functional/AddMediaToNodeTest.php
  7. 10
      tests/src/Functional/MediaSourceUpdateTest.php

12
README.md

@ -28,6 +28,18 @@ 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
```
### /node/{node}/media/{field}/add/{bundle}
You can POST content to the `/node/{node}/media/{field}/add/{bundle}` endpoint to create a new Media of the specified bundle
using the POST body. It will be associated with the specified Node using the field from the route. 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 -v -u admin:islandora -H "Content-Type: image/jpeg" -H "Content-Disposition: attachment; filename=\"test.jpeg\"" --data-binary @test.jpeg http://localhost:8000/node/1/media/my_media_field/add/my_media_bundle
```
## Maintainers
Current maintainers:

10
islandora.routing.yml

@ -33,3 +33,13 @@ islandora.media_source_update:
_permission: 'update media'
options:
_auth: ['basic_auth', 'cookie', 'jwt_auth']
islandora.media_source_add_to_node:
path: '/node/{node}/media/{field}/add/{bundle}'
defaults:
_controller: '\Drupal\islandora\Controller\MediaSourceController::addToNode'
methods: [POST]
requirements:
_custom_access: '\Drupal\islandora\Controller\MediaSourceController::addToNodeAccess'
options:
_auth: ['basic_auth', 'cookie', 'jwt_auth']

3
islandora.services.yml

@ -41,5 +41,4 @@ services:
- { 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']
arguments: ['@entity_type.manager', '@current_user', '@stream_wrapper_manager', '@token']

105
src/Controller/MediaSourceController.php

@ -2,9 +2,13 @@
namespace Drupal\islandora\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Session\AccountInterface;
use Drupal\media_entity\MediaInterface;
use Drupal\node\NodeInterface;
use Drupal\islandora\MediaSource\MediaSourceService;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
@ -79,10 +83,78 @@ class MediaSourceController extends ControllerBase {
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function put(MediaInterface $media, Request $request) {
$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];
// Since we update both the Media and its File, do this in a transaction.
$transaction = $this->database->startTransaction();
try {
$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());
}
}
/**
* Adds a Media to a Node using the specified field.
*
* @param \Drupal\node\NodeInterface $node
* The Node to which you want to add a Media.
* @param string $field
* Name of field on Node to reference Media.
* @param string $bundle
* Name of bundle for Media to create.
* @param \Symfony\Component\HttpFoundation\Request $request
* The request object.
*
* @return \Symfony\Component\HttpFoundation\Response
* 201 on success with a Location link header.
*
* @throws \Symfony\Component\HttpKernel\Exception\HttpException
*/
public function addToNode(
NodeInterface $node,
$field,
$bundle,
Request $request
) {
$content_type = $request->headers->get('Content-Type', "");
if (empty($content_type)) {
@ -107,15 +179,24 @@ class MediaSourceController extends ControllerBase {
}
$filename = $matches[1];
$this->service->updateSourceField(
$media,
// Since we create both a Media and its File, AND update a node,
// start a transaction.
$transaction = $this->database->startTransaction();
try {
$media = $this->service->addToNode(
$node,
$field,
$bundle,
$request->getContent(TRUE),
$content_type,
$content_length,
$filename
);
return new Response("", 204);
$response = new Response("", 201);
$response->headers->set("Location", $media->url('canonical', ['absolute' => TRUE]));
return $response;
}
catch (HttpException $e) {
$transaction->rollBack();
@ -127,4 +208,22 @@ class MediaSourceController extends ControllerBase {
}
}
/**
* Checks for permissions to update a node and create media.
*
* @param \Drupal\Core\Session\AccountInterface $account
* Account for user making the request.
* @param \Drupal\Core\Routing\RouteMatch $route_match
* Route match to get Node from url params.
*
* @return \Drupal\Core\Access\AccessResultInterface
* Access result.
*/
public function addToNodeAccess(AccountInterface $account, RouteMatch $route_match) {
// We'd have 404'd already if node didn't exist, so no need to check.
// Just hack it out of the route match.
$node = $route_match->getParameter('node');
return AccessResult::allowedIf($node->access('update', $account) && $account->hasPermission('create media'));
}
}

217
src/MediaSource/MediaSourceService.php

@ -2,11 +2,15 @@
namespace Drupal\islandora\MediaSource;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\Utility\Token;
use Drupal\media_entity\MediaInterface;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -16,18 +20,18 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class MediaSourceService {
/**
* Media bundle storage.
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
* @var \Drupal\Core\Entity\EntityTypeManager
*/
protected $mediaBundleStorage;
protected $entityTypeManager;
/**
* Field config storage.
* Current user.
*
* @var \Drupal\Core\Entity\EntityStorageInterface
* @var \Drupal\Core\Session\AccountInterface
*/
protected $fieldConfigStorage;
protected $account;
/**
* Stream wrapper manager.
@ -37,45 +41,34 @@ class MediaSourceService {
protected $streamWrapperManager;
/**
* Constructor.
* Token service.
*
* @param \Drupal\Core\Entity\EntityStorageInterface $media_bundle_storage
* Media bundle storage.
* @param \Drupal\Core\Entity\EntityStorageInterface $field_config_storage
* Field config storage.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManager $stream_wrapper_manager
* Stream wrapper manager.
* @var \Drupal\Core\Utility\Token
*/
public function __construct(
EntityStorageInterface $media_bundle_storage,
EntityStorageInterface $field_config_storage,
StreamWrapperManager $stream_wrapper_manager
) {
$this->mediaBundleStorage = $media_bundle_storage;
$this->fieldConfigStorage = $field_config_storage;
$this->streamWrapperManager = $stream_wrapper_manager;
}
protected $token;
/**
* Factory.
* Constructor.
*
* @param \Drupal\Core\Entity\EntityTypeManager $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Session\AccountInterface $account
* The current user.
* @param \Drupal\Core\StreamWrapper\StreamWrapperManager $stream_wrapper_manager
* Stream wrapper manager.
*
* @return \Drupal\islandora\MediaSource\MediaSourceService
* MediaSourceService instance.
* @param \Drupal\Core\Utility\Token $token
* Token service.
*/
public static function create(
public function __construct(
EntityTypeManager $entity_type_manager,
StreamWrapperManager $stream_wrapper_manager
AccountInterface $account,
StreamWrapperManager $stream_wrapper_manager,
Token $token
) {
return new static(
$entity_type_manager->getStorage('media_bundle'),
$entity_type_manager->getStorage('field_config'),
$stream_wrapper_manager
);
$this->entityTypeManager = $entity_type_manager;
$this->account = $account;
$this->streamWrapperManager = $stream_wrapper_manager;
$this->token = $token;
}
/**
@ -88,9 +81,12 @@ class MediaSourceService {
* Field name if it exists in configuration, else NULL.
*/
public function getSourceFieldName($media_bundle) {
$bundle = $this->mediaBundleStorage->load($media_bundle);
$type_configuration = $bundle->getTypeConfiguration();
$bundle = $this->entityTypeManager->getStorage('media_bundle')->load($media_bundle);
if (!$bundle) {
throw new NotFoundHttpException("Bundle $media_bundle does not exist");
}
$type_configuration = $bundle->getTypeConfiguration();
if (!isset($type_configuration['source_field'])) {
return NULL;
}
@ -98,27 +94,6 @@ class MediaSourceService {
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.
*
@ -159,9 +134,9 @@ class MediaSourceService {
$file->setSize($content_length);
// Validate file extension.
$entity_type = $media->getEntityTypeId();
$bundle = $media->bundle();
$valid_extensions = $this->getFileFieldExtensions($entity_type, $bundle, $source_field);
$source_field_config = $this->entityTypeManager->getStorage('field_config')->load("media.$bundle.$source_field");
$valid_extensions = $source_field_config->getSetting('file_extensions');
$errors = file_validate_extensions($file, $valid_extensions);
if (!empty($errors)) {
@ -193,4 +168,128 @@ class MediaSourceService {
$media->save();
}
/**
* Creates a new Media using the provided resource, adding it to a Node.
*
* @param \Drupal\node\NodeInterface $node
* The node to reference the newly created Media.
* @param string $field
* Name of field on the Node to reference the Media.
* @param string $bundle
* Bundle of Media to create.
* @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 addToNode(
NodeInterface $node,
$field,
$bundle,
$resource,
$mimetype,
$content_length,
$filename
) {
if (!$node->hasField($field)) {
throw new NotFoundHttpException();
}
if (!$node->get($field)->isEmpty()) {
throw new ConflictHttpException();
}
// Get the source field for the media type.
$source_field = $this->getSourceFieldName($bundle);
if (empty($source_field)) {
throw new NotFoundHttpException("Source field not set for {$media->bundle()} media");
}
// Load its config to get file extensions and upload path.
$source_field_config = $this->entityTypeManager->getStorage('field_config')->load("media.$bundle.$source_field");
// Construct the destination uri.
$directory = $source_field_config->getSetting('file_directory');
$directory = trim($directory, '/');
$directory = PlainTextOutput::renderFromHtml($this->token->replace($directory, ['node' => $node]));
$scheme = file_default_scheme();
$destination_directory = "$scheme://$directory";
$destination = "$destination_directory/$filename";
// Construct the File.
$file = $this->entityTypeManager->getStorage('file')->create([
'uid' => $this->account->id(),
'uri' => $destination,
'filename' => $filename,
'filemime' => $mimetype,
'filesize' => $content_length,
'status' => FILE_STATUS_PERMANENT,
]);
// Validate file extension.
$source_field_config = $this->entityTypeManager->getStorage('field_config')->load("media.$bundle.$source_field");
$valid_extensions = $source_field_config->getSetting('file_extensions');
$errors = file_validate_extensions($file, $valid_extensions);
if (!empty($errors)) {
throw new BadRequestHttpException("Invalid file extension. Valid types are $valid_extensions");
}
if (!file_prepare_directory($destination_directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
throw new HttpException(500, "The destination directory does not exist, could not be created, or is not writable");
}
// 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();
// Construct the Media.
$media_struct = [
'bundle' => $bundle,
'uid' => $this->account->id(),
'name' => $filename,
"$source_field" => [
'target_id' => $file->id(),
],
];
if ($source_field_config->getSetting('alt_field') && $source_field_config->getSetting('alt_field_required')) {
$media_struct[$source_field]['alt'] = $filename;
}
$media = $this->entityTypeManager->getStorage('media')->create($media_struct);
// 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();
// Update the Node.
$node->get($field)->appendItem($media);
$node->save();
// Return the created media.
return $media;
}
}

223
tests/src/Functional/AddMediaToNodeTest.php

@ -0,0 +1,223 @@
<?php
namespace Drupal\Tests\islandora\Functional;
use Drupal\Core\Url;
use Drupal\field\Tests\EntityReference\EntityReferenceTestTrait;
/**
* Tests the RelatedLinkHeader view alter.
*
* @group islandora
*/
class AddMediaToNodeTest extends IslandoraFunctionalTestBase {
use EntityReferenceTestTrait;
/**
* Node that has entity reference field.
*
* @var \Drupal\node\NodeInterface
*/
protected $referencer;
/**
* {@inheritdoc}
*/
public function setUp() {
parent::setUp();
// Create a test content type with an entity reference field.
$test_type_with_reference = $this->container->get('entity_type.manager')->getStorage('node_type')->create([
'type' => 'test_type_with_reference',
'label' => 'Test Type With Reference',
]);
$test_type_with_reference->save();
// Add two entity reference fields.
// One for nodes and one for media.
$this->createEntityReferenceField('node', 'test_type_with_reference', 'field_media', 'Media Entity', 'media', 'default', [], 2);
$this->referencer = $this->container->get('entity_type.manager')->getStorage('node')->create([
'type' => 'test_type_with_reference',
'title' => 'Referencer',
]);
$this->referencer->save();
}
/**
* @covers \Drupal\islandora\Controller\MediaSourceController::addToNode
*/
public function testAddMediaToNode() {
// Hack out the guzzle client.
$client = $this->getSession()->getDriver()->getClient()->getClient();
$add_to_node_url = Url::fromRoute(
'islandora.media_source_add_to_node',
[
'node' => $this->referencer->id(),
'field' => 'field_media',
'bundle' => 'tn',
]
)
->setAbsolute()
->toString();
$bad_node_url = Url::fromRoute(
'islandora.media_source_add_to_node',
[
'node' => 123456,
'field' => 'field_media',
'bundle' => 'tn',
]
)
->setAbsolute()
->toString();
$image = file_get_contents(__DIR__ . '/../../static/test.jpeg');
// Test different permissions scenarios.
$options = [
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; filename="test.jpeg"',
],
'body' => $image,
];
// 403 if you don't have permissions to update the node.
$account = $this->drupalCreateUser([
'access content',
'create media',
]);
$this->drupalLogin($account);
$options['auth'] = [$account->getUsername(), $account->pass_raw];
$response = $client->request('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 403, "Expected 403, received {$response->getStatusCode()}");
// Bad node URL should return 404, regardless of permissions.
// Just making sure our custom access function doesn't obfuscate responses.
$response = $client->request('POST', $bad_node_url, $options);
$this->assertTrue($response->getStatusCode() == 404, "Expected 404, received {$response->getStatusCode()}");
// 403 if you don't have permissions to create Media.
$account = $this->drupalCreateUser([
'bypass node access',
]);
$this->drupalLogin($account);
$options['auth'] = [$account->getUsername(), $account->pass_raw];
$response = $client->request('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 403, "Expected 403, received {$response->getStatusCode()}");
// Now with proper credentials, test responses given to malformed requests.
$account = $this->drupalCreateUser([
'bypass node access',
'create media',
]);
$this->drupalLogin($account);
// Request 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('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Request 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('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Request 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' => 'garbage; filename="test.jpeg"',
],
'body' => $image,
];
$response = $client->request('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Request 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('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 400, "Expected 400, received {$response->getStatusCode()}");
// Test properly formed requests with bad parameters in the route.
$options = [
'auth' => [$account->getUsername(), $account->pass_raw],
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; filename="test.jpeg"',
],
'body' => $image,
];
// Bad node id should return 404 even with proper permissions.
$response = $client->request('POST', $bad_node_url, $options);
$this->assertTrue($response->getStatusCode() == 404, "Expected 404, received {$response->getStatusCode()}");
// Bad field name in url should return 404.
$bad_field_url = Url::fromRoute(
'islandora.media_source_add_to_node',
[
'node' => $this->referencer->id(),
'field' => 'field_garbage',
'bundle' => 'tn',
]
)
->setAbsolute()
->toString();
$response = $client->request('POST', $bad_field_url, $options);
$this->assertTrue($response->getStatusCode() == 404, "Expected 404, received {$response->getStatusCode()}");
// Bad bundle name in url should return 404.
$bad_bundle_url = Url::fromRoute(
'islandora.media_source_add_to_node',
[
'node' => $this->referencer->id(),
'field' => 'field_media',
'bundle' => 'garbage',
]
)
->setAbsolute()
->toString();
$response = $client->request('POST', $bad_bundle_url, $options);
$this->assertTrue($response->getStatusCode() == 404, "Expected 404, received {$response->getStatusCode()}");
// Should be successful with proper url, options, and permissions.
$response = $client->request('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 201, "Expected 201, received {$response->getStatusCode()}");
$this->assertTrue(!empty($response->getHeader("Location")), "Response must include Location header");
// Should fail with 409 if Node already references a media using the field
// (i.e. the previous call was successful).
$response = $client->request('POST', $add_to_node_url, $options);
$this->assertTrue($response->getStatusCode() == 409, "Expected 409, received {$response->getStatusCode()}");
}
}

10
tests/src/Functional/MediaSourceControllerTest.php → tests/src/Functional/MediaSourceUpdateTest.php

@ -9,7 +9,7 @@ use Drupal\Core\Url;
*
* @group islandora
*/
class MediaSourceControllerTest extends IslandoraFunctionalTestBase {
class MediaSourceUpdateTest extends IslandoraFunctionalTestBase {
/**
* {@inheritdoc}
@ -99,7 +99,7 @@ class MediaSourceControllerTest extends IslandoraFunctionalTestBase {
'http_errors' => FALSE,
'headers' => [
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'attachment; garbage="test.jpeg"',
'Content-Disposition' => 'garbage; filename="test.jpeg"',
],
'body' => $image,
];
@ -145,9 +145,9 @@ class MediaSourceControllerTest extends IslandoraFunctionalTestBase {
$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($original_width != $updated_width, "Width should be updated with media source update");
$this->assertTrue($original_height != $updated_height, "Height should be updated with media source update");
$this->assertTrue($original_image != $updated_image, "Image 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");
Loading…
Cancel
Save