Skip to content

Commit 86d503f

Browse files
authored
feat(files): Add a file upload utility service (#818)
1 parent a7328ad commit 86d503f

File tree

4 files changed

+339
-0
lines changed

4 files changed

+339
-0
lines changed

graphql.services.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ services:
141141
- '\Drupal\graphql\Annotation\DataProducer'
142142
- '%graphql.config%'
143143

144+
# File upload.
145+
graphql.file_upload:
146+
class: Drupal\graphql\GraphQL\Utility\FileUpload
147+
arguments: ['@entity_type.manager', '@current_user', '@file.mime_type.guesser', '@file_system', '@logger.channel.graphql']
148+
144149
plugin.manager.graphql.persisted_query:
145150
class: Drupal\graphql\Plugin\PersistedQueryPluginManager
146151
arguments:

src/GraphQL/Utility/FileUpload.php

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
<?php
2+
3+
namespace Drupal\graphql\GraphQL\Utility;
4+
5+
use Drupal\Component\Utility\Bytes;
6+
use Drupal\Component\Utility\Environment;
7+
use Drupal\Core\Entity\EntityTypeManagerInterface;
8+
use Drupal\Core\File\FileSystem;
9+
use Drupal\Core\File\FileSystemInterface;
10+
use Drupal\Core\Logger\LoggerChannelInterface;
11+
use Drupal\Core\Session\AccountProxyInterface;
12+
use Drupal\Core\StringTranslation\StringTranslationTrait;
13+
use Drupal\graphql\Wrappers\FileUploadResponse;
14+
use Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface;
15+
use Symfony\Component\HttpFoundation\File\UploadedFile;
16+
17+
/**
18+
* Service to manage file uploads within GraphQL mutations.
19+
*/
20+
class FileUpload {
21+
22+
use StringTranslationTrait;
23+
24+
/**
25+
* The entity type manager.
26+
*
27+
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
28+
*/
29+
protected $entityTypeManager;
30+
31+
/**
32+
* The current user.
33+
*
34+
* @var \Drupal\Core\Session\AccountProxyInterface
35+
*/
36+
protected $currentUser;
37+
38+
/**
39+
* The mime type guesser service.
40+
*
41+
* @var \Symfony\Component\HttpFoundation\File\MimeType\MimeTypeGuesserInterface
42+
*/
43+
protected $mimeTypeGuesser;
44+
45+
/**
46+
* The file system service.
47+
*
48+
* @var \Drupal\Core\File\FileSystemInterface
49+
*/
50+
protected $fileSystem;
51+
52+
/**
53+
* GraphQL logger channel.
54+
*
55+
* @var \Drupal\Core\Logger\LoggerChannelInterface
56+
*/
57+
protected $logger;
58+
59+
/**
60+
* {@inheritdoc}
61+
*/
62+
public function __construct(
63+
EntityTypeManagerInterface $entityTypeManager,
64+
AccountProxyInterface $currentUser,
65+
MimeTypeGuesserInterface $mimeTypeGuesser,
66+
FileSystemInterface $fileSystem,
67+
LoggerChannelInterface $logger
68+
) {
69+
$this->entityTypeManager = $entityTypeManager;
70+
$this->currentUser = $currentUser;
71+
$this->mimeTypeGuesser = $mimeTypeGuesser;
72+
$this->fileSystem = $fileSystem;
73+
$this->logger = $logger;
74+
}
75+
76+
/**
77+
* Gets max upload size.
78+
*
79+
* @param array $settings
80+
* The file field settings.
81+
*
82+
* @return int
83+
* Max upload size.
84+
*/
85+
protected function getMaxUploadSize(array $settings) {
86+
// Cap the upload size according to the PHP limit.
87+
$max_filesize = Bytes::toInt(Environment::getUploadMaxSize());
88+
if (!empty($settings['max_filesize'])) {
89+
$max_filesize = min($max_filesize, Bytes::toInt($settings['max_filesize']));
90+
}
91+
return $max_filesize;
92+
}
93+
94+
/**
95+
* Retrieves the upload validators for a file field.
96+
*
97+
* @param array $settings
98+
* The file field settings.
99+
*
100+
* @return array
101+
* List of file validators.
102+
*/
103+
protected function getUploadValidators(array $settings) {
104+
// Validate name length.
105+
$validators = [
106+
'file_validate_name_length' => [],
107+
];
108+
109+
// There is always a file size limit due to the PHP server limit.
110+
$validators['file_validate_size'] = [$this->getMaxUploadSize($settings)];
111+
112+
// Add the extension check if necessary.
113+
if (!empty($settings['file_extensions'])) {
114+
$validators['file_validate_extensions'] = [$settings['file_extensions']];
115+
}
116+
117+
return $validators;
118+
}
119+
120+
/**
121+
* Create a temporary file and send back the newly created entity.
122+
*
123+
* Based on several file upload handlers, see
124+
* _file_save_upload_single()
125+
* \Drupal\file\Plugin\Field\FieldType\FileItem
126+
* \Drupal\file\Plugin\rest\resource\FileUploadResource.
127+
*
128+
* @param \Symfony\Component\HttpFoundation\File\UploadedFile $file
129+
* The file entity to upload.
130+
* @param array $settings
131+
* File settings as specified in regular file field config. Contains keys:
132+
* - file_directory: Where to upload the file
133+
* - uri_scheme: Uri scheme to upload the file to (eg public://, private://)
134+
* - file_extensions: List of valid file extensions (eg [xml, pdf])
135+
* - max_filesize: Maximum allowed size of uploaded file.
136+
*
137+
* @return \Drupal\graphql\Wrappers\FileUploadResponse
138+
* The file upload response containing file entity or list of violations.
139+
*
140+
* @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
141+
* @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
142+
*/
143+
public function createTemporaryFileUpload(UploadedFile $file, array $settings) {
144+
$response = new FileUploadResponse();
145+
146+
// Check for file upload errors and return FALSE for this file if a lower
147+
// level system error occurred.
148+
// @see http://php.net/manual/features.file-upload.errors.php.
149+
switch ($file->getError()) {
150+
case UPLOAD_ERR_INI_SIZE:
151+
case UPLOAD_ERR_FORM_SIZE:
152+
$maxUploadSize = format_size($this->getMaxUploadSize($settings));
153+
$response->setViolation($this->t('The file @file could not be saved because it exceeds @maxsize, the maximum allowed size for uploads.', ['@file' => $file->getClientOriginalName(), '@maxsize' => $maxUploadSize]));
154+
$this->logger->error('The file @file could not be saved because it exceeds @maxsize, the maximum allowed size for uploads.', ['@file' => $file->getFilename(), '@maxsize' => $maxUploadSize]);
155+
return $response;
156+
157+
case UPLOAD_ERR_PARTIAL:
158+
case UPLOAD_ERR_NO_FILE:
159+
$response->setViolation($this->t('The file "@file" could not be saved because the upload did not complete.', ['@file' => $file->getClientOriginalName()]));
160+
$this->logger->error('The file "@file" could not be saved because the upload did not complete.', ['@file' => $file->getFilename()]);
161+
return $response;
162+
163+
case UPLOAD_ERR_OK:
164+
// Final check that this is a valid upload, if it isn't, use the
165+
// default error handler.
166+
if (is_uploaded_file($file->getRealPath())) {
167+
break;
168+
}
169+
170+
default:
171+
$response->setViolation($this->t('Unknown error while uploading the file "@file".', ['@file' => $file->getClientOriginalName()]));
172+
$this->logger->error('Error while uploading the file "@file" with an error code "@code".', ['@file' => $file->getFilename(), '@code' => $file->getError()]);
173+
return $response;
174+
}
175+
176+
// Make sure the destination directory exists.
177+
$uploadDir = $settings['uri_scheme'] . '://' . trim($settings['file_directory'], '/');
178+
if (!$this->fileSystem->prepareDirectory($uploadDir, FileSystem::CREATE_DIRECTORY)) {
179+
$response->setViolation($this->t('Unknown error while uploading the file "@file".', ['@file' => $file->getClientOriginalName()]));
180+
$this->logger->error('Could not create directory "@upload_directory".', ["@upload_directory" => $uploadDir]);
181+
return $response;
182+
}
183+
$name = $file->getClientOriginalName();
184+
$mime = $this->mimeTypeGuesser->guess($name);
185+
$destination = $this->fileSystem->getDestinationFilename("{$uploadDir}/{$name}", $this->fileSystem::EXISTS_RENAME);
186+
187+
// Begin building file entity.
188+
$values = [
189+
'uid' => $this->currentUser->id(),
190+
'status' => 0,
191+
'filename' => $name,
192+
'uri' => $destination,
193+
'filesize' => $file->getSize(),
194+
'filemime' => $mime,
195+
];
196+
$storage = $this->entityTypeManager->getStorage('file');
197+
/** @var \Drupal\file\FileInterface $fileEntity */
198+
$fileEntity = $storage->create($values);
199+
200+
// Validate the entity values.
201+
if (($violations = $fileEntity->validate()) && $violations->count()) {
202+
$response->setViolations($violations);
203+
return $response;
204+
}
205+
206+
// Validate the file name length.
207+
if ($violations = file_validate($fileEntity, $this->getUploadValidators($settings))) {
208+
$response->setViolations($violations);
209+
return $response;
210+
}
211+
212+
// Move uploaded files from PHP's upload_tmp_dir to Drupal's temporary
213+
// directory. This overcomes open_basedir restrictions for future file
214+
// operations.
215+
if (!$this->fileSystem->moveUploadedFile($file->getRealPath(), $fileEntity->getFileUri())) {
216+
$response->setViolation($this->t('Unknown error while uploading the file "@file".', ['@file' => $file->getClientOriginalName()]));
217+
$this->logger->error('Unable to move file from "@file" to "@destination".', ['@file' => $file->getRealPath(), '@destination' => $fileEntity->getFileUri()]);
218+
return $response;
219+
}
220+
221+
// Adjust permissions.
222+
if (!$this->fileSystem->chmod($fileEntity->getFileUri())) {
223+
$response->setViolation($this->t('Unknown error while uploading the file "@file".', ['@file' => $file->getClientOriginalName()]));
224+
$this->logger->error('Unable to set file permission for file "@file".', ['@file' => $fileEntity->getFileUri()]);
225+
return $response;
226+
}
227+
228+
$response->setFileEntity($fileEntity);
229+
return $response;
230+
}
231+
232+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace Drupal\graphql\Wrappers;
4+
5+
use Drupal\file\FileInterface;
6+
use Symfony\Component\Validator\ConstraintViolationInterface;
7+
8+
/**
9+
* File upload response wrapper.
10+
*/
11+
class FileUploadResponse implements FileUploadResponseInterface {
12+
13+
/**
14+
* List of violations in case of unsuccessful file upload.
15+
*
16+
* @var array
17+
*/
18+
protected $violations = [];
19+
20+
/**
21+
* The file entity in case of successful file upload.
22+
*
23+
* @var \Drupal\file\FileInterface|null
24+
*/
25+
protected $fileEntity;
26+
27+
/**
28+
* Sets violation.
29+
*
30+
* @param string|\Drupal\Core\StringTranslation\TranslatableMarkup|\Symfony\Component\Validator\ConstraintViolationInterface $violation
31+
* Violation. Either string, translatable markup or constraint.
32+
*/
33+
public function setViolation($violation) {
34+
if ($violation instanceof ConstraintViolationInterface) {
35+
$violation = $violation->getMessage();
36+
}
37+
$this->violations[] = (string) $violation;
38+
}
39+
40+
/**
41+
* Sets violations.
42+
*
43+
* @param array|\Symfony\Component\Validator\ConstraintViolationListInterface $violations
44+
* List of violations.
45+
*/
46+
public function setViolations($violations) {
47+
foreach ($violations as $violation) {
48+
$this->setViolation($violation);
49+
}
50+
}
51+
52+
/**
53+
* Sets file entity.
54+
*
55+
* @param \Drupal\file\FileInterface $fileEntity
56+
* File entity.
57+
*/
58+
public function setFileEntity(FileInterface $fileEntity) {
59+
$this->fileEntity = $fileEntity;
60+
}
61+
62+
/**
63+
* {@inheritdoc}
64+
*/
65+
public function getViolations() {
66+
return $this->violations;
67+
}
68+
69+
/**
70+
* {@inheritdoc}
71+
*/
72+
public function getFileEntity() {
73+
return $this->fileEntity;
74+
}
75+
76+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace Drupal\graphql\Wrappers;
4+
5+
/**
6+
* Defines interface for file upload responses.
7+
*/
8+
interface FileUploadResponseInterface {
9+
10+
/**
11+
* Gets violations.
12+
*
13+
* @return array
14+
* List of violations.
15+
*/
16+
public function getViolations();
17+
18+
/**
19+
* Gets file entity.
20+
*
21+
* @return \Drupal\file\FileInterface|null
22+
* File entity.
23+
*/
24+
public function getFileEntity();
25+
26+
}

0 commit comments

Comments
 (0)