PageRenderTime 36ms CodeModel.GetById 8ms RepoModel.GetById 0ms app.codeStats 0ms

/src/FuelPHP/Upload/File.php

https://github.com/propcom/upload
PHP | 597 lines | 350 code | 75 blank | 172 comment | 35 complexity | ee94fe7eaa041c86a2bd242d850a5eb9 MD5 | raw file
  1. <?php
  2. /**
  3. * Part of the Fuel framework.
  4. *
  5. * @package FuelPHP
  6. * @version 2.0
  7. * @author Fuel Development Team
  8. * @license MIT License
  9. * @copyright 2010 - 2013 Fuel Development Team
  10. * @link http://fuelphp.com
  11. */
  12. namespace FuelPHP\Upload;
  13. /**
  14. * Files is a container for a single uploaded file
  15. */
  16. class File implements \ArrayAccess, \Iterator, \Countable
  17. {
  18. /**
  19. * Our custom error code constants
  20. */
  21. const UPLOAD_ERR_MAX_SIZE = 101;
  22. const UPLOAD_ERR_EXT_BLACKLISTED = 102;
  23. const UPLOAD_ERR_EXT_NOT_WHITELISTED = 103;
  24. const UPLOAD_ERR_TYPE_BLACKLISTED = 104;
  25. const UPLOAD_ERR_TYPE_NOT_WHITELISTED = 105;
  26. const UPLOAD_ERR_MIME_BLACKLISTED = 106;
  27. const UPLOAD_ERR_MIME_NOT_WHITELISTED = 107;
  28. const UPLOAD_ERR_MAX_FILENAME_LENGTH = 108;
  29. const UPLOAD_ERR_MOVE_FAILED = 109;
  30. const UPLOAD_ERR_DUPLICATE_FILE = 110;
  31. const UPLOAD_ERR_MKDIR_FAILED = 111;
  32. const UPLOAD_ERR_EXTERNAL_MOVE_FAILED = 112;
  33. /**
  34. * @var array Container for uploaded file objects
  35. */
  36. protected $container = array();
  37. /**
  38. * @var int index pointer for Iterator
  39. */
  40. protected $index = 0;
  41. /**
  42. * @var array Container for validation errors
  43. */
  44. protected $errors = array();
  45. /**
  46. * @var array Configuration values
  47. */
  48. protected $config = array(
  49. // validation settings
  50. 'max_size' => 0,
  51. 'max_length' => 0,
  52. 'ext_whitelist' => array(),
  53. 'ext_blacklist' => array(),
  54. 'type_whitelist' => array(),
  55. 'type_blacklist' => array(),
  56. 'mime_whitelist' => array(),
  57. 'mime_blacklist' => array(),
  58. // file settings
  59. 'prefix' => '',
  60. 'suffix' => '',
  61. 'extension' => '',
  62. 'randomize' => false,
  63. 'normalize' => false,
  64. 'normalize_separator' => '_',
  65. 'change_case' => false,
  66. // save-to-disk settings
  67. 'path' => '',
  68. 'create_path' => true,
  69. 'path_chmod' => 0777,
  70. 'file_chmod' => 0666,
  71. 'auto_rename' => true,
  72. 'overwrite' => false,
  73. );
  74. /**
  75. * @var bool Flag to indicate if validation has run on this object
  76. */
  77. protected $isValidated = false;
  78. /**
  79. * @var bool Flag to indicate the result of the validation run
  80. */
  81. protected $isValid = false;
  82. /**
  83. * @var array Container for callbacks
  84. */
  85. protected $callbacks = array();
  86. /**
  87. * @var mixed Callback to perform error string translations
  88. */
  89. protected $langCallback = null;
  90. /**
  91. * @var mixed Callback to a custom file move operation
  92. */
  93. protected $moveCallback = null;
  94. /**
  95. * Constructor
  96. *
  97. * @param array $file Array with unified information about the file uploaded
  98. */
  99. public function __construct(array $file, &$callbacks = array(), $langCallback = null, $moveCallback = null)
  100. {
  101. // store the file data for this file
  102. $this->container = $file;
  103. // the file callbacks reference
  104. $this->callbacks =& $callbacks;
  105. // and the system callbacks
  106. $this->langCallback = $langCallback;
  107. $this->moveCallback = $moveCallback;
  108. }
  109. /**
  110. * Magic getter, gives read access to all elements in the file container
  111. *
  112. * @param string $name name of the container item to get
  113. *
  114. * @return mixed value of the item, or null if the item does not exist
  115. */
  116. public function __get($name)
  117. {
  118. $name = strtolower($name);
  119. return isset($this->container[$name]) ? $this->container[$name] : null;
  120. }
  121. /**
  122. * Magic setter, gives write access to all elements in the file container
  123. *
  124. * @param string $name name of the container item to set
  125. * @param mixed $value value to set it to
  126. */
  127. public function __set($name, $value)
  128. {
  129. $name = strtolower($name);
  130. isset($this->container[$name]) and $this->container[$name] = $value;
  131. }
  132. /**
  133. * Return the validation state of this object
  134. *
  135. * @return bool
  136. */
  137. public function isValidated()
  138. {
  139. return $this->isValidated;
  140. }
  141. /**
  142. * Return the state of this object
  143. *
  144. * @return bool
  145. */
  146. public function isValid()
  147. {
  148. return $this->isValid;
  149. }
  150. /**
  151. * Return the error objects collected for this file upload
  152. *
  153. * @return array
  154. */
  155. public function getErrors()
  156. {
  157. return $this->isValidated ? $this->errors : array();
  158. }
  159. /**
  160. * Set the configuration for this file
  161. *
  162. * @param $name string|array name of the configuration item to set, or an array of configuration items
  163. * @param $value mixed if $name is an item name, this holds the configuration values for that item
  164. *
  165. * @return void
  166. */
  167. public function setConfig($item, $value = null)
  168. {
  169. // unify the parameters
  170. is_array($item) or $item = array($item => $value);
  171. // update the configuration
  172. foreach ($item as $name => $value)
  173. {
  174. array_key_exists($name, $this->config) and $this->config[$name] = $value;
  175. }
  176. }
  177. /**
  178. * Run validation on the uploaded file, based on the config being loaded
  179. *
  180. * @return bool
  181. */
  182. public function validate()
  183. {
  184. // reset the error container
  185. $this->errors = array();
  186. // validation starts, call the pre-validation callback
  187. $this->runCallbacks('before_validation');
  188. // was the upload of the file a success?
  189. if ($this->container['error'] == 0)
  190. {
  191. // add some filename details (pathinfo can't be trusted with utf-8 filenames!)
  192. $this->container['extension'] = ltrim(strrchr(ltrim($this->container['name'], '.'), '.'),'.');
  193. if (empty($this->container['extension']))
  194. {
  195. $this->container['filename'] = $this->container['name'];
  196. }
  197. else
  198. {
  199. $this->container['filename'] = substr($this->container['name'], 0, strlen($this->container['name'])-(strlen($this->container['extension'])+1));
  200. }
  201. // does this upload exceed the maximum size?
  202. if ( ! empty($this->config['max_size']) and is_numeric($this->config['max_size']) and $this->container['size'] > $this->config['max_size'])
  203. {
  204. $this->addError(static::UPLOAD_ERR_MAX_SIZE);
  205. }
  206. // add mimetype information
  207. try
  208. {
  209. $handle = finfo_open(FILEINFO_MIME_TYPE);
  210. $this->container['mimetype'] = finfo_file($handle, $this->container['tmp_name']);
  211. finfo_close($handle);
  212. }
  213. // this will only work if PHP errors are converted into ErrorException (like when you use FuelPHP)
  214. catch (\ErrorException $e)
  215. {
  216. $this->container['mimetype'] = false;
  217. $this->addError(UPLOAD_ERR_NO_FILE);
  218. }
  219. // always use the more specific of the mime types available
  220. if ($this->container['mimetype'] == 'application/octet-stream' and $this->container['type'] != $this->container['mimetype'])
  221. {
  222. $this->container['mimetype'] = $this->container['type'];
  223. }
  224. // make sure it contains something valid
  225. if (empty($this->container['mimetype']) or strpos($this->container['mimetype'], '/') === false)
  226. {
  227. $this->container['mimetype'] = 'application/octet-stream';
  228. }
  229. // split the mimetype info so we can run some tests
  230. preg_match('|^(.*)/(.*)|', $this->container['mimetype'], $mimeinfo);
  231. // check the file extension black- and whitelists
  232. if (in_array(strtolower($this->container['extension']), (array) $this->config['ext_blacklist']))
  233. {
  234. $this->addError(static::UPLOAD_ERR_EXT_BLACKLISTED);
  235. }
  236. elseif ( ! empty($this->config['ext_whitelist']) and ! in_array(strtolower($this->container['extension']), (array) $this->config['ext_whitelist']))
  237. {
  238. $this->addError(static::UPLOAD_ERR_EXT_NOT_WHITELISTED);
  239. }
  240. // check the file type black- and whitelists
  241. if (in_array($mimeinfo[1], (array) $this->config['type_blacklist']))
  242. {
  243. $this->addError(static::UPLOAD_ERR_TYPE_BLACKLISTED);
  244. }
  245. if ( ! empty($this->config['type_whitelist']) and ! in_array($mimeinfo[1], (array) $this->config['type_whitelist']))
  246. {
  247. $this->addError(static::UPLOAD_ERR_TYPE_NOT_WHITELISTED);
  248. }
  249. // check the file mimetype black- and whitelists
  250. if (in_array($this->container['mimetype'], (array) $this->config['mime_blacklist']))
  251. {
  252. $this->addError(static::UPLOAD_ERR_MIME_BLACKLISTED);
  253. }
  254. elseif ( ! empty($this->config['mime_whitelist']) and ! in_array($this->container['mimetype'], (array) $this->config['mime_whitelist']))
  255. {
  256. $this->addError(static::UPLOAD_ERR_MIME_NOT_WHITELISTED);
  257. }
  258. // update the status of this validation
  259. $this->isValid = empty($this->errors);
  260. // validation finished, call the post-validation callback
  261. $this->runCallbacks('after_validation');
  262. }
  263. else
  264. {
  265. // upload was already a failure, store the corresponding error
  266. $this->addError($this->container['error']);
  267. // and mark this validation a failure
  268. $this->isValid = false;
  269. }
  270. // set the flag to indicate we ran the validation
  271. $this->isValidated = true;
  272. // return the validation state
  273. return $this->isValid;
  274. }
  275. /**
  276. * Save the uploaded file
  277. *
  278. * @return bool
  279. */
  280. public function save()
  281. {
  282. // we can only save files marked as valid
  283. if ($this->isValid)
  284. {
  285. // make sure we have a valid path
  286. if (empty($this->container['path']))
  287. {
  288. $this->container['path'] = rtrim($this->config['path'], DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
  289. }
  290. if ( ! is_dir($this->container['path']) and (bool) $this->config['create_path'])
  291. {
  292. @mkdir($this->container['path'], $this->config['path_chmod'], true);
  293. if ( ! is_dir($this->container['path']))
  294. {
  295. throw new \DomainException('Can\'t save the uploaded file. Destination path specified does not exist.');
  296. }
  297. }
  298. // was a new name for the file given?
  299. if ( ! array_key_exists('filename', $this->container))
  300. {
  301. // do we need to generate a random filename?
  302. if ( (bool) $this->config['randomize'])
  303. {
  304. $this->container['filename'] = md5(serialize($this->container));
  305. }
  306. // do we need to normalize the filename?
  307. else
  308. {
  309. $this->container['filename'] = $this->container['name'];
  310. (bool) $this->config['normalize'] and $this->normalize();
  311. }
  312. }
  313. // array with all filename components
  314. $filename = array(
  315. $this->config['prefix'],
  316. $this->container['filename'],
  317. $this->config['suffix'],
  318. '',
  319. '.',
  320. empty($this->config['extension']) ? $this->container['extension'] : $this->config['extension']
  321. );
  322. // remove the dot if no extension is present
  323. empty($filename[5]) and $filename[4] = '';
  324. // need to modify case?
  325. switch($this->config['change_case'])
  326. {
  327. case 'upper':
  328. $filename = array_map(function($var) { return strtoupper($var); }, $filename);
  329. break;
  330. case 'lower':
  331. $filename = array_map(function($var) { return strtolower($var); }, $filename);
  332. break;
  333. default:
  334. break;
  335. }
  336. // if we're saving the file locally
  337. if ( ! $this->moveCallback)
  338. {
  339. // check if the file already exists
  340. if (file_exists($this->container['path'].implode('', $filename)))
  341. {
  342. if ( (bool) $this->config['auto_rename'])
  343. {
  344. $counter = 0;
  345. do
  346. {
  347. $filename[3] = '_'.++$counter;
  348. }
  349. while (file_exists($this->container['path'].implode('', $filename)));
  350. }
  351. else
  352. {
  353. if ( ! (bool) $this->config['overwrite'])
  354. {
  355. $this->addError(static::UPLOAD_ERR_DUPLICATE_FILE);
  356. }
  357. }
  358. }
  359. }
  360. // no need to store it as an array anymore
  361. $this->container['filename'] = implode('', $filename);
  362. // does the filename exceed the maximum length?
  363. if ( ! empty($this->config['max_length']) and strlen($this->container['filename']) > $this->config['max_length'])
  364. {
  365. $this->addError(static::UPLOAD_ERR_MAX_FILENAME_LENGTH);
  366. }
  367. // update the status of this validation
  368. $this->isValid = empty($this->errors);
  369. // if the file is still valid, run the before save callbacks
  370. if ($this->isValid)
  371. {
  372. // validation starts, call the pre-save callbacks
  373. $this->runCallbacks('before_save');
  374. // recheck the path, it might have been altered by a callback
  375. if ($this->isValid and ! is_dir($this->container['path']) and (bool) $this->config['create_path'])
  376. {
  377. @mkdir($this->container['path'], $this->config['path_chmod'], true);
  378. if ( ! is_dir($this->container['path']))
  379. {
  380. $this->addError(static::UPLOAD_ERR_MKDIR_FAILED);
  381. }
  382. }
  383. // update the status of this validation
  384. $this->isValid = empty($this->errors);
  385. }
  386. // if the file is still valid, move it
  387. if ($this->isValid)
  388. {
  389. // check if file should be moved to an ftp server
  390. if ($this->moveCallback)
  391. {
  392. $moved = call_user_func($this->container['tmp_name'], $this->container['path'].$this->container['filename']);
  393. if ( ! $moved)
  394. {
  395. $this->addError(static::UPLOAD_ERR_EXTERNAL_MOVE_FAILED);
  396. }
  397. }
  398. else
  399. {
  400. if( ! @move_uploaded_file($this->container['tmp_name'], $this->container['path'].$this->container['filename']))
  401. {
  402. $this->addError(static::UPLOAD_ERR_MOVE_FAILED);
  403. }
  404. else
  405. {
  406. @chmod($this->container['path'].$this->container['filename'], $this->config['file_chmod']);
  407. }
  408. }
  409. }
  410. // validation starts, call the post-save callback
  411. if ($this->isValid)
  412. {
  413. $this->runCallbacks('after_save');
  414. }
  415. }
  416. // return the status of this operation
  417. return empty($this->errors);
  418. }
  419. /**
  420. * Run callbacks of he defined type
  421. */
  422. protected function runCallbacks($type)
  423. {
  424. // make sure we have callbacks of this type
  425. if (array_key_exists($type, $this->callbacks))
  426. {
  427. // run the defined callbacks
  428. foreach ($this->callbacks[$type] as $callback)
  429. {
  430. // check if the callback is valid
  431. if (is_callable($callback))
  432. {
  433. // call the defined callback
  434. $result = call_user_func($callback, $this);
  435. // and process the results. we need FileError instances only
  436. foreach ((array) $result as $entry)
  437. {
  438. if (is_object($entry) and $entry instanceOf FileError)
  439. {
  440. $this->errors[] = $entry;
  441. }
  442. }
  443. // update the status of this validation
  444. $this->isValid = empty($this->errors);
  445. }
  446. }
  447. }
  448. }
  449. /**
  450. * Convert a filename into a normalized name. only outputs 7 bit ASCII characters.
  451. */
  452. protected function normalize()
  453. {
  454. // Decode all entities to their simpler forms
  455. $this->container['filename'] = html_entity_decode($this->container['filename'], ENT_QUOTES, 'UTF-8');
  456. // Remove all quotes
  457. $this->container['filename'] = preg_replace("#[\"\']#", '', $this->container['filename']);
  458. // Strip unwanted characters
  459. $this->container['filename'] = preg_replace("#[^a-z0-9]#i", $this->config['normalize_separator'], $this->container['filename']);
  460. $this->container['filename'] = preg_replace("#[/_|+ -]+#u", $this->config['normalize_separator'], $this->container['filename']);
  461. $this->container['filename'] = trim($this->container['filename'], $this->config['normalize_separator']);
  462. }
  463. /**
  464. * Add a new error object to the list
  465. *
  466. * @param array $entry uploaded file structure
  467. */
  468. protected function addError($error)
  469. {
  470. $this->errors[] = new FileError($error, $this->langCallback);
  471. }
  472. //------------------------------------------------------------------------------------------------------------------
  473. /**
  474. * Countable methods
  475. */
  476. public function count()
  477. {
  478. return count($this->container);
  479. }
  480. /**
  481. * ArrayAccess methods
  482. */
  483. public function offsetExists($offset)
  484. {
  485. return isset($this->container[$offset]);
  486. }
  487. public function offsetGet($offset)
  488. {
  489. return $this->container[$offset];
  490. }
  491. public function offsetSet($offset, $value)
  492. {
  493. $this->container[$offset] = $value;
  494. }
  495. public function offsetUnset($offset)
  496. {
  497. throw new \OutOfBoundsException('You can not unset a data element of an Upload File instance');
  498. }
  499. /**
  500. * Iterator methods
  501. */
  502. function rewind()
  503. {
  504. return reset($this->container);
  505. }
  506. function current()
  507. {
  508. return current($this->container);
  509. }
  510. function key()
  511. {
  512. return key($this->container);
  513. }
  514. function next()
  515. {
  516. return next($this->container);
  517. }
  518. function valid()
  519. {
  520. return key($this->container) !== null;
  521. }
  522. }