PageRenderTime 27ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Backend/Modules/MediaLibrary/Component/UploadHandler.php

http://github.com/forkcms/forkcms
PHP | 381 lines | 300 code | 46 blank | 35 comment | 23 complexity | dfa74d0be790bd6c5791ac2ed142de7a MD5 | raw file
Possible License(s): MPL-2.0-no-copyleft-exception, MIT, AGPL-3.0, LGPL-2.1, BSD-3-Clause
  1. <?php
  2. namespace Backend\Modules\MediaLibrary\Component;
  3. use Backend\Modules\MediaLibrary\Manager\FileManager;
  4. use Exception;
  5. use Symfony\Component\HttpFoundation\File\UploadedFile;
  6. use Symfony\Component\HttpFoundation\Request;
  7. class UploadHandler
  8. {
  9. public $allowedExtensions = [];
  10. public $allowedMimeTypes = [];
  11. public $sizeLimit = null;
  12. public $inputName = 'qqfile';
  13. public $chunksFolder = 'chunks';
  14. public $chunksCleanupProbability = 0.001; // Once in 1000 requests on avg
  15. public $chunksExpireIn = 604800; // One week
  16. /** @var string */
  17. protected $uploadName;
  18. /** @var Request */
  19. protected $request;
  20. /** @var FileManager */
  21. protected $fileManager;
  22. public function __construct(Request $request, FileManager $fileManager)
  23. {
  24. $this->request = $request;
  25. $this->fileManager = $fileManager;
  26. }
  27. public function getName(): string
  28. {
  29. $fileName = $this->request->request->get('qqfilename');
  30. if ($fileName !== null) {
  31. return $fileName;
  32. }
  33. /** @var UploadedFile|null $file */
  34. $file = $this->request->files->get($this->inputName);
  35. if ($file instanceof UploadedFile) {
  36. return $file->getClientOriginalName();
  37. }
  38. }
  39. public function getUploadName(): string
  40. {
  41. return $this->uploadName;
  42. }
  43. public function combineChunks(string $uploadDirectory, string $name = null): array
  44. {
  45. $uuid = $this->request->request->get('qquuid');
  46. if ($name === null) {
  47. $name = $this->getName();
  48. }
  49. $targetFolder = $this->chunksFolder . DIRECTORY_SEPARATOR . $uuid;
  50. $totalParts = $this->request->request->getInt('qqtotalparts', 1);
  51. $targetPath = implode(DIRECTORY_SEPARATOR, [$uploadDirectory, $uuid, $name]);
  52. $this->uploadName = $name;
  53. if (!$this->fileManager->exists($targetPath)) {
  54. mkdir(dirname($targetPath), 0777, true);
  55. }
  56. $target = fopen($targetPath, 'wb');
  57. for ($i = 0; $i < $totalParts; ++$i) {
  58. $chunk = fopen($targetFolder . DIRECTORY_SEPARATOR . $i, 'rb');
  59. stream_copy_to_stream($chunk, $target);
  60. fclose($chunk);
  61. }
  62. // Success
  63. fclose($target);
  64. for ($i = 0; $i < $totalParts; ++$i) {
  65. unlink($targetFolder . DIRECTORY_SEPARATOR . $i);
  66. }
  67. rmdir($targetFolder);
  68. if ($this->sizeLimit !== null && filesize($targetPath) > $this->sizeLimit) {
  69. unlink($targetPath);
  70. http_response_code(413);
  71. return ['success' => false, 'uuid' => $uuid, 'preventRetry' => true];
  72. }
  73. return ['success' => true, 'uuid' => $uuid];
  74. }
  75. public function handleUpload(string $uploadDirectory, string $name = null): array
  76. {
  77. $this->cleanupChunksIfNecessary();
  78. try {
  79. $this->checkMaximumSize();
  80. $this->checkUploadDirectory($uploadDirectory);
  81. $this->checkType();
  82. $file = $this->getFile();
  83. $size = $this->getSize($file);
  84. if ($this->sizeLimit !== null && $size > $this->sizeLimit) {
  85. return ['error' => 'File is too large.', 'preventRetry' => true];
  86. }
  87. $name = $this->getRedefinedName($name);
  88. $this->checkFileExtension($name);
  89. $this->checkFileMimeType($file);
  90. } catch (Exception $e) {
  91. return ['error' => $e->getMessage()];
  92. }
  93. $uuid = $this->request->request->get('qquuid');
  94. // Chunked upload
  95. if ($this->request->request->getInt('qqtotalparts', 1) > 1) {
  96. $chunksFolder = $this->chunksFolder;
  97. $partIndex = $this->request->request->getInt('qqpartindex');
  98. if (!is_writable($chunksFolder) && !is_executable($uploadDirectory)) {
  99. return ['error' => "Server error. Chunks directory isn't writable or executable."];
  100. }
  101. $targetFolder = $this->chunksFolder . DIRECTORY_SEPARATOR . $uuid;
  102. if (!file_exists($targetFolder)) {
  103. mkdir($targetFolder, 0777, true);
  104. }
  105. $target = $targetFolder . '/' . $partIndex;
  106. $success = move_uploaded_file($file->getRealPath(), $target);
  107. return ['success' => $success, 'uuid' => $uuid];
  108. }
  109. // Non-chunked upload
  110. $target = implode(DIRECTORY_SEPARATOR, [$uploadDirectory, $uuid, $name]);
  111. if ($target) {
  112. $this->uploadName = basename($target);
  113. if (!is_dir(dirname($target))) {
  114. mkdir(dirname($target), 0777, true);
  115. }
  116. if (move_uploaded_file($file->getRealPath(), $target)) {
  117. return ['success' => true, 'uuid' => $uuid];
  118. }
  119. }
  120. return ['error' => 'Could not save uploaded file.' . 'The upload was cancelled, or server error encountered'];
  121. }
  122. private function checkMaximumSize(): void
  123. {
  124. // Check that the max upload size specified in class configuration does not exceed size allowed by server config
  125. if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit ||
  126. $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit
  127. ) {
  128. $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
  129. throw new Exception(
  130. 'Server error. Increase post_max_size and upload_max_filesize to ' . $neededRequestSize
  131. );
  132. }
  133. }
  134. /**
  135. * Determines whether a directory can be accessed.
  136. *
  137. * is_executable() is not reliable on Windows prior PHP 5.0.0
  138. * (http://www.php.net/manual/en/function.is-executable.php)
  139. * The following tests if the current OS is Windows and if so, merely
  140. * checks if the folder is writable;
  141. * otherwise, it checks additionally for executable status (like before).
  142. *
  143. * @param string $uploadDirectory
  144. *
  145. * @throws Exception
  146. */
  147. private function checkUploadDirectory(string $uploadDirectory): void
  148. {
  149. if (($this->isWindows() && !is_writable($uploadDirectory))
  150. || (!is_writable($uploadDirectory) && !is_executable($uploadDirectory))) {
  151. throw new Exception('Server error. Uploads directory isn\'t writable');
  152. }
  153. }
  154. private function checkType()
  155. {
  156. $type = $this->request->server->get('HTTP_CONTENT_TYPE', $this->request->server->get('CONTENT_TYPE'));
  157. if ($type === null) {
  158. throw new Exception('No files were uploaded.');
  159. }
  160. if (strpos(strtolower($type), 'multipart/') !== 0) {
  161. throw new Exception(
  162. 'Server error. Not a multipart request. Please set forceMultipart to default value (true).'
  163. );
  164. }
  165. }
  166. private function checkFileExtension(string $name): void
  167. {
  168. // Validate file extension
  169. $pathinfo = pathinfo($name);
  170. $ext = $pathinfo['extension'] ?? '';
  171. // Check file extension
  172. if (!in_array(strtolower($ext), array_map('strtolower', $this->allowedExtensions), true)) {
  173. $these = implode(', ', $this->allowedExtensions);
  174. throw new Exception('File has an invalid extension, it should be one of ' . $these . '.');
  175. }
  176. }
  177. private function checkFileMimeType(UploadedFile $file): void
  178. {
  179. // Check file mime type
  180. if (!in_array(strtolower($file->getMimeType()), array_map('strtolower', $this->allowedMimeTypes), true)) {
  181. $these = implode(', ', $this->allowedMimeTypes);
  182. throw new Exception('File has an invalid mime type, it should be one of ' . $these . '.');
  183. }
  184. }
  185. /**
  186. * Deletes all file parts in the chunks folder for files uploaded
  187. * more than chunksExpireIn seconds ago
  188. */
  189. private function cleanupChunksIfNecessary(): void
  190. {
  191. if (!is_writable($this->chunksFolder) || 1 !== random_int(1, 1 / $this->chunksCleanupProbability)) {
  192. return;
  193. }
  194. foreach (scandir($this->chunksFolder) as $item) {
  195. if ($item === '.' || $item === '..') {
  196. continue;
  197. }
  198. $path = $this->chunksFolder . DIRECTORY_SEPARATOR . $item;
  199. if (!is_dir($path)) {
  200. continue;
  201. }
  202. if (time() - filemtime($path) > $this->chunksExpireIn) {
  203. $this->fileManager->deleteFolder($path);
  204. }
  205. }
  206. }
  207. private function getFile(): UploadedFile
  208. {
  209. /** @var UploadedFile|null $file */
  210. $file = $this->request->files->get($this->inputName);
  211. // check file error
  212. if (!$file instanceof UploadedFile) {
  213. throw new Exception('Upload Error #UNKNOWN');
  214. }
  215. return $file;
  216. }
  217. private function getRedefinedName(string $name = null): string
  218. {
  219. if ($name === null) {
  220. $name = $this->getName();
  221. }
  222. // Validate name
  223. if ($name === null || $name === '') {
  224. throw new Exception('File name empty.');
  225. }
  226. return $name;
  227. }
  228. private function getSize(UploadedFile $file): int
  229. {
  230. $size = (int) $this->request->request->get('qqtotalfilesize', $file->getClientSize());
  231. // Validate file size
  232. if ($size === 0) {
  233. throw new Exception('File is empty.');
  234. }
  235. return $size;
  236. }
  237. /**
  238. * Returns a path to use with this upload. Check that the name does not exist,
  239. * and appends a suffix otherwise.
  240. *
  241. * @param string $uploadDirectory Target directory
  242. * @param string $filename The name of the file to use.
  243. *
  244. * @return false|string
  245. */
  246. protected function getUniqueTargetPath(string $uploadDirectory, string $filename)
  247. {
  248. // Allow only one process at the time to get a unique file name, otherwise
  249. // if multiple people would upload a file with the same name at the same time
  250. // only the latest would be saved.
  251. if (function_exists('sem_acquire')) {
  252. $lock = sem_get(ftok(__FILE__, 'u'));
  253. sem_acquire($lock);
  254. }
  255. $pathinfo = pathinfo($filename);
  256. $base = $pathinfo['filename'];
  257. $ext = isset($pathinfo['extension']) ? $pathinfo['extension'] : '';
  258. $ext = $ext === '' ? $ext : '.' . $ext;
  259. $unique = $base;
  260. $suffix = 0;
  261. // Get unique file name for the file, by appending random suffix.
  262. while ($this->fileManager->exists($uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext)) {
  263. $suffix += random_int(1, 999);
  264. $unique = $base . '-' . $suffix;
  265. }
  266. $result = $uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext;
  267. // Create an empty target file
  268. if (!touch($result)) {
  269. // Failed
  270. $result = false;
  271. }
  272. if (function_exists('sem_acquire')) {
  273. sem_release($lock);
  274. }
  275. return $result;
  276. }
  277. /**
  278. * Determines is the OS is Windows or not
  279. *
  280. * @return bool
  281. */
  282. protected function isWindows(): bool
  283. {
  284. return 0 === stripos(PHP_OS, 'WIN');
  285. }
  286. /**
  287. * Converts a given size with units to bytes.
  288. *
  289. * @param string $str
  290. *
  291. * @return int
  292. */
  293. protected function toBytes(string $str): int
  294. {
  295. $str = trim($str);
  296. $unit = strtolower($str[strlen($str) - 1]);
  297. if (is_numeric($unit)) {
  298. return (int) $str;
  299. }
  300. $val = (int) substr($str, 0, -1);
  301. switch (strtoupper($unit)) {
  302. case 'G':
  303. return $val * 1073741824;
  304. case 'M':
  305. return $val * 1048576;
  306. case 'K':
  307. return $val * 1024;
  308. default:
  309. return $val;
  310. }
  311. }
  312. }