PageRenderTime 53ms CodeModel.GetById 23ms RepoModel.GetById 0ms app.codeStats 0ms

/public_html/wire/core/WireUpload.php

https://bitbucket.org/thomas1151/mats
PHP | 788 lines | 357 code | 106 blank | 325 comment | 100 complexity | 42c9da118a348f5ff67965a5d353779f MD5 | raw file
Possible License(s): Apache-2.0, BSD-3-Clause, LGPL-2.1, MPL-2.0-no-copyleft-exception
  1. <?php namespace ProcessWire;
  2. /**
  3. * ProcessWire WireUpload
  4. *
  5. * Saves uploads of single or multiple files, saving them to the destination path.
  6. * If the destination path does not exist, it will be created.
  7. *
  8. * ProcessWire 3.x, Copyright 2016 by Ryan Cramer
  9. * https://processwire.com
  10. *
  11. */
  12. class WireUpload extends Wire {
  13. /**
  14. * Submitted field name for files field
  15. *
  16. * @var string
  17. *
  18. */
  19. protected $name;
  20. /**
  21. * Path where files will be saved
  22. *
  23. * @var string
  24. *
  25. */
  26. protected $destinationPath;
  27. /**
  28. * Max number of files accepted
  29. *
  30. * @var int
  31. *
  32. */
  33. protected $maxFiles;
  34. /**
  35. * Max size (in bytes) of uploaded file
  36. *
  37. * @var int
  38. *
  39. */
  40. protected $maxFileSize = 0;
  41. /**
  42. * Array of uploaded filenames (basenames)
  43. *
  44. * @var array
  45. *
  46. */
  47. protected $completedFilenames = array();
  48. /**
  49. * Allow files to be overwritten?
  50. *
  51. * @var bool
  52. *
  53. */
  54. protected $overwrite;
  55. /**
  56. * If specified, only this filename may be overwritten
  57. *
  58. * @var string
  59. *
  60. */
  61. protected $overwriteFilename = '';
  62. /**
  63. * Enforce lowercase filenames?
  64. *
  65. * @var bool
  66. *
  67. */
  68. protected $lowercase = true;
  69. /**
  70. * The filename to save to (if not using uploaded filename)
  71. *
  72. * @var string
  73. *
  74. */
  75. protected $targetFilename = '';
  76. /**
  77. * Allow extraction of archives/ZIPs?
  78. *
  79. * @var bool
  80. *
  81. */
  82. protected $extractArchives = false;
  83. /**
  84. * Allowed extensions for uploaded filenames
  85. *
  86. * @var array
  87. *
  88. */
  89. protected $validExtensions = array();
  90. /**
  91. * Disallowed extensions for uploaded filenames
  92. *
  93. * @var array
  94. *
  95. */
  96. protected $badExtensions = array('php', 'php3', 'phtml', 'exe', 'cfm', 'shtml', 'asp', 'pl', 'cgi', 'sh');
  97. /**
  98. * Errors that occurred
  99. *
  100. * @var array of strings
  101. *
  102. */
  103. protected $errors = array();
  104. /**
  105. * Allow AJAX uploads?
  106. *
  107. * @var bool
  108. *
  109. */
  110. protected $allowAjax = false;
  111. /**
  112. * Array of files (full paths) that were overwritten in format: backed up filename => replaced file name
  113. *
  114. * @var array
  115. *
  116. */
  117. protected $overwrittenFiles = array();
  118. /**
  119. * Predefined error message strings indexed by PHP UPLOAD_ERR_* defines
  120. *
  121. * @var array
  122. *
  123. */
  124. protected $errorInfo = array();
  125. /**
  126. * Construct with the given input name
  127. *
  128. * @param string $name
  129. *
  130. */
  131. public function __construct($name) {
  132. $this->errorInfo = array(
  133. UPLOAD_ERR_OK => $this->_('Successful Upload'),
  134. UPLOAD_ERR_INI_SIZE => $this->_('The uploaded file exceeds the upload_max_filesize directive in php.ini.'),
  135. UPLOAD_ERR_FORM_SIZE => $this->_('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.'),
  136. UPLOAD_ERR_PARTIAL => $this->_('The uploaded file was only partially uploaded.'),
  137. UPLOAD_ERR_NO_FILE => $this->_('No file was uploaded.'),
  138. UPLOAD_ERR_NO_TMP_DIR => $this->_('Missing a temporary folder.'),
  139. UPLOAD_ERR_CANT_WRITE => $this->_('Failed to write file to disk.'),
  140. UPLOAD_ERR_EXTENSION => $this->_('File upload stopped by extension.')
  141. );
  142. $this->setName($name);
  143. $this->maxFiles = 0; // no limit
  144. $this->overwrite = false;
  145. $this->destinationPath = '';
  146. if($this->config->uploadBadExtensions) {
  147. $badExtensions = $this->config->uploadBadExtensions;
  148. if(is_string($badExtensions) && $badExtensions) $badExtensions = explode(' ', $badExtensions);
  149. if(is_array($badExtensions)) $this->badExtensions = $badExtensions;
  150. }
  151. }
  152. /**
  153. * Destruct by removing overwritten backup files (if applicable)
  154. *
  155. */
  156. public function __destruct() {
  157. // cleanup files that were backed up when overwritten
  158. foreach($this->overwrittenFiles as $bakDestination => $destination) {
  159. if(is_file($bakDestination)) unlink($bakDestination);
  160. }
  161. }
  162. /**
  163. * Execute/process the upload
  164. *
  165. * @return array of uploaded filenames
  166. * @throws WireException
  167. *
  168. */
  169. public function execute() {
  170. if(!$this->name) throw new WireException("You must set the name for WireUpload before executing it");
  171. if(!$this->destinationPath) throw new WireException("You must set the destination path for WireUpload before executing it");
  172. $files = array();
  173. $f = $this->getPhpFiles();
  174. if(!$f) return $files;
  175. if(is_array($f['name'])) {
  176. // multi file upload
  177. $cnt = 0;
  178. foreach($f['name'] as $key => $name) {
  179. if($this->maxFiles && ($cnt >= $this->maxFiles)) {
  180. $this->error($this->_('Max file upload limit reached'));
  181. break;
  182. }
  183. if(!$this->isValidUpload($f['name'][$key], $f['size'][$key], $f['error'][$key])) continue;
  184. if(!$this->saveUpload($f['tmp_name'][$key], $f['name'][$key])) continue;
  185. $cnt++;
  186. }
  187. $files = $this->completedFilenames;
  188. } else {
  189. // single file upload, including ajax
  190. if($this->isValidUpload($f['name'], $f['size'], $f['error'])) {
  191. $this->saveUpload($f['tmp_name'], $f['name'], !empty($f['ajax'])); // returns filename or false
  192. $files = $this->completedFilenames;
  193. }
  194. }
  195. return $files;
  196. }
  197. /**
  198. * Returns PHP's $_FILES or one constructed from an ajax upload
  199. *
  200. * @return array|bool
  201. * @throws WireException
  202. *
  203. */
  204. protected function getPhpFiles() {
  205. if(isset($_SERVER['HTTP_X_FILENAME']) && $this->allowAjax) return $this->getPhpFilesAjax();
  206. if(empty($_FILES) || !count($_FILES)) return false;
  207. if(!isset($_FILES[$this->name]) || !is_array($_FILES[$this->name])) return false;
  208. return $_FILES[$this->name];
  209. }
  210. /**
  211. * Get the directory where files should upload to
  212. *
  213. * @return string
  214. * @throws WireException If no suitable upload directory can be found
  215. *
  216. */
  217. protected function getUploadDir() {
  218. $config = $this->wire('config');
  219. $dir = $config->uploadTmpDir;
  220. if(!$dir && stripos(PHP_OS, 'WIN') === 0) {
  221. $dir = $config->paths->cache . 'uploads/';
  222. if(!is_dir($dir)) wireMkdir($dir);
  223. }
  224. if(!$dir || !is_writable($dir)) {
  225. $dir = ini_get('upload_tmp_dir');
  226. }
  227. if(!$dir || !is_writable($dir)) {
  228. $dir = sys_get_temp_dir();
  229. }
  230. if(!$dir || !is_writable($dir)) {
  231. throw new WireException(
  232. "Error writing to $dir. Please define \$config->uploadTmpDir and ensure it exists and is writable."
  233. );
  234. }
  235. return $dir;
  236. }
  237. /**
  238. * Handles an ajax file upload and constructs a resulting $_FILES
  239. *
  240. * @return array|bool
  241. * @throws WireException
  242. *
  243. */
  244. protected function getPhpFilesAjax() {
  245. if(!$filename = $_SERVER['HTTP_X_FILENAME']) return false;
  246. $filename = rawurldecode($filename); // per #1487
  247. $dir = $this->getUploadDir();
  248. $tmpName = tempnam($dir, wireClassName($this, false));
  249. file_put_contents($tmpName, file_get_contents('php://input'));
  250. $filesize = is_file($tmpName) ? filesize($tmpName) : 0;
  251. $error = $filesize ? UPLOAD_ERR_OK : UPLOAD_ERR_NO_FILE;
  252. $file = array(
  253. 'name' => $filename,
  254. 'tmp_name' => $tmpName,
  255. 'size' => $filesize,
  256. 'error' => $error,
  257. 'ajax' => true,
  258. );
  259. return $file;
  260. }
  261. /**
  262. * Does the given filename have a valid extension?
  263. *
  264. * @param string $name
  265. * @return bool
  266. *
  267. */
  268. protected function isValidExtension($name) {
  269. $pathInfo = pathinfo($name);
  270. if(!isset($pathInfo['extension'])) return false;
  271. $extension = strtolower($pathInfo['extension']);
  272. if(in_array($extension, $this->badExtensions)) return false;
  273. if(in_array($extension, $this->validExtensions)) return true;
  274. return false;
  275. }
  276. /**
  277. * Is the given upload information valid?
  278. *
  279. * Also populates $this->errors
  280. *
  281. * @param string $name Filename
  282. * @param int $size Size in bytes
  283. * @param int $error Error code from PHP
  284. * @return bool
  285. *
  286. */
  287. protected function isValidUpload($name, $size, $error) {
  288. $valid = false;
  289. $fname = $this->wire('sanitizer')->name($name);
  290. if($error && $error != UPLOAD_ERR_NO_FILE) {
  291. $this->error($this->errorInfo[$error]);
  292. } else if(!$size) {
  293. $valid = false; // no data
  294. } else if($name[0] == '.') {
  295. $valid = false;
  296. } else if(!$this->isValidExtension($name)) {
  297. $this->error(
  298. "$fname - " . $this->_('Invalid file extension, please use one of:') . ' ' .
  299. implode(', ', $this->validExtensions)
  300. );
  301. } else if($this->maxFileSize > 0 && $size > $this->maxFileSize) {
  302. $this->error("$fname - " . $this->_('Exceeds max allowed file size'));
  303. } else {
  304. $valid = true;
  305. }
  306. return $valid;
  307. }
  308. /**
  309. * Check that the destination path exists and populate $this->errors with appropriate message if it doesn't
  310. *
  311. * @return bool
  312. *
  313. */
  314. protected function checkDestinationPath() {
  315. if(!is_dir($this->destinationPath)) {
  316. $this->error("Destination path does not exist {$this->destinationPath}");
  317. return false;
  318. }
  319. return true;
  320. }
  321. /**
  322. * Given a filename/path destination, adjust it to ensure it is unique
  323. *
  324. * @param string $destination
  325. * @return string
  326. *
  327. */
  328. protected function getUniqueFilename($destination) {
  329. $cnt = 0;
  330. $p = pathinfo($destination);
  331. $basename = basename($p['basename'], ".$p[extension]");
  332. while(file_exists($destination)) {
  333. $cnt++;
  334. $filename = "$basename-$cnt.$p[extension]";
  335. $destination = "$p[dirname]/$filename";
  336. }
  337. return $destination;
  338. }
  339. /**
  340. * Sanitize/validate a given filename
  341. *
  342. * @param string $value Filename
  343. * @param array $extensions Allowed file extensions
  344. * @return bool|string Returns boolean false if invalid or string of potentially modified filename if valid
  345. *
  346. */
  347. public function validateFilename($value, $extensions = array()) {
  348. $value = basename($value);
  349. if($value[0] == '.') return false; // no hidden files
  350. if($this->lowercase) $value = function_exists('mb_strtolower') ? mb_strtolower($value) : strtolower($value);
  351. $value = $this->wire('sanitizer')->filename($value, Sanitizer::translate);
  352. $value = trim($value, "_");
  353. if(!strlen($value)) return false;
  354. $p = pathinfo($value);
  355. if(!isset($p['extension'])) return false;
  356. $extension = strtolower($p['extension']);
  357. $basename = basename($p['basename'], ".$extension");
  358. // replace any dots in the basename with underscores
  359. $basename = trim(str_replace(".", "_", $basename), "_");
  360. $value = "$basename.$extension";
  361. if(count($extensions)) {
  362. if(!in_array($extension, $extensions)) $value = false;
  363. }
  364. return $value;
  365. }
  366. /**
  367. * Save the uploaded file
  368. *
  369. * @param string $tmp_name Temporary filename
  370. * @param string $filename Actual filename
  371. * @param bool $ajax Is this an AJAX upload?
  372. * @return array|bool|string Boolean false on fail, array of multiple filenames, or string of filename if maxFiles=1
  373. *
  374. */
  375. protected function saveUpload($tmp_name, $filename, $ajax = false) {
  376. if(!$this->checkDestinationPath()) return false;
  377. $success = false;
  378. $error = '';
  379. $filename = $this->getTargetFilename($filename);
  380. $_filename = $filename;
  381. $filename = $this->validateFilename($filename);
  382. if(!$filename && $this->name) {
  383. // if filename doesn't validate, generate filename based on field name
  384. $ext = pathinfo($_filename, PATHINFO_EXTENSION);
  385. $filename = $this->name . ".$ext";
  386. $filename = $this->validateFilename($filename);
  387. $this->overwrite = false;
  388. }
  389. $destination = $this->destinationPath . $filename;
  390. $p = pathinfo($destination);
  391. if($filename) {
  392. if($this->lowercase) {
  393. $filename = function_exists('mb_strtolower') ? mb_strtolower($filename) : strtolower($filename);
  394. }
  395. $exists = file_exists($destination);
  396. if(!$this->overwrite && $filename != $this->overwriteFilename) {
  397. // overwrite not allowed, so find a new name for it
  398. $destination = $this->getUniqueFilename($destination);
  399. $filename = basename($destination);
  400. } else if($exists && $this->overwrite) {
  401. // file already exists in destination and will be overwritten
  402. // here we back it up temporarily, and we don't remove the backup till __destruct()
  403. $bakName = $filename;
  404. do {
  405. $bakName = "_$bakName";
  406. $bakDestination = $this->destinationPath . $bakName;
  407. } while(file_exists($bakDestination));
  408. rename($destination, $bakDestination);
  409. $this->overwrittenFiles[$bakDestination] = $destination;
  410. }
  411. if($ajax) {
  412. $success = @rename($tmp_name, $destination);
  413. } else {
  414. $success = move_uploaded_file($tmp_name, $destination);
  415. }
  416. } else {
  417. $error = "Filename does not validate";
  418. }
  419. if(!$success) {
  420. if(!$destination || !$filename) $destination = $this->destinationPath . 'invalid-filename';
  421. if(!$error) $error = "Unable to move uploaded file to: $destination";
  422. $this->error($error);
  423. if(is_file($tmp_name)) @unlink($tmp_name);
  424. return false;
  425. }
  426. $this->wire('files')->chmod($destination);
  427. if($p['extension'] == 'zip' && ($this->maxFiles == 0) && $this->extractArchives) {
  428. if($this->saveUploadZip($destination)) {
  429. if(count($this->completedFilenames) == 1) return $this->completedFilenames[0];
  430. }
  431. return $this->completedFilenames;
  432. } else {
  433. $this->completedFilenames[] = $filename;
  434. return $filename;
  435. }
  436. }
  437. /**
  438. * Save and process an uploaded ZIP file
  439. *
  440. * @param string $zipFile
  441. * @return array|bool Array of files in the ZIP or boolean false on fail
  442. * @throws WireException If ZIP is empty
  443. *
  444. */
  445. protected function saveUploadZip($zipFile) {
  446. // unzip with command line utility
  447. $files = array();
  448. $dir = dirname($zipFile) . '/';
  449. $tmpDir = $dir . '.zip_tmp/';
  450. try {
  451. $files = $this->wire('files')->unzip($zipFile, $tmpDir);
  452. if(!count($files)) {
  453. throw new WireException($this->_('No files found in ZIP file'));
  454. }
  455. } catch(\Exception $e) {
  456. $this->error($e->getMessage());
  457. $this->wire('files')->rmdir($tmpDir, true);
  458. unlink($zipFile);
  459. return $files;
  460. }
  461. $cnt = 0;
  462. foreach($files as $file) {
  463. $pathname = $tmpDir . $file;
  464. if(!$this->isValidUpload($file, filesize($pathname), UPLOAD_ERR_OK)) {
  465. @unlink($pathname);
  466. continue;
  467. }
  468. $basename = $file;
  469. $basename = $this->validateFilename($basename, $this->validExtensions);
  470. if($basename) {
  471. $destination = $dir . $basename;
  472. if(file_exists($destination) && $this->overwrite) {
  473. $bakName = $basename;
  474. do {
  475. $bakName = "_$bakName";
  476. $bakDestination = $dir . $bakName;
  477. } while(file_exists($bakDestination));
  478. rename($destination, $bakDestination);
  479. $this->wire('log')->message("Renamed $destination => $bakDestination");
  480. $this->overwrittenFiles[$bakDestination] = $destination;
  481. } else {
  482. $destination = $this->getUniqueFilename($dir . $basename);
  483. }
  484. } else {
  485. $destination = '';
  486. }
  487. if($destination && rename($pathname, $destination)) {
  488. $this->completedFilenames[] = basename($destination);
  489. $cnt++;
  490. } else {
  491. @unlink($pathname);
  492. }
  493. }
  494. $this->wire('files')->rmdir($tmpDir, true);
  495. @unlink($zipFile);
  496. if(!$cnt) return false;
  497. return true;
  498. }
  499. /**
  500. * Get array of uploaded filenames
  501. *
  502. * @return array
  503. *
  504. */
  505. public function getCompletedFilenames() {
  506. return $this->completedFilenames;
  507. }
  508. /**
  509. * Set the target filename, only useful for single uploads
  510. *
  511. * @param $filename
  512. *
  513. */
  514. public function setTargetFilename($filename) {
  515. $this->targetFilename = $filename;
  516. }
  517. /**
  518. * Get target filename updated for extension
  519. *
  520. * Given a filename, takes its extension and combines it with that if the targetFilename (if set).
  521. * Otehrwise returns the filename you gave it.
  522. *
  523. * @param string $filename
  524. * @return string
  525. *
  526. */
  527. protected function getTargetFilename($filename) {
  528. if(!$this->targetFilename) return $filename;
  529. $pathInfo = pathinfo($filename);
  530. $targetPathInfo = pathinfo($this->targetFilename);
  531. return rtrim(basename($this->targetFilename, $targetPathInfo['extension']), ".") . "." . $pathInfo['extension'];
  532. }
  533. /**
  534. * Set the filename that may be overwritten (i.e. myphoto.jpg) for single uploads only
  535. *
  536. * @param string $filename
  537. * @return $this
  538. *
  539. */
  540. public function setOverwriteFilename($filename) {
  541. $this->overwrite = false; // required
  542. if($this->lowercase) $filename = strtolower($filename);
  543. $this->overwriteFilename = $filename;
  544. return $this;
  545. }
  546. /**
  547. * Set allowed file extensions
  548. *
  549. * @param array $extensions Array of file extensions (strings), not including periods
  550. * @return $this
  551. *
  552. */
  553. public function setValidExtensions(array $extensions) {
  554. foreach($extensions as $ext) $this->validExtensions[] = strtolower($ext);
  555. return $this;
  556. }
  557. /**
  558. * Set the max allowed number of uploaded files
  559. *
  560. * @param int $maxFiles
  561. * @return $this
  562. *
  563. */
  564. public function setMaxFiles($maxFiles) {
  565. $this->maxFiles = (int) $maxFiles;
  566. return $this;
  567. }
  568. /**
  569. * Set the max allowed uploaded file size
  570. *
  571. * @param int $bytes
  572. * @return $this
  573. *
  574. */
  575. public function setMaxFileSize($bytes) {
  576. $this->maxFileSize = (int) $bytes;
  577. return $this;
  578. }
  579. /**
  580. * Set whether or not overwrite is allowed
  581. *
  582. * @param bool $overwrite
  583. * @return $this
  584. *
  585. */
  586. public function setOverwrite($overwrite) {
  587. $this->overwrite = $overwrite ? true : false;
  588. return $this;
  589. }
  590. /**
  591. * Set the destination path for uploaded files
  592. *
  593. * @param string $destinationPath Include a trailing slash
  594. * @return $this
  595. *
  596. */
  597. public function setDestinationPath($destinationPath) {
  598. $this->destinationPath = $destinationPath;
  599. return $this;
  600. }
  601. /**
  602. * Set whether or not ZIP files may be extracted
  603. *
  604. * @param bool $extract
  605. * @return $this
  606. *
  607. */
  608. public function setExtractArchives($extract = true) {
  609. $this->extractArchives = $extract;
  610. $this->validExtensions[] = 'zip';
  611. return $this;
  612. }
  613. /**
  614. * Set the upload field name (same as that provided to the constructor)
  615. *
  616. * @param string $name
  617. * @return $this
  618. *
  619. */
  620. public function setName($name) {
  621. $this->name = $this->wire('sanitizer')->fieldName($name);
  622. return $this;
  623. }
  624. /**
  625. * Set whether or not lowercase is enforced
  626. *
  627. * @param bool $lowercase
  628. * @return $this
  629. *
  630. */
  631. public function setLowercase($lowercase = true) {
  632. $this->lowercase = $lowercase ? true : false;
  633. return $this;
  634. }
  635. /**
  636. * Set whether or not AJAX uploads are allowed
  637. *
  638. * @param bool $allowAjax
  639. * @return $this
  640. *
  641. */
  642. public function setAllowAjax($allowAjax = true) {
  643. $this->allowAjax = $allowAjax ? true : false;
  644. return $this;
  645. }
  646. /**
  647. * Record an error message
  648. *
  649. * @param array|Wire|string $text
  650. * @param int $flags
  651. * @return Wire|WireUpload
  652. *
  653. */
  654. public function error($text, $flags = 0) {
  655. $this->errors[] = $text;
  656. return parent::error($text, $flags);
  657. }
  658. /**
  659. * Get error messages
  660. *
  661. * @param bool $clear Clear the list of error messages? (default=false)
  662. * @return array of strings
  663. *
  664. */
  665. public function getErrors($clear = false) {
  666. $errors = $this->errors;
  667. if($clear) $this->errors = array();
  668. return $errors;
  669. }
  670. /**
  671. * Get files that were overwritten (for overwrite mode only)
  672. *
  673. * WireUpload keeps a temporary backup of replaced files. The backup will be removed at __destruct()
  674. * You may retrieve backed up files temporarily if needed.
  675. *
  676. * @return array associative array of ('backup path/file' => 'replaced basename')
  677. *
  678. */
  679. public function getOverwrittenFiles() {
  680. return $this->overwrittenFiles;
  681. }
  682. }