1<?php 2 3namespace App\Utils; 4 5/** 6 * A basic upload service class for uploading files via HTML forms. 7 */ 8class Uploader 9{ 10 /** 11 * Maximum allowed file size in bytes. 12 * @var int 13 */ 14 private $maxFileSize = 2 * 1024 * 1024; 15 16 /** 17 * Valid file extension. 18 * @var string 19 */ 20 private $validExtension; 21 22 /** 23 * Valid media types. 24 * @var array 25 */ 26 private $validMediaTypes; 27 28 /** 29 * Destination directory. 30 * @var string 31 */ 32 private $dir; 33 34 /** 35 * Destination file name. 36 * @var string 37 */ 38 private $destinationFileName; 39 40 /** 41 * Set the maximum allowed file size in bytes. 42 */ 43 public function setMaxFileSize(int $maxFileSize): void 44 { 45 $this->maxFileSize = $maxFileSize; 46 } 47 48 /** 49 * Set allowed file extension without leading dot. For example, 'tgz'. 50 */ 51 public function setValidExtension(string $validExtension): void 52 { 53 $this->validExtension = $validExtension; 54 } 55 56 /** 57 * Set array of valid media types. 58 */ 59 public function setValidMediaTypes(array $validMediaTypes): void 60 { 61 $this->validMediaTypes = $validMediaTypes; 62 } 63 64 /** 65 * Set destination directory. 66 */ 67 public function setDir(string $dir): void 68 { 69 $this->dir = $dir; 70 } 71 72 /** 73 * Set the destination file name. 74 */ 75 public function setDestinationFileName(string $destinationFileName): void 76 { 77 $this->destinationFileName = $destinationFileName; 78 } 79 80 /** 81 * Upload file. 82 */ 83 public function upload(string $key): string 84 { 85 $files = isset($_FILES[$key]) ? $_FILES[$key] : []; 86 87 // Check if uploaded file size exceeds the ini post_max_size directive. 88 if( 89 empty($_FILES) 90 && empty($_POST) 91 && isset($_SERVER['REQUEST_METHOD']) 92 && strtolower($_SERVER['REQUEST_METHOD']) === 'post' 93 ) { 94 $max = ini_get('post_max_size'); 95 throw new \Exception('Error on upload: Exceeded POST content length server limit of '.$max); 96 } 97 98 // Some other upload error happened 99 if (empty($files) || $files['error'] !== UPLOAD_ERR_OK) { 100 throw new \Exception('Error on upload: Something went wrong. Error code: '.$files['error']); 101 } 102 103 // Be sure we're dealing with an upload 104 if ($this->isUploadedFile($files['tmp_name']) === false) { 105 throw new \Exception('Error on upload: Invalid file definition'); 106 } 107 108 // Check file extension 109 $uploadedName = $files['name']; 110 $ext = $this->getFileExtension($uploadedName); 111 if (isset($this->validExtension) && $ext !== $this->validExtension) { 112 throw new \Exception('Error on upload: Invalid file extension. Should be .'.$this->validExtension); 113 } 114 115 // Check file size 116 if ($files['size'] > $this->maxFileSize) { 117 throw new \Exception('Error on upload: Exceeded file size limit '.$this->maxFileSize.' bytes'); 118 } 119 120 // Check zero length file size 121 if (!$files['size']) { 122 throw new \Exception('Error on upload: Zero-length patches are not allowed'); 123 } 124 125 // Check media type 126 $type = $this->getMediaType($files['tmp_name']); 127 if (isset($this->validMediaTypes) && !in_array($type, $this->validMediaTypes)) { 128 throw new \Exception('Error: Uploaded patch file must be text file 129 (save as e.g. "patch.txt" or "package.diff") 130 (detected "'.htmlspecialchars($type, ENT_QUOTES).'")' 131 ); 132 } 133 134 // Rename the uploaded file 135 $destination = $this->dir.'/'.$this->destinationFileName; 136 137 // Move uploaded file to final destination 138 if (!$this->moveUploadedFile($files['tmp_name'], $destination)) { 139 throw new \Exception('Error on upload: Something went wrong'); 140 } 141 142 return $destination; 143 } 144 145 /** 146 * Checks if given file has been uploaded via POST method. This is wrapped 147 * into a separate method for convenience of testing it via phpunit and using 148 * a mock. 149 */ 150 protected function isUploadedFile(string $file): bool 151 { 152 return is_uploaded_file($file); 153 } 154 155 /** 156 * Move uploaded file to destination. This method is wrapping PHP function 157 * to allow testing with PHPUnit and creating a mock object. 158 */ 159 protected function moveUploadedFile(string $source, string $destination): bool 160 { 161 return move_uploaded_file($source, $destination); 162 } 163 164 /** 165 * Rename file to a unique name. 166 */ 167 protected function renameFile(string $filename): ?string 168 { 169 $ext = $this->getFileExtension($filename); 170 171 $rand = uniqid(rand()); 172 173 $i = 0; 174 while (true) { 175 $newName = $rand.$i.'.'.$ext; 176 177 if (!file_exists($this->dir.'/'.$newName)) { 178 return $newName; 179 } 180 181 $i++; 182 } 183 } 184 185 /** 186 * Returns file extension without a leading dot. 187 */ 188 protected function getFileExtension(string $filename): string 189 { 190 return strtolower(substr($filename, strripos($filename, '.') + 1)); 191 } 192 193 /** 194 * Guess file media type (formerly known as MIME type) using the fileinfo 195 * extension. If fileinfo extension is not installed fallback to plain text 196 * type. 197 */ 198 protected function getMediaType(string $file): string 199 { 200 // If fileinfo extension is not available it defaults to text/plain. 201 if (!class_exists('finfo')) { 202 return 'text/plain'; 203 } 204 205 $finfo = new \finfo(FILEINFO_MIME); 206 207 if (!$finfo) { 208 throw new \Exception('Error: Opening fileinfo database failed.'); 209 } 210 211 // Get type for a specific file 212 $type = $finfo->file($file); 213 214 // Remove the charset part 215 $mediaType = explode(';', $type); 216 217 return isset($mediaType[0]) ? $mediaType[0] : 'text/plain'; 218 } 219} 220