1: <?php
2:
3: /**
4: * Base SystemFineUploadHandler class to work with ajaxfineupload.php endpoint
5: *
6: * Upload files as specified
7: *
8: * Do not use or reference this directly from your client-side code.
9: * Instead, this should be required via the endpoint.php or endpoint-cors.php
10: * file(s).
11: *
12: * @license MIT License (MIT)
13: * @copyright Copyright (c) 2015-present, Widen Enterprises, Inc.
14: * @link https://github.com/FineUploader/php-traditional-server
15: *
16: * The MIT License (MIT)
17: *
18: * Copyright (c) 2015-present, Widen Enterprises, Inc.
19: *
20: * Permission is hereby granted, free of charge, to any person obtaining a copy
21: * of this software and associated documentation files (the "Software"), to deal
22: * in the Software without restriction, including without limitation the rights
23: * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
24: * copies of the Software, and to permit persons to whom the Software is
25: * furnished to do so, subject to the following conditions:
26: *
27: * The above copyright notice and this permission notice shall be included in all
28: * copies or substantial portions of the Software.
29: *
30: * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31: * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
32: * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
33: * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
34: * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
35: * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
36: * SOFTWARE.
37: */
38:
39: abstract class SystemFineUploadHandler
40: {
41:
42: public $allowedExtensions = array();
43: public $allowedMimeTypes = array('(none)'); // must specify!
44: public $sizeLimit = null;
45: public $inputName = 'qqfile';
46: public $chunksFolder = 'chunks';
47:
48: public $chunksCleanupProbability = 0.001; // Once in 1000 requests on avg
49: public $chunksExpireIn = 604800; // One week
50:
51: protected $uploadName;
52: public $claims;
53:
54: /**
55: * XoopsFineUploadHandler constructor.
56: * @param stdClass $claims claims passed in JWT header
57: */
58: public function __construct(\stdClass $claims)
59: {
60: $this->claims = $claims;
61: }
62:
63: /**
64: * Get the original filename
65: */
66: public function getName()
67: {
68: if (isset($_REQUEST['qqfilename'])) {
69: return $_REQUEST['qqfilename'];
70: }
71:
72: if (isset($_FILES[$this->inputName])) {
73: return $_FILES[$this->inputName]['name'];
74: }
75: }
76:
77: /**
78: * Get the name of the uploaded file
79: * @return string
80: */
81: public function getUploadName()
82: {
83: return $this->uploadName;
84: }
85:
86: /**
87: * Combine chunks into a single file
88: *
89: * @param string $uploadDirectory upload directory
90: * @param string|null $name name
91: * @return array response to be json encoded and returned to client
92: */
93: public function combineChunks($uploadDirectory, $name = null)
94: {
95: $uuid = $_POST['qquuid'];
96: if ($name === null) {
97: $name = $this->getName();
98: }
99: $targetFolder = $this->chunksFolder.DIRECTORY_SEPARATOR.$uuid;
100: $totalParts = isset($_REQUEST['qqtotalparts']) ? (int)$_REQUEST['qqtotalparts'] : 1;
101:
102: $targetPath = join(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
103: $this->uploadName = $name;
104:
105: if (!file_exists($targetPath)) {
106: mkdir(dirname($targetPath), 0777, true);
107: }
108: $target = fopen($targetPath, 'wb');
109:
110: for ($i=0; $i<$totalParts; $i++) {
111: $chunk = fopen($targetFolder.DIRECTORY_SEPARATOR.$i, "rb");
112: stream_copy_to_stream($chunk, $target);
113: fclose($chunk);
114: }
115:
116: // Success
117: fclose($target);
118:
119: for ($i=0; $i<$totalParts; $i++) {
120: unlink($targetFolder.DIRECTORY_SEPARATOR.$i);
121: }
122:
123: rmdir($targetFolder);
124:
125: if (!is_null($this->sizeLimit) && filesize($targetPath) > $this->sizeLimit) {
126: unlink($targetPath);
127: //http_response_code(413);
128: header("HTTP/1.0 413 Request Entity Too Large");
129: return array("success" => false, "uuid" => $uuid, "preventRetry" => true);
130: }
131:
132: return array("success" => true, "uuid" => $uuid);
133: }
134:
135: /**
136: * Process the upload.
137: * @param string $uploadDirectory Target directory.
138: * @param string $name Overwrites the name of the file.
139: * @return array response to be json encoded and returned to client
140: */
141: public function handleUpload($uploadDirectory, $name = null)
142: {
143: if (is_writable($this->chunksFolder) &&
144: 1 == mt_rand(1, 1/$this->chunksCleanupProbability)) {
145: // Run garbage collection
146: $this->cleanupChunks();
147: }
148:
149: // Check that the max upload size specified in class configuration does not
150: // exceed size allowed by server config
151: if ($this->toBytes(ini_get('post_max_size')) < $this->sizeLimit ||
152: $this->toBytes(ini_get('upload_max_filesize')) < $this->sizeLimit) {
153: $neededRequestSize = max(1, $this->sizeLimit / 1024 / 1024) . 'M';
154: return array(
155: 'error'=>"Server error. Increase post_max_size and upload_max_filesize to ".$neededRequestSize
156: );
157: }
158:
159: if ($this->isInaccessible($uploadDirectory)) {
160: return array('error' => "Server error. Uploads directory isn't writable");
161: }
162:
163: $type = $_SERVER['CONTENT_TYPE'];
164: if (isset($_SERVER['HTTP_CONTENT_TYPE'])) {
165: $type = $_SERVER['HTTP_CONTENT_TYPE'];
166: }
167:
168: if (!isset($type)) {
169: return array('error' => "No files were uploaded.");
170: } elseif (strpos(strtolower($type), 'multipart/') !== 0) {
171: return array(
172: 'error' => "Server error. Not a multipart request. Please set forceMultipart to default value (true)."
173: );
174: }
175:
176: // Get size and name
177: $file = $_FILES[$this->inputName];
178: $size = $file['size'];
179: if (isset($_REQUEST['qqtotalfilesize'])) {
180: $size = $_REQUEST['qqtotalfilesize'];
181: }
182:
183: if ($name === null) {
184: $name = $this->getName();
185: }
186:
187: // check file error
188: if ($file['error']) {
189: return array('error' => 'Upload Error #'.$file['error']);
190: }
191:
192: // Validate name
193: if ($name === null || $name === '') {
194: return array('error' => 'File name empty.');
195: }
196:
197: // Validate file size
198: if ($size == 0) {
199: return array('error' => 'File is empty.');
200: }
201:
202: if (!is_null($this->sizeLimit) && $size > $this->sizeLimit) {
203: return array('error' => 'File is too large.', 'preventRetry' => true);
204: }
205:
206: // Validate file extension
207: $pathinfo = pathinfo($name);
208: $ext = isset($pathinfo['extension']) ? strtolower($pathinfo['extension']) : '';
209:
210: if ($this->allowedExtensions
211: && !in_array(strtolower($ext), array_map("strtolower", $this->allowedExtensions))) {
212: $these = implode(', ', $this->allowedExtensions);
213: return array(
214: 'error' => 'File has an invalid extension, it should be one of '. $these . '.',
215: 'preventRetry' => true
216: );
217: }
218:
219: $mimeType = '';
220: if (!empty($this->allowedMimeTypes)) {
221: $mimeType = mime_content_type($_FILES[$this->inputName]['tmp_name']);
222: if (!in_array($mimeType, $this->allowedMimeTypes)) {
223: return array('error' => 'File is of an invalid type.', 'preventRetry' => true);
224: }
225: }
226:
227: // Save a chunk
228: $totalParts = isset($_REQUEST['qqtotalparts']) ? (int)$_REQUEST['qqtotalparts'] : 1;
229:
230: $uuid = $_REQUEST['qquuid'];
231: if ($totalParts > 1) {
232: # chunked upload
233:
234: $chunksFolder = $this->chunksFolder;
235: $partIndex = (int)$_REQUEST['qqpartindex'];
236:
237: if (!is_writable($chunksFolder) && !is_executable($uploadDirectory)) {
238: return array('error' => "Server error. Chunks directory isn't writable or executable.");
239: }
240:
241: $targetFolder = $this->chunksFolder.DIRECTORY_SEPARATOR.$uuid;
242:
243: if (!file_exists($targetFolder)) {
244: mkdir($targetFolder, 0775, true);
245: }
246:
247: $target = $targetFolder.'/'.$partIndex;
248:
249: $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
250: if (false !== $storeResult) {
251: return $storeResult;
252: }
253: } else {
254: # non-chunked upload
255:
256: $target = join(DIRECTORY_SEPARATOR, array($uploadDirectory, $uuid, $name));
257:
258: if ($target) {
259: $this->uploadName = basename($target);
260:
261: $storeResult = $this->storeUploadedFile($target, $mimeType, $uuid);
262: if (false !== $storeResult) {
263: return $storeResult;
264: }
265: }
266:
267: return array('error'=> 'Could not save uploaded file.' .
268: 'The upload was cancelled, or server error encountered');
269: }
270: }
271:
272: protected function storeUploadedFile($target, $mimeType, $uuid)
273: {
274: if (!is_dir(dirname($target))) {
275: mkdir(dirname($target), 0775, true);
276: }
277: if (move_uploaded_file($_FILES[$this->inputName]['tmp_name'], $target)) {
278: return array('success'=> true, "uuid" => $uuid);
279: }
280: return false;
281: }
282:
283: /**
284: * Process a delete.
285: * @param string $uploadDirectory Target directory.
286: * @param string|null $name Overwrites the name of the file.
287: * @return array response to be json encoded and returned to client
288: */
289: public function handleDelete($uploadDirectory, $name = null)
290: {
291: if ($this->isInaccessible($uploadDirectory)) {
292: return array(
293: 'error' => "Server error. Uploads directory isn't writable"
294: . ((!$this->isWindows()) ? " or executable." : ".")
295: );
296: }
297:
298: $targetFolder = $uploadDirectory;
299: $uuid = false;
300: $method = $_SERVER["REQUEST_METHOD"];
301: if ($method == "DELETE") {
302: $url = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
303: $tokens = explode('/', $url);
304: $uuid = $tokens[sizeof($tokens)-1];
305: } elseif ($method == "POST") {
306: $uuid = $_REQUEST['qquuid'];
307: } else {
308: return array("success" => false,
309: "error" => "Invalid request method! ".$method
310: );
311: }
312:
313: $target = join(DIRECTORY_SEPARATOR, array($targetFolder, $uuid));
314:
315: if (is_dir($target)) {
316: $this->removeDir($target);
317: return array("success" => true, "uuid" => $uuid);
318: } else {
319: return array("success" => false,
320: "error" => "File not found! Unable to delete.".$url,
321: "path" => $uuid
322: );
323: }
324: }
325:
326: /**
327: * Returns a path to use with this upload. Check that the name does not exist,
328: * and appends a suffix otherwise.
329: * @param string $uploadDirectory Target directory
330: * @param string $filename The name of the file to use.
331: *
332: * @return string|false path or false if path could not be determined
333: */
334: protected function getUniqueTargetPath($uploadDirectory, $filename)
335: {
336: // Allow only one process at the time to get a unique file name, otherwise
337: // if multiple people would upload a file with the same name at the same time
338: // only the latest would be saved.
339:
340: if (function_exists('sem_acquire')) {
341: $lock = sem_get(ftok(__FILE__, 'u'));
342: sem_acquire($lock);
343: }
344:
345: $pathinfo = pathinfo($filename);
346: $base = $pathinfo['filename'];
347: $ext = isset($pathinfo['extension']) ? $pathinfo['extension'] : '';
348: $ext = $ext == '' ? $ext : '.' . $ext;
349:
350: $unique = $base;
351: $suffix = 0;
352:
353: // Get unique file name for the file, by appending random suffix.
354:
355: while (file_exists($uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext)) {
356: $suffix += rand(1, 999);
357: $unique = $base.'-'.$suffix;
358: }
359:
360: $result = $uploadDirectory . DIRECTORY_SEPARATOR . $unique . $ext;
361:
362: // Create an empty target file
363: if (!touch($result)) {
364: // Failed
365: $result = false;
366: }
367:
368: if (function_exists('sem_acquire')) {
369: sem_release($lock);
370: }
371:
372: return $result;
373: }
374:
375: /**
376: * Deletes all file parts in the chunks folder for files uploaded
377: * more than chunksExpireIn seconds ago
378: *
379: * @return void
380: */
381: protected function cleanupChunks()
382: {
383: foreach (scandir($this->chunksFolder) as $item) {
384: if ($item == "." || $item == "..") {
385: continue;
386: }
387:
388: $path = $this->chunksFolder.DIRECTORY_SEPARATOR.$item;
389:
390: if (!is_dir($path)) {
391: continue;
392: }
393:
394: if (time() - filemtime($path) > $this->chunksExpireIn) {
395: $this->removeDir($path);
396: }
397: }
398: }
399:
400: /**
401: * Removes a directory and all files contained inside
402: * @param string $dir
403: * @return void
404: */
405: protected function removeDir($dir)
406: {
407: foreach (scandir($dir) as $item) {
408: if ($item == "." || $item == "..") {
409: continue;
410: }
411:
412: if (is_dir($item)) {
413: $this->removeDir($item);
414: } else {
415: unlink(join(DIRECTORY_SEPARATOR, array($dir, $item)));
416: }
417: }
418: rmdir($dir);
419: }
420:
421: /**
422: * Converts a given size with units to bytes.
423: * @param string $str
424: * @return int
425: */
426: protected function toBytes($str)
427: {
428: $str = trim($str);
429: $last = strtolower($str[strlen($str)-1]);
430: if(is_numeric($last)) {
431: $val = (int) $str;
432: } else {
433: $val = (int) substr($str, 0, -1);
434: }
435: switch ($last) {
436: case 'g':
437: $val *= 1024; // fall thru
438: case 'm':
439: $val *= 1024; // fall thru
440: case 'k':
441: $val *= 1024; // fall thru
442: }
443: return $val;
444: }
445:
446: /**
447: * Determines whether a directory can be accessed.
448: *
449: * is_executable() is not reliable on Windows prior PHP 5.0.0
450: * (https://www.php.net/manual/en/function.is-executable.php)
451: * The following tests if the current OS is Windows and if so, merely
452: * checks if the folder is writable;
453: * otherwise, it checks additionally for executable status (like before).
454: *
455: * @param string $directory The target directory to test access
456: * @return bool true if directory is NOT accessible
457: */
458: protected function isInaccessible($directory)
459: {
460: $isWin = $this->isWindows();
461: $folderInaccessible =
462: ($isWin) ? !is_writable($directory) : (!is_writable($directory) && !is_executable($directory));
463: return $folderInaccessible;
464: }
465:
466: /**
467: * Determines is the OS is Windows or not
468: *
469: * @return boolean
470: */
471:
472: protected function isWindows()
473: {
474: $isWin = (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN');
475: return $isWin;
476: }
477: }
478: