SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Controllers/FileUploadController.php

Maximum number of chunks that can be uploaded per field-session
  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Controllers;
  15.  
  16. use Exception;
  17.  
  18. use Sprout\Exceptions\FileUploadException;
  19. use Sprout\Helpers\Enc;
  20. use Sprout\Helpers\File;
  21. use Sprout\Helpers\FileConstants;
  22. use Sprout\Helpers\FileUpload;
  23. use Sprout\Helpers\Json;
  24. use Sprout\Helpers\Session;
  25. use Sprout\Helpers\View;
  26.  
  27.  
  28. /**
  29.  * Handles file uploads in chunks using the JS File API and XMLHttpRequest
  30.  */
  31. class FileUploadController extends Controller
  32. {
  33. /**
  34.   * Maximum number of chunks that can be uploaded per field-session
  35.   */
  36. const MAX_CHUNK_COUNT = 512;
  37.  
  38.  
  39. /**
  40.   * Checks POSTed code matched the expected format
  41.   *
  42.   * @return string The code itself, if it's valid
  43.   */
  44. protected function validateCode()
  45. {
  46. if (!preg_match('/^[a-z0-9]{32}$/i', @$_POST['code'])) {
  47. Json::error('Invalid data');
  48. }
  49.  
  50. return $_POST['code'];
  51. }
  52.  
  53.  
  54. /**
  55.   * Creates a session record for a new file upload, and configures its initial state
  56.   *
  57.   * @return &array
  58.   */
  59. protected function &startSession()
  60. {
  61. Session::instance();
  62.  
  63. if (empty($_POST['form_id']) or !is_string($_POST['form_id'])) {
  64. Json::error('Invalid data');
  65. }
  66.  
  67. if (empty($_POST['field_name']) or !is_string($_POST['field_name'])) {
  68. Json::error('Invalid data');
  69. }
  70.  
  71. $code = $this->validateCode();
  72.  
  73. $uri = $_POST['form_id'];
  74. $field_name = $_POST['field_name'];
  75.  
  76. if (isset($_SESSION['file_uploads'][$uri][$field_name][$code])) {
  77. Json::error('Invalid data');
  78. }
  79.  
  80. if (!isset($_SESSION['file_uploads'][$uri][$field_name])) {
  81. $_SESSION['file_uploads'][$uri][$field_name] = [];
  82. }
  83.  
  84. $_SESSION['file_uploads'][$uri][$field_name][$code] = [
  85. 'index' => 0,
  86. 'size' => 0,
  87. ];
  88.  
  89. return $_SESSION['file_uploads'][$uri][$field_name][$code];
  90. }
  91.  
  92.  
  93. /**
  94.   * Gets the session data associated with a file upload
  95.   * @return &array
  96.   */
  97. public function &session()
  98. {
  99. Session::instance();
  100.  
  101. if (empty($_POST['form_id']) or !is_string($_POST['form_id'])) {
  102. Json::error('Invalid data');
  103. }
  104.  
  105. if (empty($_POST['field_name']) or !is_string($_POST['field_name'])) {
  106. Json::error('Invalid data');
  107. }
  108.  
  109. $code = $this->validateCode();
  110. $uri = $_POST['form_id'];
  111. $field_name = $_POST['field_name'];
  112.  
  113. if (!isset($_SESSION['file_uploads'][$uri][$field_name][$code])) {
  114. Json::error('Invalid data');
  115. }
  116.  
  117. return $_SESSION['file_uploads'][$uri][$field_name][$code];
  118. }
  119.  
  120.  
  121. public function clearSession()
  122. {
  123. Session::instance();
  124.  
  125. $code = $this->validateCode();
  126. $uri = (string) @$_POST['uri'];
  127. $field_name = (string) @$_POST['field_name'];
  128.  
  129. unset($_SESSION['file_uploads'][$uri][$field_name][$code]);
  130.  
  131. if (empty($_SESSION['file_uploads'][$uri][$field_name])) {
  132. unset($_SESSION['file_uploads'][$uri][$field_name]);
  133. }
  134. }
  135.  
  136.  
  137. /**
  138.   * Signals that the user would like to upload a file
  139.   * Perform basic sanity checks and establish (or reset) their session state
  140.   * @post string code Their upload code
  141.   * @post string uri URI of the form processor, e.g. /user/process_register
  142.   * @post string field_name The name of the field
  143.   */
  144. public function uploadBegin()
  145. {
  146. $session = &$this->startSession();
  147.  
  148. Json::confirm([]);
  149. }
  150.  
  151.  
  152. /**
  153.   * Perform additional checks (beyond the checks already in {@see FileUploadController::uploadChunk}) on an uploaded chunk.
  154.   * To be implemented by subclasses
  155.   * @return void
  156.   */
  157. public function extraChunkCheck()
  158. {
  159. }
  160.  
  161.  
  162. /**
  163.   * Perform a size check on a set of uploaded chunks.
  164.   * To be implemented by subclasses
  165.   * @return void
  166.   */
  167. public function chunkSizeCheck()
  168. {
  169. }
  170.  
  171.  
  172. /**
  173.   * Perform a size check on a completed upload (after chunks have been stitched).
  174.   * To be implemented by subclasses
  175.   * @param string $temp_path Path to the temporary uploaded file
  176.   * @throws FileUploadException if the file is smaller than the allowed maximum for the field.
  177.   */
  178. public function fileSizeCheck($temp_path)
  179. {
  180. }
  181.  
  182.  
  183. /**
  184.   * Perform a file extension check.
  185.   * To be implemented by subclasses
  186.   * @throws FileUploadException if the file extension is matches the requirements for the field.
  187.   */
  188. public function fileExtCheck($temp_path)
  189. {
  190. $ext = strtolower(File::getExt($_GET['file']['name']));
  191. $ok = File::checkFileContentsExtension($temp_path, $ext);
  192. // N.B. $ok === null if there's no means of checking the content for a specific extension
  193. if ($ok === false) {
  194. throw new FileUploadException("File type doesn't match extension");
  195. }
  196. }
  197.  
  198.  
  199. /**
  200.   * Save a single chunk of a multi-part file upload
  201.   *
  202.   * @post string chunk Binary data
  203.   * @post int index Chunk index, 0-based
  204.   * @post string code Unique code for this upload
  205.   * @return void Outputs JSON
  206.   */
  207. public function uploadChunk()
  208. {
  209. $upload_state = &$this->session();
  210.  
  211. // TODO: implement rate-limiting and other anti-spam measures
  212. if (!is_dir(APPPATH . 'temp')) {
  213. Json::error('Temporary directory does not exist');
  214. }
  215. if (!is_writable(APPPATH . 'temp')) {
  216. Json::error('Temporary directory is not writable');
  217. }
  218.  
  219. // Ensure a file chunk was actually uploaded
  220. if (empty($_FILES['chunk']) or $_FILES['chunk']['error'] !== UPLOAD_ERR_OK) {
  221. Json::error('Error uploading chunk');
  222. }
  223.  
  224. // Check that there's actually content behind their upload
  225. if ($_FILES['chunk']['size'] <= 0) {
  226. Json::error('The uploaded file chunk was empty, there may have been a network fault during your upload');
  227. }
  228.  
  229. $field_name = (string) @$_POST['field_name'];
  230. if (!$field_name) {
  231. Json::error('Invalid data');
  232. }
  233.  
  234. $this->extraChunkCheck();
  235.  
  236. $_POST['index'] = (int) @$_POST['index'];
  237.  
  238. if (!$upload_state) {
  239. // @see uploadBegin
  240. Json::error('File upload wasn\'t started correctly');
  241. }
  242.  
  243. $upload_state['size'] += $_FILES['chunk']['size'];
  244.  
  245. $this->chunkSizeCheck();
  246.  
  247. if ($_POST['index'] < 0 or $upload_state['index'] !== $_POST['index']) {
  248. Json::error('File chunks received out of sequence, you may have experienced a network error');
  249. }
  250.  
  251. // Don't fill up the temp folder with too much junk
  252. if ($_POST['index'] > static::MAX_CHUNK_COUNT) {
  253. // Another likely malicious failure - wipe the data
  254. static::cleanupChunks($upload_state['code'], $upload_state['index']);
  255.  
  256. Json::error('Maximum number of upload chunks exceeded');
  257. }
  258.  
  259. $upload_state['index'] = $_POST['index'] + 1;
  260.  
  261. $filename = APPPATH . 'temp/chunk-' . $_POST['code'] . '-' . $_POST['index'] . '.dat';
  262. $result = move_uploaded_file($_FILES['chunk']['tmp_name'], $filename);
  263. if (!$result) {
  264. Json::error('Move of chunk to temporary directory failed');
  265. }
  266.  
  267. Json::confirm();
  268. }
  269.  
  270.  
  271. /**
  272.   * Stitch together uploaded chunks into an actual file
  273.   *
  274.   * Outputs a JSON response.
  275.   * The field "success" will be checked (= 1) to determine success.
  276.   * On error, the field "message" will be used as an error message.
  277.   * Other keys provided are passed to the ajaxDragdropForm method.
  278.   *
  279.   * @post num The total number of chunks uploaded
  280.   * @post string code Unique code for this upload
  281.   * @return void Outputs JSON
  282.   */
  283. public function uploadDone()
  284. {
  285. $upload_state = &$this->session();
  286.  
  287. $field_name = (string) @$_POST['field_name'];
  288.  
  289. $this->clearSession();
  290.  
  291. $_POST['num'] = (int) $_POST['num'];
  292. if ($upload_state['index'] !== $_POST['num']) {
  293. Json::error('Invalid number of chunks uploaded');
  294. }
  295.  
  296. $dest_filename = 'upload-' . time() . '-' . $_POST['code'] . '.dat';
  297.  
  298. try {
  299. $total_size = $this->stitchChunks(APPPATH . 'temp/' . $dest_filename, $_POST['code'], $_POST['num']);
  300.  
  301. if ($total_size !== $upload_state['size']) {
  302. Json::error('Final filesize didn\'t match upload size');
  303. }
  304. } catch (Exception $ex) {
  305. Json::error($ex->getMessage());
  306. }
  307.  
  308. Json::confirm(array(
  309. 'tmp_file' => $dest_filename,
  310. ));
  311. }
  312.  
  313.  
  314. /**
  315.   * Returns the form for updating a file which has been uploaded
  316.   *
  317.   * @get array file File details, as per the File API; 'lastModifiedDate', 'name', 'size', 'type'
  318.   * @get array result The full JSON response from the ajaxDragdropDone call
  319.   * @get array form Details of the form shown above the drag-n-drop field
  320.   * @return void Outputs HTML
  321.   */
  322. public function uploadForm()
  323. {
  324. $temp_path = APPPATH . 'temp/' . $_GET['result']['tmp_file'];
  325.  
  326. $_GET['file']['name'] = trim(Enc::cleanfunky($_GET['file']['name']));
  327.  
  328. $error = false;
  329.  
  330. if (!FileUpload::checkFilename($_GET['file']['name'])) {
  331. $error = 'This type of file cannot be uploaded';
  332. }
  333.  
  334. if (!$error) {
  335. try {
  336. $this->fileSizeCheck($temp_path);
  337. } catch (FileUploadException $ex) {
  338. $error = $ex->getMessage();
  339. }
  340. }
  341.  
  342. if (!$error) {
  343. try {
  344. $this->fileExtCheck($temp_path);
  345. } catch (FileUploadException $ex) {
  346. $error = $ex->getMessage();
  347. }
  348. }
  349.  
  350. $view = new View('sprout/file_confirm');
  351. if ($error) {
  352. $view->error = $error;
  353.  
  354. } else {
  355. $data = [];
  356. $data['name'] = str_replace('_', ' ', File::getNoext($_GET['file']['name']));
  357.  
  358. // Determine type from extension
  359. $data['type'] = File::getType($_GET['file']['name']);
  360.  
  361. // Attempt to use the last modified date as the publish date
  362. $ts = strtotime(@$_GET['file']['lastModifiedDate']);
  363. if (!$ts) $ts = time();
  364. $data['date_published'] = date('Y-m-d', $ts);
  365.  
  366. $view->tmp_file = $_GET['result']['tmp_file'];
  367. $view->orig_file = $_GET['file'];
  368. $view->data = $data;
  369.  
  370. if ($data['type'] == FileConstants::TYPE_IMAGE) {
  371. try {
  372. $view->shrunk_img = File::base64Thumb($temp_path, 200, 200);
  373. } catch (Exception $ex) {
  374. $view->image_too_large = true;
  375. }
  376. }
  377. }
  378.  
  379. echo $view->render();
  380. }
  381.  
  382.  
  383. /**
  384.   * Stitch together the uploaded file from multiple chunks
  385.   *
  386.   * @param string $dest_filename The destination filename
  387.   * @param string $code Upload code
  388.   * @param string $num_chunks The number of chunks to stitch together
  389.   * @return int Size of the final file in bytes
  390.   */
  391. private function stitchChunks($dest_filename, $code, $num_chunks) {
  392. $out = @fopen($dest_filename, 'w');
  393. if (!$out) {
  394. throw new Exception('Unable to open file for writing');
  395. }
  396.  
  397. // Copy chunks into the file. If anything goes wrong, the file will not be complete so bail
  398. $total_size = 0;
  399. $damaged = false;
  400. for ($i = 0; $i < $num_chunks; ++$i) {
  401. $chunk = APPPATH . 'temp/chunk-' . $code . '-' . $i . '.dat';
  402. if (!file_exists($chunk)) {
  403. $damaged = true;
  404. break;
  405. }
  406.  
  407. $in = @fopen($chunk, 'r');
  408. if (!$in) {
  409. $damaged = true;
  410. break;
  411. }
  412.  
  413. $result = @stream_copy_to_stream($in, $out);
  414. if (!$result) {
  415. $damaged = true;
  416. break;
  417. }
  418.  
  419. // stream_copy_to_stream returns the number of bytes copied
  420. $total_size += $result;
  421.  
  422. $result = @fclose($in);
  423. if (!$result) {
  424. $damaged = true;
  425. break;
  426. }
  427. }
  428.  
  429. $result = fclose($out);
  430. if (! $result) {
  431. $damaged = true;
  432. }
  433.  
  434. $this->cleanupChunks($code, $num_chunks);
  435.  
  436. if ($damaged) {
  437. throw new Exception('One or more chunks failed to be read');
  438. }
  439.  
  440. return $total_size;
  441. }
  442.  
  443.  
  444. /**
  445.   * Clean up any left-over chunks
  446.   * @param string $code The upload session code
  447.   * @param int $num_chunks The number of chunks
  448.   * @return void
  449.   */
  450. protected function cleanupChunks($code, $num_chunks)
  451. {
  452. for ($i = 0; $i < $num_chunks; ++$i) {
  453. $chunk = APPPATH . 'temp/chunk-' . $code . '-' . $i . '.dat';
  454. @unlink($chunk);
  455. }
  456. }
  457.  
  458.  
  459. /**
  460.   * Cancel an upload - delete temporary files.
  461.   *
  462.   * May receive two different variations on the provided POST data:
  463.   * [result][tmp_file] Whole file was uploaded
  464.   * [partial_upload][code] Only some chunks of the file have been uploaded
  465.   *
  466.   * @return void
  467.   */
  468. public function uploadCancel()
  469. {
  470. if (isset($_POST['result']['tmp_file'])) {
  471. // Whole file was uploaded
  472. $result = preg_match('/^upload-[0-9]+-[a-zA-Z0-9]{32}.dat$/', $_POST['result']['tmp_file']);
  473. if (!$result) {
  474. die('Invalid');
  475. }
  476.  
  477. @unlink(APPPATH . 'temp/' . $_POST['result']['tmp_file']);
  478.  
  479. } elseif (isset($_POST['partial_upload']['code'])) {
  480. // Only part of the file has been uploaded
  481. $result = preg_match('/^[a-zA-Z0-9]{32}$/', $_POST['partial_upload']['code']);
  482. if (!$result) {
  483. die('Invalid');
  484. }
  485.  
  486. $files = glob(APPPATH . 'temp/chunk-' . $_POST['partial_upload']['code'] . '-*.dat');
  487. foreach ($files as $file) {
  488. @unlink($file);
  489. }
  490.  
  491. } else {
  492. die('Invalid');
  493. }
  494.  
  495. echo 'Done';
  496. }
  497. }
  498.