diff --git a/src/Console/Commands/MigrateToStorageRequests.php b/src/Console/Commands/MigrateToStorageRequests.php deleted file mode 100644 index 8edbb2e..0000000 --- a/src/Console/Commands/MigrateToStorageRequests.php +++ /dev/null @@ -1,82 +0,0 @@ -argument('id')); - - if (StorageRequest::where('user_id', $user->id)->exists()) { - $this->error('The user already has existing storage requests!'); - - return 1; - } - - $disk = Storage::disk(config('user_storage.storage_disk')); - $prefix = "user-{$user->id}"; - $files = $disk->allFiles($prefix); - - if (empty($files)) { - $this->line('No files found.'); - - return 0; - } - - $totalSize = array_reduce($files, function ($carry, $file) use ($disk) { - return $carry + $disk->size($file); - }, 0); - - $files = array_map(function ($path) use ($prefix) { - return substr($path, strlen($prefix) + 1); - }, $files); - - $request = StorageRequest::make([ - 'user_id' => $user->id, - 'submitted_at' => now(), - 'expires_at' => now()->addMonths(config('user_storage.expires_months')), - 'files' => $files, - ]); - - $user->storage_quota_used += $totalSize; - - if (!$this->option('dry-run')) { - $request->save(); - $user->save(); - } - - $humanSize = size_for_humans($totalSize); - - $this->info("Migrated {$request->files_count} file(s) ({$humanSize})."); - - return 0; - } -} diff --git a/src/Database/Factories/StorageRequestFactory.php b/src/Database/Factories/StorageRequestFactory.php index 2da3da5..c0c2a75 100644 --- a/src/Database/Factories/StorageRequestFactory.php +++ b/src/Database/Factories/StorageRequestFactory.php @@ -24,7 +24,6 @@ public function definition() { return [ 'user_id' => User::factory(), - 'files' => [], 'expires_at' => null, 'submitted_at' => null, ]; diff --git a/src/Database/Factories/StorageRequestFileFactory.php b/src/Database/Factories/StorageRequestFileFactory.php new file mode 100644 index 0000000..a818864 --- /dev/null +++ b/src/Database/Factories/StorageRequestFileFactory.php @@ -0,0 +1,33 @@ + 'my/file.jpg', + 'storage_request_id' => StorageRequest::factory(), + 'size' => 123, + 'received_chunks' => null, + 'total_chunks' => null, + ]; + } +} diff --git a/src/Http/Controllers/Api/StorageRequestController.php b/src/Http/Controllers/Api/StorageRequestController.php index 96ccabb..ab60d88 100644 --- a/src/Http/Controllers/Api/StorageRequestController.php +++ b/src/Http/Controllers/Api/StorageRequestController.php @@ -9,10 +9,12 @@ use Biigle\Modules\UserStorage\Http\Requests\StoreStorageRequest; use Biigle\Modules\UserStorage\Http\Requests\UpdateStorageRequest; use Biigle\Modules\UserStorage\Jobs\ApproveStorageRequest as ApproveStorageRequestJob; +use Biigle\Modules\UserStorage\Jobs\AssembleChunkedFile; use Biigle\Modules\UserStorage\Notifications\StorageRequestRejected; use Biigle\Modules\UserStorage\Notifications\StorageRequestSubmitted; use Biigle\Modules\UserStorage\StorageRequest; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Bus; use Notification; class StorageRequestController extends Controller @@ -51,7 +53,7 @@ public function store(StoreStorageRequest $request) */ public function show($id) { - $request = StorageRequest::findOrFail($id); + $request = StorageRequest::with('files')->findOrFail($id); $this->authorize('access', $request); return $request; @@ -74,8 +76,18 @@ public function update(UpdateStorageRequest $request) { $storageRequest = $request->storageRequest; $storageRequest->update(['submitted_at' => now()]); - Notification::route('mail', config('biigle.admin_email')) - ->notify(new StorageRequestSubmitted($storageRequest)); + + if ($request->chunkedFiles->isNotEmpty()) { + Bus::batch($request->chunkedFiles->map(function ($file) { + return new AssembleChunkedFile($file); + }))->then(function () use ($storageRequest) { + Notification::route('mail', config('biigle.admin_email')) + ->notify(new StorageRequestSubmitted($storageRequest)); + })->dispatch(); + } else { + Notification::route('mail', config('biigle.admin_email')) + ->notify(new StorageRequestSubmitted($storageRequest)); + } } /** @@ -137,7 +149,6 @@ public function extend(ExtendStorageRequest $request) { $months = config('user_storage.expires_months'); $request->storageRequest->update(['expires_at' => now()->addMonths($months)]); - $request->storageRequest->setHidden(['files']); return $request->storageRequest; } diff --git a/src/Http/Controllers/Api/StorageRequestDirectoryController.php b/src/Http/Controllers/Api/StorageRequestDirectoryController.php index f684a63..2fe7832 100644 --- a/src/Http/Controllers/Api/StorageRequestDirectoryController.php +++ b/src/Http/Controllers/Api/StorageRequestDirectoryController.php @@ -4,7 +4,9 @@ use Biigle\Http\Controllers\Api\Controller; use Biigle\Modules\UserStorage\Http\Requests\DestroyStorageRequestDirectory; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFile; +use Biigle\Modules\UserStorage\StorageRequestFile; +use Queue; class StorageRequestDirectoryController extends Controller { @@ -25,11 +27,11 @@ class StorageRequestDirectoryController extends Controller */ public function destroy(DestroyStorageRequestDirectory $request) { - DeleteStorageRequestFiles::dispatch($request->storageRequest, $request->files); + $request->files->load('request'); + Queue::bulk($request->files->map(function ($file) { + return new DeleteStorageRequestFile($file); + })); - $request->storageRequest->files = array_values(array_diff( - $request->storageRequest->files, $request->files - )); - $request->storageRequest->save(); + StorageRequestFile::whereIn('id', $request->files->pluck('id'))->delete(); } } diff --git a/src/Http/Controllers/Api/StorageRequestFileController.php b/src/Http/Controllers/Api/StorageRequestFileController.php index cfc2065..bfa66e1 100644 --- a/src/Http/Controllers/Api/StorageRequestFileController.php +++ b/src/Http/Controllers/Api/StorageRequestFileController.php @@ -5,8 +5,9 @@ use Biigle\Http\Controllers\Api\Controller; use Biigle\Modules\UserStorage\Http\Requests\DestroyStorageRequestFile; use Biigle\Modules\UserStorage\Http\Requests\StoreStorageRequestFile; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFile; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; use DB; use Exception; @@ -40,6 +41,8 @@ class StorageRequestFileController extends Controller * @apiParam (Required arguments) {File} file The file to add to the storage request. * * @apiParam (Optional arguments) {string} prefix Optional prefix to prepend to the filename. Use slashes to create directories. + * @apiParam (Optional arguments) {int} chunk_index Index of the uploaded chunk in case the file is uploaded in chunks. The first chunk must be uploaded first. + * @apiParam (Optional arguments) {int} chunk_total Total number of chunks for this file in case the file is uploaded in chunks. * * @param StoreStorageRequestFile $request * @@ -47,19 +50,46 @@ class StorageRequestFileController extends Controller */ public function store(StoreStorageRequestFile $request) { - DB::transaction(function () use ($request) { + return DB::transaction(function () use ($request) { $sr = $request->storageRequest; $file = $request->file('file'); - $filePath = $request->getFilePath(); $disk = config('user_storage.pending_disk'); + $filePath = $request->getFilePath(); + $fileModel = $request->storageRequestFile; + + if ($request->isChunked()) { + $chunkIndex = (int) $request->input('chunk_index'); - $user = User::convert($sr->user); - $user->storage_quota_used += $file->getSize(); - $user->save(); + if ($fileModel) { + $fileModel->update([ + 'size' => $fileModel->size + $file->getSize(), + 'received_chunks' => array_merge($fileModel->received_chunks, [$chunkIndex]), + ]); + } elseif ($chunkIndex === 0) { + $fileModel = $sr->files()->create([ + 'path' => $filePath, + 'size' => $file->getSize(), + 'received_chunks' => [0], + 'total_chunks' => $request->input('chunk_total'), + ]); + } else { + // This should never be allowed by the validation. + throw new Exception('The first chunk must be uploaded first.'); + } - $sr->files = array_merge($sr->files, [$filePath]); - $sr->save(); + $filePath .= '.'.$chunkIndex; + + } else { + if ($fileModel) { + $fileModel->update(['size' => $file->getSize()]); + } else { + $fileModel = $sr->files()->create([ + 'path' => $filePath, + 'size' => $file->getSize(), + ]); + } + } // Retry the upload a few times, as we observed storage backends that threw // random errors which did not happen again after a retry. @@ -81,20 +111,20 @@ public function store(StoreStorageRequestFile $request) if ($success === false) { throw new Exception("Unable to save file."); } + + return $fileModel; }); } /** * Show a file of a storage request. * - * @api {get} storage-requests/:id/files/:path Show a file + * @api {get} storage-request-files/:id Show a file * @apiGroup UserStorage * @apiName ShowStorageRequestFile * @apiPermission admin * - * @apiParam {Number} id The storage request ID - * - * @apiParam (Required parameters) {String} path The file path + * @apiParam {Number} id The storage request file ID * * @param Request $request * @param int $id @@ -107,19 +137,14 @@ public function show(Request $request, $id) abort(Response::HTTP_NOT_FOUND); } - $storageRequest = StorageRequest::findOrFail($id); - $path = $request->input('path'); + $file = StorageRequestFile::with('request')->findOrFail($id); - if (!in_array($path, $storageRequest->files)) { - abort(Response::HTTP_NOT_FOUND); - } - - if (is_null($storageRequest->expires_at)) { + if (is_null($file->request->expires_at)) { $disk = Storage::disk(config('user_storage.pending_disk')); - $path = $storageRequest->getPendingPath($path); + $path = $file->request->getPendingPath($file->path); } else { $disk = Storage::disk(config('user_storage.storage_disk')); - $path = $storageRequest->getStoragePath($path); + $path = $file->request->getStoragePath($file->path); } try { @@ -145,26 +170,19 @@ public function show(Request $request, $id) /** * Delete files of a storage request * - * @api {delete} storage-requests/:id/files Delete files + * @api {delete} storage-request-files/:id Delete files * @apiGroup UserStorage * @apiName DestroyStorageRequestFile * @apiPermission storageRequestOwner * - * @apiParam {Number} id The storage request ID. - * - * @apiParam (Required arguments) {File[]} files Array of file paths that should be deleted from this storage request. + * @apiParam {Number} id The storage request file ID. * * @param DestroyStorageRequestFile $request * @return \Illuminate\Http\Response */ public function destroy(DestroyStorageRequestFile $request) { - $files = $request->input('files'); - DeleteStorageRequestFiles::dispatch($request->storageRequest, $files); - - $request->storageRequest->files = array_values(array_diff( - $request->storageRequest->files, $files - )); - $request->storageRequest->save(); + DeleteStorageRequestFile::dispatch($request->file); + $request->file->delete(); } } diff --git a/src/Http/Controllers/Views/StorageRequestController.php b/src/Http/Controllers/Views/StorageRequestController.php index 339d4a6..9b08f93 100644 --- a/src/Http/Controllers/Views/StorageRequestController.php +++ b/src/Http/Controllers/Views/StorageRequestController.php @@ -26,10 +26,7 @@ public function index(Request $request) $requests = StorageRequest::where('user_id', $user->id) ->whereNotNull('submitted_at') ->orderBy('submitted_at', 'desc') - ->get() - ->each(function ($request) { - $request->setHidden(['files']); - }); + ->get(); $expireDate = now()->addWeeks(config('user_storage.about_to_expire_weeks')); @@ -55,8 +52,10 @@ public function create(Request $request) $usedQuota = $user->storage_quota_used; $availableQuota = $user->storage_quota_available; $maxFilesize = config('user_storage.max_file_size'); + $chunkSize = config('user_storage.upload_chunk_size'); $previousRequest = StorageRequest::whereNull('submitted_at') + ->with('files') ->where('user_id', $user->id) ->first(); @@ -66,6 +65,7 @@ public function create(Request $request) 'usedQuota' => $usedQuota, 'availableQuota' => $availableQuota, 'maxFilesize' => $maxFilesize, + 'chunkSize' => $chunkSize, ]); } @@ -78,7 +78,9 @@ public function create(Request $request) */ public function review($id) { - $request = StorageRequest::whereNull('expires_at')->findOrFail($id); + $request = StorageRequest::whereNull('expires_at') + ->with('files') + ->findOrFail($id); $this->authorize('approve', $request); return view('user-storage::review', [ diff --git a/src/Http/Requests/ApproveStorageRequest.php b/src/Http/Requests/ApproveStorageRequest.php index 26c632f..50116b4 100644 --- a/src/Http/Requests/ApproveStorageRequest.php +++ b/src/Http/Requests/ApproveStorageRequest.php @@ -48,7 +48,7 @@ public function rules() public function withValidator($validator) { $validator->after(function ($validator) { - if (empty($this->storageRequest->files)) { + if (!$this->storageRequest->files()->exists()) { $validator->errors()->add('id', "The storage request has no files."); } }); diff --git a/src/Http/Requests/DestroyStorageRequestDirectory.php b/src/Http/Requests/DestroyStorageRequestDirectory.php index 4ea099e..5bc5937 100644 --- a/src/Http/Requests/DestroyStorageRequestDirectory.php +++ b/src/Http/Requests/DestroyStorageRequestDirectory.php @@ -59,9 +59,11 @@ public function withValidator($validator) return strpos($item, '/', -1) === false ? "{$item}/" : $item; }, $this->input('directories', [])); - $this->files = array_filter($this->storageRequest->files, function ($file) use ($directories) { + $files = $this->storageRequest->files; + $allFilesCount = $files->count(); + $this->files = $files->filter(function ($file) use ($directories) { return array_reduce($directories, function ($carry, $item) use ($file) { - return $carry || strpos($file, $item) === 0; + return $carry || strpos($file->path, $item) === 0; }, false); }); @@ -69,7 +71,7 @@ public function withValidator($validator) if ($filesCount === 0) { $validator->errors()->add('directories', 'No files were found for the specified directories.'); - } elseif ($filesCount === count($this->storageRequest->files)) { + } elseif ($filesCount === $allFilesCount) { $validator->errors()->add('directories', 'You cannot delete all files of the storage request this way. Delete the whole request instead.'); } }); diff --git a/src/Http/Requests/DestroyStorageRequestFile.php b/src/Http/Requests/DestroyStorageRequestFile.php index ef907a9..6f8f275 100644 --- a/src/Http/Requests/DestroyStorageRequestFile.php +++ b/src/Http/Requests/DestroyStorageRequestFile.php @@ -2,17 +2,17 @@ namespace Biigle\Modules\UserStorage\Http\Requests; -use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Illuminate\Foundation\Http\FormRequest; class DestroyStorageRequestFile extends FormRequest { /** - * Storage request that should be approved. + * Storage request file that should be deleted. * - * @var StorageRequest + * @var StorageRequestFile */ - public $storageRequest; + public $file; /** * Determine if the user is authorized to make this request. @@ -21,9 +21,9 @@ class DestroyStorageRequestFile extends FormRequest */ public function authorize() { - $this->storageRequest = StorageRequest::findOrFail($this->route('id')); + $this->file = StorageRequestFile::with('request')->findOrFail($this->route('id')); - return $this->user()->can('destroy', $this->storageRequest); + return $this->user()->can('destroy', $this->file->request); } /** @@ -34,7 +34,7 @@ public function authorize() public function rules() { return [ - 'files' => 'required|array|min:1', + // ]; } @@ -47,13 +47,7 @@ public function rules() public function withValidator($validator) { $validator->after(function ($validator) { - $files = $this->input('files', []); - $union = array_unique(array_merge($this->storageRequest->files, $files)); - $filesCount = count($this->storageRequest->files); - - if (count($union) > $filesCount) { - $validator->errors()->add('files', 'Some specified files do not belong to the storage request.'); - } elseif (count($files) === $filesCount) { + if ($this->file->request->files()->count() === 1) { $validator->errors()->add('files', 'You cannot delete all files of the storage request this way. Delete the whole request instead.'); } }); diff --git a/src/Http/Requests/StoreStorageRequestFile.php b/src/Http/Requests/StoreStorageRequestFile.php index 27239dc..6c0d221 100644 --- a/src/Http/Requests/StoreStorageRequestFile.php +++ b/src/Http/Requests/StoreStorageRequestFile.php @@ -3,8 +3,10 @@ namespace Biigle\Modules\UserStorage\Http\Requests; use Biigle\Image; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFile; use Biigle\Modules\UserStorage\Rules\FilePrefix; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; use Biigle\Video; use Illuminate\Foundation\Http\FormRequest; @@ -19,6 +21,13 @@ class StoreStorageRequestFile extends FormRequest */ public $storageRequest; + /** + * The file that belongs to a chunked upload (if any). + * + * @var StorageRequestFile + */ + public $storageRequestFile; + /** * Determine if the user is authorized to make this request. * @@ -38,31 +47,19 @@ public function authorize() */ public function rules() { - $user = User::convert($this->storageRequest->user); - - $maxQuota = $user->storage_quota_remaining; - $maxFile = config('user_storage.max_file_size'); - - // The "max" rule expects kilobyte but the quota is in byte. - $maxKb = intval(round(min($maxQuota, $maxFile) / 1000)); + $fileRules = 'required|file'; - $mimes = implode(',', array_merge(Image::MIMES, Video::MIMES)); + // Skip MIME type check for file chunks (except the first). + if (!$this->isChunked() || $this->input('chunk_index') === 0) { + $mimes = implode(',', array_merge(Image::MIMES, Video::MIMES)); + $fileRules .= "|mimetypes:{$mimes}"; + } return [ - 'file' => "required|file|max:{$maxKb}|mimetypes:{$mimes}", + 'file' => $fileRules, 'prefix' => ['filled', new FilePrefix], - ]; - } - - /** - * Get the error messages for the defined validation rules. - * - * @return array - */ - public function messages() - { - return [ - 'file.max' => 'The file size exceeds the available storage quota.', + 'chunk_index' => 'filled|integer|required_with:chunk_total|min:0|lt:chunk_total', + 'chunk_total' => 'filled|integer|required_with:chunk_index|min:2', ]; } @@ -92,32 +89,94 @@ public function withValidator($validator) $validator->after(function ($validator) { if (!is_null($this->storageRequest->submitted_at)) { $validator->errors()->add('file', 'The storage request was already submitted and no new files can be uploaded.'); + + return; } - if (!$validator->valid() || !$this->hasFile('file')) { - // Return early before checking file existence below. + if (!$this->hasFile('file')) { return; } + $file = $this->file('file'); + $user = User::convert($this->storageRequest->user); + $shouldDeletePreviousChunks = false; + + if ($file->getSize() > $user->storage_quota_remaining) { + $validator->errors()->add('file', 'The file size exceeds the available storage quota.'); + $shouldDeletePreviousChunks = true; + } + + $maxFileSize = config('user_storage.max_file_size'); + if ($file->getSize() > $maxFileSize) { + $validator->errors()->add('file', "The file size exceeds the maximum allowed file size of {$maxFileSize} bytes."); + $shouldDeletePreviousChunks = true; + } + + $chunkSize = config('user_storage.upload_chunk_size'); + if ($file->getSize() > $chunkSize) { + if ($this->isChunked()) { + $validator->errors()->add('file', "The file size of this chunk exceeds the configured chunk size of {$chunkSize} bytes."); + } else { + $validator->errors()->add('file', "The file is too large and must be uploaded in chunks of a maximum of {$chunkSize} bytes each."); + } + $shouldDeletePreviousChunks = true; + } + $path = $this->getFilePath(); - $existingFiles = StorageRequest::where('id', '!=', $this->storageRequest->id) - ->where('user_id', $this->storageRequest->user_id) - // Limit to requests that seem to contain the file. We can't be sure here - // because the files are stored as single string in the DB. Executing the - // query will run the model accessor that converts the files to an actual - // array. - ->where('files', 'ilike', "%{$path}%") - ->pluck('files') - ->flatten(); - - // Deny uploading of files that already exist in another request. This could - // lead to the following issue: + $this->storageRequestFile = $this->storageRequest->files() + ->where('path', $path) + ->first(); + + if ($this->isChunked()) { + if ($this->storageRequestFile) { + $combinedSize = $this->storageRequestFile->size + $file->getSize(); + if ($combinedSize > $maxFileSize) { + $validator->errors()->add('file', "The file size exceeds the maximum allowed file size of {$maxFileSize} bytes."); + $shouldDeletePreviousChunks = true; + } + + // Delete chunks of an uploaded file if size validation of a single + // chunk failed. + if ($shouldDeletePreviousChunks) { + DeleteStorageRequestFile::dispatch($this->storageRequestFile); + $this->storageRequestFile->delete(); + } + + if ($this->storageRequestFile->total_chunks !== (int) $this->input('chunk_total')) { + $validator->errors()->add('chunk_total', 'The specified number of chunks does not match the previously specified number for this file.'); + } + + if (in_array($this->input('chunk_index'), $this->storageRequestFile->received_chunks)) { + $validator->errors()->add('chunk_index', 'The chunk was already uploaded.'); + } + } elseif ($this->input('chunk_index') > 0) { + $validator->errors()->add('chunk_index', 'The first chunk of a new file must be uploaded before the remaining chunks.'); + } + } + + if (!$validator->valid()) { + // Return early before checking file existence below. + return; + } + + if (strlen($path) > 512) { + $validator->errors()->add('file', 'The filename and prefix combined must not exceed 512 characters.'); + } + + $existsInOtherRequest = StorageRequestFile::join('storage_requests', 'storage_requests.id', '=', 'storage_request_files.storage_request_id') + ->where('storage_requests.id', '!=', $this->storageRequest->id) + ->where('storage_requests.user_id', $this->storageRequest->user_id) + ->where('storage_request_files.path', $path) + ->exists(); + + // Deny uploading of files that already exist in another request of the same + // user. This could lead to the following issue: // The file exists in request A and B. Its size was added twice during each // upload to the used quota of the user. But ultimately the file exists only // once in storage. If requests A and B are deleted with all files, the size // of the duplicate file will remain in the used quota because it could be // deleted only once. - if ($existingFiles->contains($path)) { + if ($existsInOtherRequest) { $validator->errors()->add('file', 'The file already exists in the user storage.'); } }); @@ -154,4 +213,14 @@ public function sanitizePrefix($prefix) return $prefix; } + + /** + * Determine if the file is a chunked upload. + * + * @return boolean + */ + public function isChunked() + { + return $this->has('chunk_index'); + } } diff --git a/src/Http/Requests/UpdateStorageRequest.php b/src/Http/Requests/UpdateStorageRequest.php index ba0338a..4f5e882 100644 --- a/src/Http/Requests/UpdateStorageRequest.php +++ b/src/Http/Requests/UpdateStorageRequest.php @@ -14,6 +14,13 @@ class UpdateStorageRequest extends FormRequest */ public $storageRequest; + /** + * Chunked files of a stroage request. + * + * @var \Illumenate\Support\Collection + */ + public $chunkedFiles; + /** * Determine if the user is authorized to make this request. * @@ -48,9 +55,24 @@ public function rules() public function withValidator($validator) { $validator->after(function ($validator) { - if (empty($this->storageRequest->files)) { + if (!$this->storageRequest->files()->exists()) { $validator->errors()->add('id', "The storage request has no files."); } + + $this->chunkedFiles = $this->storageRequest->files() + ->whereNotNull('total_chunks') + ->get(); + + $unfinished = $this->chunkedFiles->reduce(function ($carry, $file) { + $received = $file->received_chunks; + sort($received); + + return $carry || $received !== range(0, $file->total_chunks - 1); + }, false); + + if ($unfinished) { + $validator->errors()->add('id', 'Some file chunks were not uploaded yet.'); + } }); } } diff --git a/src/Http/routes.php b/src/Http/routes.php index 425a17a..444b622 100644 --- a/src/Http/routes.php +++ b/src/Http/routes.php @@ -17,9 +17,9 @@ 'parameters' => ['storage-requests' => 'id'], ]); - $router->get('storage-requests/{id}/files', 'StorageRequestFileController@show'); + $router->get('storage-request-files/{id}', 'StorageRequestFileController@show'); - $router->delete('storage-requests/{id}/files', 'StorageRequestFileController@destroy'); + $router->delete('storage-request-files/{id}', 'StorageRequestFileController@destroy'); $router->delete('storage-requests/{id}/directories', 'StorageRequestDirectoryController@destroy'); $router->group([ diff --git a/src/Jobs/ApproveStorageRequest.php b/src/Jobs/ApproveStorageRequest.php index f420304..467f311 100644 --- a/src/Jobs/ApproveStorageRequest.php +++ b/src/Jobs/ApproveStorageRequest.php @@ -44,7 +44,7 @@ public function __construct(StorageRequest $request) */ public function handle() { - if (empty($this->request->files)) { + if (!$this->request->files()->exists()) { return; } @@ -56,25 +56,31 @@ public function handle() $useCopy = true; } - foreach ($this->request->files as $file) { + $paths = $this->request->files()->pluck('path'); + foreach ($paths as $path) { if ($useCopy) { $success = $storageDisk->copy( - $this->request->getPendingPath($file), - $this->request->getStoragePath($file) + $this->request->getPendingPath($path), + $this->request->getStoragePath($path) ); } else { - $stream = $pendingDisk->readStream($this->request->getPendingPath($file)); - $success = $storageDisk->writeStream($this->request->getStoragePath($file), $stream); + $stream = $pendingDisk->readStream($this->request->getPendingPath($path)); + $success = $storageDisk->writeStream($this->request->getStoragePath($path), $stream); } if (!$success) { - throw new Exception("Could not copy file '{$file}' of storage request {$this->request->id}"); + throw new Exception("Could not copy file '{$path}' of storage request {$this->request->id}"); } } + + // Notify user before deleting old directory because they can already use the + // files. If deleting goes wrong below, it's only of concern for the instance + // admins. + $this->request->user->notify(new StorageRequestApproved($this->request)); + $success = $pendingDisk->deleteDirectory($this->request->getPendingPath()); if (!$success) { throw new Exception("Could not delete pending files of storage request {$this->request->id}"); } - $this->request->user->notify(new StorageRequestApproved($this->request)); } } diff --git a/src/Jobs/AssembleChunkedFile.php b/src/Jobs/AssembleChunkedFile.php new file mode 100644 index 0000000..af8572a --- /dev/null +++ b/src/Jobs/AssembleChunkedFile.php @@ -0,0 +1,94 @@ +total_chunks)) { + throw new Exception('The file is not chunked.'); + } + + $this->file = $file; + } + + /** + * Execute the job. + */ + public function handle() + { + if ($this->batch() && $this->batch()->cancelled()) { + return; + } + + $disk = Storage::disk(config('user_storage.pending_disk')); + File::ensureDirectoryExists(config('user_storage.tmp_dir')); + $filename = tempnam(config('user_storage.tmp_dir'), 'assemble-chunks-'); + + try { + $tempFile = fopen($filename, 'w+'); + $path = $this->file->request->getPendingPath($this->file->path); + $chunkPaths = []; + + for ($i = 0; $i < $this->file->total_chunks; $i++) { + $chunkPath = "{$path}.{$i}"; + $chunkPaths[] = $chunkPath; + $stream = $disk->readStream($chunkPath); + stream_copy_to_stream($stream, $tempFile); + } + + fseek($tempFile, 0); + $success = $disk->writeStream($path, $tempFile); + fclose($tempFile); + + if (!$success) { + throw new Exception("Could not store assembled file at '{$path}'."); + } + + $success = $disk->delete($chunkPaths); + + if (!$success) { + throw new Exception("Could not delete chunks of file '{$path}'."); + } + + $this->file->update([ + 'received_chunks' => null, + 'total_chunks' => null, + ]); + } finally { + unlink($filename); + } + } +} diff --git a/src/Jobs/DeleteStorageRequestDirectory.php b/src/Jobs/DeleteStorageRequestDirectory.php new file mode 100644 index 0000000..b2ffad0 --- /dev/null +++ b/src/Jobs/DeleteStorageRequestDirectory.php @@ -0,0 +1,58 @@ +pending = is_null($request->expires_at); + $this->path = $this->pending ? $request->getPendingPath() : $request->getStoragePath(); + } + + /** + * Execute the job. + */ + public function handle() + { + if ($this->pending) { + $disk = Storage::disk(config('user_storage.pending_disk')); + } else { + $disk = Storage::disk(config('user_storage.storage_disk')); + } + + $success = $disk->deleteDirectory($this->path); + + if (!$success) { + throw new Exception("Could not delete storage request directory '{$this->path}'."); + } + } +} diff --git a/src/Jobs/DeleteStorageRequestFile.php b/src/Jobs/DeleteStorageRequestFile.php new file mode 100644 index 0000000..5f4b41d --- /dev/null +++ b/src/Jobs/DeleteStorageRequestFile.php @@ -0,0 +1,99 @@ +path = $file->path; + $request = $file->request; + $this->pending = is_null($request->expires_at); + if ($this->pending) { + $this->prefix = $request->getPendingPath(); + if ($file->received_chunks) { + $this->chunks = $file->received_chunks; + } + } else { + $this->prefix = $request->getStoragePath(); + } + } + + /** + * Execute the job. + */ + public function handle() + { + if ($this->pending) { + $disk = Storage::disk(config('user_storage.pending_disk')); + } else { + $disk = Storage::disk(config('user_storage.storage_disk')); + } + + $path = "{$this->prefix}/{$this->path}"; + + if ($this->chunks) { + $paths = array_map(function ($chunk) use ($path) { + return "{$path}.{$chunk}"; + }, $this->chunks); + + $success = $disk->delete($paths); + } else { + $success = $disk->delete($path); + } + + if (!$success) { + throw new Exception("Could not delete file '{$this->path}' for storage request with prefix '{$this->prefix}'."); + } + + if ($success && count($disk->allFiles($this->prefix)) === 0) { + $success = $disk->deleteDirectory($this->prefix); + + if (!$success) { + throw new Exception("Could not delete empty directory of storage request with prefix '{$this->prefix}'."); + } + } + } +} diff --git a/src/Jobs/DeleteStorageRequestFiles.php b/src/Jobs/DeleteStorageRequestFiles.php deleted file mode 100644 index 037c076..0000000 --- a/src/Jobs/DeleteStorageRequestFiles.php +++ /dev/null @@ -1,115 +0,0 @@ -user = $request->user; - $this->files = $only ?: $request->files; - $this->deleteAllFiles = empty($only); - $this->pending = is_null($request->expires_at); - $this->prefix = $this->pending ? $request->getPendingPath() : $request->getStoragePath(); - } - - /** - * Execute the job. - */ - public function handle() - { - if ($this->pending) { - $disk = Storage::disk(config('user_storage.pending_disk')); - } else { - $disk = Storage::disk(config('user_storage.storage_disk')); - } - - $files = array_map(fn ($f) => "{$this->prefix}/{$f}", $this->files); - - $totalSize = 0; - if (!is_null($this->user)) { - foreach ($files as $path) { - try { - $totalSize += $disk->size($path); - } catch (UnableToRetrieveMetadata $e) { - // The file probably does not exist. - continue; - } - } - } - - if ($this->pending && $this->deleteAllFiles) { - $success = $disk->deleteDirectory($this->prefix); - } else { - $success = $disk->delete($files); - if ($success && count($disk->allFiles($this->prefix)) === 0) { - $success = $disk->deleteDirectory($this->prefix); - } - } - - if (!$success) { - $fileString = implode(', ', $this->files); - throw new Exception("Could not delete files for storage request with prefix '{$this->prefix}'. Files: {$fileString}."); - } - - if (!is_null($this->user)) { - $user = User::convert($this->user); - $user->storage_quota_used -= $totalSize; - $user->save(); - } - } -} diff --git a/src/Notifications/StorageRequestSubmitted.php b/src/Notifications/StorageRequestSubmitted.php index 787f6a3..a20343b 100644 --- a/src/Notifications/StorageRequestSubmitted.php +++ b/src/Notifications/StorageRequestSubmitted.php @@ -57,13 +57,16 @@ public function via($notifiable) */ public function toMail($notifiable) { - $fileCount = count($this->request->files); + $fileCount = $this->request->files()->count(); + $size = $this->request->files()->sum('size'); + $sizeForHumans = size_for_humans($size); + $name = "{$this->request->user->firstname} {$this->request->user->lastname}"; $affiliation = $this->request->user->affiliation ?: 'no affiliation'; $message = (new MailMessage) ->subject('New storage request') - ->line("A new storage request with {$fileCount} file(s) was created by {$name} ($affiliation).") + ->line("A new storage request with {$fileCount} files ($sizeForHumans) was created by {$name} ($affiliation).") ->action('Review', route('review-storage-request', $this->request->id)); return $message; diff --git a/src/StorageRequest.php b/src/StorageRequest.php index 8a4ed77..51fcca2 100644 --- a/src/StorageRequest.php +++ b/src/StorageRequest.php @@ -3,7 +3,7 @@ namespace Biigle\Modules\UserStorage; use Biigle\Modules\UserStorage\Database\Factories\StorageRequestFactory; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestDirectory; use Biigle\User; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,7 +31,6 @@ class StorageRequest extends Model 'user_id', 'expires_at', 'submitted_at', - 'files', ]; /** @@ -43,6 +42,7 @@ class StorageRequest extends Model 'created_at_for_humans', 'expires_at_for_humans', 'files_count', + 'size', ]; /** @@ -53,8 +53,8 @@ class StorageRequest extends Model protected static function booted() { static::deleting(function ($request) { - if (!empty($request->files)) { - DeleteStorageRequestFiles::dispatch($request); + if ($request->files()->exists()) { + DeleteStorageRequestDirectory::dispatch($request); } }); } @@ -70,25 +70,19 @@ public function user() } /** - * Set the files attribute. + * The files belonging to this storage request. * - * @param array $value + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ - public function setFilesAttribute(array $value) + public function files() { - $this->attributes['files'] = implode(',', $value); - } - - /** - * Get the files attribute. - */ - public function getFilesAttribute() - { - return array_filter(explode(',', $this->attributes['files'] ?? '')); + return $this->hasMany(StorageRequestFile::class); } /** * Get the created_at_for_humans attribute + * + * @return string */ public function getCreatedAtForHumansAttribute() { @@ -97,6 +91,8 @@ public function getCreatedAtForHumansAttribute() /** * Get the expires_at_for_humans attribute + * + * @return string */ public function getExpiresAtForHumansAttribute() { @@ -105,10 +101,22 @@ public function getExpiresAtForHumansAttribute() /** * Get the files_count attribute + * + * @return int */ public function getFilesCountAttribute() { - return count($this->files); + return $this->files()->count(); + } + + /** + * Get the size attribute + * + * @return int + */ + public function getSizeAttribute() + { + return (int) $this->files()->sum('size'); } /** diff --git a/src/StorageRequestFile.php b/src/StorageRequestFile.php new file mode 100644 index 0000000..0f68700 --- /dev/null +++ b/src/StorageRequestFile.php @@ -0,0 +1,72 @@ + 'int', + 'received_chunks' => 'array', + 'total_chunks' => 'int', + ]; + + /** + * The attributes that are mass assignable. + * + * @var array + */ + protected $fillable = [ + 'path', + 'size', + 'received_chunks', + 'total_chunks', + ]; + + /** + * The attributes that should be hidden for arrays. + * + * @var array + */ + protected $hidden = [ + 'received_chunks', + 'total_chunks', + ]; + + /** + * The request to which this file belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function request() + { + return $this->belongsTo(StorageRequest::class, 'storage_request_id'); + } + + /** + * Create a new factory instance for the model. + * + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + protected static function newFactory() + { + return StorageRequestFileFactory::new(); + } +} diff --git a/src/User.php b/src/User.php index b55a07f..3b4f53e 100644 --- a/src/User.php +++ b/src/User.php @@ -3,6 +3,7 @@ namespace Biigle\Modules\UserStorage; use Biigle\User as BaseModel; +use Cache; class User extends BaseModel { @@ -49,19 +50,13 @@ public function setStorageQuotaAvailableAttribute($value) */ public function getStorageQuotaUsedAttribute() { - return $this->getJsonAttr('storage_quota_used', 0); - } - - /** - * Set the allowed user storage quota. - * - * @@param int|null $value - */ - public function setStorageQuotaUsedAttribute($value) - { - $value = $value === 0 ? null : $value; + $key = "user-{$this->id}-storage-quota-used"; - $this->setJsonAttr('storage_quota_used', max(0, $value)); + return Cache::store('array')->remember($key, null, function () { + return (int) StorageRequestFile::join('storage_requests', 'storage_requests.id', '=', 'storage_request_files.storage_request_id') + ->where('storage_requests.user_id', $this->id) + ->sum('storage_request_files.size'); + }); } /** diff --git a/src/UserStorageServiceProvider.php b/src/UserStorageServiceProvider.php index af19fe3..de3c35e 100644 --- a/src/UserStorageServiceProvider.php +++ b/src/UserStorageServiceProvider.php @@ -4,7 +4,6 @@ use Biigle\Http\Requests\UpdateUserSettings; use Biigle\Modules\UserStorage\Console\Commands\CheckExpiredStorageRequests; -use Biigle\Modules\UserStorage\Console\Commands\MigrateToStorageRequests; use Biigle\Modules\UserStorage\Console\Commands\PruneExpiredStorageRequests; use Biigle\Modules\UserStorage\Console\Commands\PruneStaleStorageRequests; use Biigle\Modules\UserStorage\Observers\UserObserver; @@ -63,7 +62,6 @@ public function boot(Modules $modules, Router $router) if ($this->app->runningInConsole()) { $this->commands([ CheckExpiredStorageRequests::class, - MigrateToStorageRequests::class, PruneExpiredStorageRequests::class, PruneStaleStorageRequests::class, ]); diff --git a/src/config/user_storage.php b/src/config/user_storage.php index ced4c7e..e20f748 100644 --- a/src/config/user_storage.php +++ b/src/config/user_storage.php @@ -55,6 +55,20 @@ */ 'maintenance_mode' => env('USER_STORAGE_MAINTENANCE_MODE', false), + /* + | Split files that are larger than this threshold (in bytes) into smaller chunks. + | Each chunk will have the size of this threshold (except maybe the last). Larger + | single file uploads are rejected. + | + | Default: 100 MB. + */ + 'upload_chunk_size' => env('USER_STORAGE_UPLOAD_CHUNK_SIZE', 1E+8), + + /* + | Directory where the temporary files to assemble chunked files are stored. + */ + 'tmp_dir' => env('USER_STORAGE_TMP_DIR', storage_path('user-storage-tmp')), + 'notifications' => [ /* | Set the way notifications for storage requests are sent by default. diff --git a/src/database/migrations/2022_05_11_115500_add_storage_request_files_table.php b/src/database/migrations/2022_05_11_115500_add_storage_request_files_table.php new file mode 100644 index 0000000..cdd2c8a --- /dev/null +++ b/src/database/migrations/2022_05_11_115500_add_storage_request_files_table.php @@ -0,0 +1,108 @@ +id(); + + $table->unsignedInteger('storage_request_id'); + $table->foreign('storage_request_id') + ->references('id') + ->on('storage_requests') + ->onDelete('cascade'); + + $table->string('path', 512); + $table->unsignedBigInteger('size'); + $table->json('received_chunks')->nullable(); + $table->unsignedInteger('total_chunks')->nullable(); + + $table->index('storage_request_id'); + $table->unique(['path', 'storage_request_id']); + }); + + $storageDisk = Storage::disk(config('user_storage.storage_disk')); + $pendingDisk = Storage::disk(config('user_storage.pending_disk')); + + StorageRequest::eachById(function ($request) use ($storageDisk, $pendingDisk) { + $files = array_filter(explode(',', $request->files ?? '')); + + if (is_null($request->expires_at)) { + $disk = $pendingDisk; + $prefix = $request->getPendingPath(); + } else { + $disk = $storageDisk; + $prefix = $request->getStoragePath(); + } + + $create = array_map(function ($path) use ($disk, $prefix) { + return [ + 'path' => $path, + 'size' => $disk->fileSize("{$prefix}/{$path}"), + ]; + }, $files); + + $request->files()->createMany($create); + }); + + Schema::table('storage_requests', function (Blueprint $table) { + $table->dropColumn('files'); + }); + + User::whereNotNull('attrs->storage_quota_used')->eachById(function ($user) { + $attrs = $user->attrs; + unset($attrs['storage_quota_used']); + $user->attrs = $attrs; + $user->save(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('storage_requests', function (Blueprint $table) { + $table->text('files')->default(''); + }); + + DB::table('storage_request_files') + ->select('storage_request_id', 'path') + ->get() + ->groupBy('storage_request_id') + ->each(function ($files, $requestId) { + $files = $files->pluck('path')->join(','); + StorageRequest::where('id', $requestId)->update(['files' => $files]); + }); + + $sizes = DB::table('storage_request_files') + ->join('storage_requests', 'storage_requests.id', '=', 'storage_request_files.storage_request_id') + ->select('storage_requests.user_id', DB::raw('sum(storage_request_files.size) as size')) + ->groupBy('user_id') + ->get() + ->each(function ($item) { + $user = User::find($item->user_id); + $attrs = $user->attrs; + $attrs['storage_quota_used'] = intval($item->size); + $user->attrs = $attrs; + $user->save(); + }); + + Schema::drop('storage_request_files'); + } +}; diff --git a/src/public/assets/scripts/main.js b/src/public/assets/scripts/main.js index 9f47a4b..6b07970 100644 --- a/src/public/assets/scripts/main.js +++ b/src/public/assets/scripts/main.js @@ -1 +1 @@ -(()=>{"use strict";var u,D={591:()=>{const u=Vue.resource("api/v1/storage-requests{/id}/directories"),D=Vue.resource("api/v1/storage-requests{/id}/files"),e=Vue.resource("api/v1/storage-requests{/id}",{},{approve:{method:"POST",url:"api/v1/storage-requests{/id}/approve"},reject:{method:"POST",url:"api/v1/storage-requests{/id}/reject"},extend:{method:"POST",url:"api/v1/storage-requests{/id}/extend"}});var F=biigle.$require("core.components.fileBrowser"),t=biigle.$require("messages").handleErrorResponse,C=biigle.$require("core.mixins.loader"),A=function(u){var D="",e=["kB","MB","GB","TB"];do{u/=1e3,D=e.shift()}while(u>1e3&&e.length>0);return"".concat(u.toFixed(2)," ").concat(D)},E=function(u){var D={name:"",directories:{},files:[]};return u.files&&u.files.forEach((function(u){var e=u.split("/"),F=e.pop(),t=D;e.forEach((function(u){t.directories.hasOwnProperty(u)||(t.directories[u]={name:u,directories:{},files:[]}),t=t.directories[u]})),t.files.push({name:F})})),D};function i(u,D,e,F,t,C,A,E){var i,s="function"==typeof u?u.options:u;if(D&&(s.render=D,s.staticRenderFns=e,s._compiled=!0),F&&(s.functional=!0),C&&(s._scopeId="data-v-"+C),A?(i=function(u){(u=u||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(u=__VUE_SSR_CONTEXT__),t&&t.call(this,u),u&&u._registeredComponents&&u._registeredComponents.add(A)},s._ssrRegister=i):t&&(i=E?function(){t.call(this,(s.functional?this.parent:this).$root.$options.shadowRoot)}:t),i)if(s.functional){s._injectStyles=i;var r=s.render;s.render=function(u,D){return i.call(D),r(u,D)}}else{var n=s.beforeCreate;s.beforeCreate=n?[].concat(n,i):[i]}return{exports:u,options:s}}const s=i({mixins:[C],components:{fileBrowser:F},data:function(){return{currentUploadedSize:0,files:[],finished:!1,finishedUploadedSize:0,loadedUnfinishedRequest:!1,maxSize:-1,rootDirectory:{name:"",directories:{},files:[],selected:!1},selectedDirectory:null,storageRequest:null,usedQuotaBytes:0,availableQuotaBytes:0,maxFilesizeBytes:0,exceedsMaxFilesize:!1}},computed:{hasSelectedDirectory:function(){return null!==this.selectedDirectory},selectedDirectoryName:function(){return this.selectedDirectory.name},hasFiles:function(){return this.files.length>0},totalSize:function(){return this.files.reduce((function(u,D){return u+D.size}),0)},totalSizeForHumans:function(){return A(this.totalSize)},uploadedSize:function(){return this.currentUploadedSize+this.finishedUploadedSize},uploadedPercent:function(){return Math.round(this.uploadedSize/this.totalSize*100)},uploadedSizeForHumans:function(){return A(this.uploadedSize)},editable:function(){return!this.loading&&!this.finished},exceedsMaxSize:function(){return-1!==this.availableQuotaBytes&&this.totalSize>this.availableQuotaBytes},canSubmit:function(){return this.hasFiles&&!this.exceedsMaxSize},usedQuota:function(){return A(this.usedQuotaBytes)},availableQuota:function(){return A(this.availableQuotaBytes)},usedQuotaPercent:function(){return Math.round(this.usedQuotaBytes/this.availableQuotaBytes*100)},maxFilesize:function(){return A(this.maxFilesizeBytes)}},methods:{handleFilesChosen:function(u){var D=this;if(this.hasSelectedDirectory){var e=Array.from(u.target.files).filter((function(u){return u.size<=D.maxFilesizeBytes}));e.length=500&&D<2)return e.uploadFile(u,D+1);throw F}))},updateCurrentUploadedSize:function(u){u.lengthComputable&&(this.currentUploadedSize=u.loaded)},finishSubmission:function(){var u=this;return e.update({id:this.storageRequest.id},{}).then((function(){return u.finished=!0}))},addExistingFiles:function(u){u.forEach(this.addExistingFile),this.syncFiles()},addExistingFile:function(u){var D=this,e=u.split("/"),F=e.pop(),t=this.rootDirectory;e.forEach((function(u){t.directories.hasOwnProperty(u)||Vue.set(t.directories,u,D.getNewDirectory(u)),(t=t.directories[u]).saved=!0})),t.files.push({saved:!0,name:F,size:0})},sanitizePath:function(u){return u=(u=(u=(u=(u=u.replace(/\\/g,"/")).replace(/(?:(?![ \(\)\x2D-9A-\[\]a-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])/g,"")).replace(/^(?:(?![0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])/g,"")).replace(/(?:(?![\)0-9A-Z\]a-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])$/g,"")).replace(/\/+/g,"/")}},created:function(){var u=this;this.usedQuotaBytes=biigle.$require("user-storage.usedQuota"),this.availableQuotaBytes=biigle.$require("user-storage.availableQuota"),this.maxFilesizeBytes=biigle.$require("user-storage.maxFilesize"),this.storageRequest=biigle.$require("user-storage.previousRequest"),this.storageRequest&&this.storageRequest.files.length>0&&(this.addExistingFiles(this.storageRequest.files),this.loadedUnfinishedRequest=!0),window.addEventListener("beforeunload",(function(D){if(u.loading)return D.preventDefault(),D.returnValue="","This page is asking you to confirm that you want to leave - the file upload is still in progress."}))}},undefined,undefined,!1,null,null,null).exports;var r=i({props:{request:{type:Object,required:!0},expireDate:{type:Date,default:null},selected:{type:Boolean,default:!1}},computed:{pending:function(){return!this.request.expires_at},expired:function(){return Date.parse(this.request.expires_at){}},e={};function F(u){var t=e[u];if(void 0!==t)return t.exports;var C=e[u]={exports:{}};return D[u](C,C.exports,F),C.exports}F.m=D,u=[],F.O=(D,e,t,C)=>{if(!e){var A=1/0;for(r=0;r=C)&&Object.keys(F.O).every((u=>F.O[u](e[i])))?e.splice(i--,1):(E=!1,C0&&u[r-1][2]>C;r--)u[r]=u[r-1];u[r]=[e,t,C]},F.o=(u,D)=>Object.prototype.hasOwnProperty.call(u,D),(()=>{var u={355:0,392:0};F.O.j=D=>0===u[D];var D=(D,e)=>{var t,C,[A,E,i]=e,s=0;if(A.some((D=>0!==u[D]))){for(t in E)F.o(E,t)&&(F.m[t]=E[t]);if(i)var r=i(F)}for(D&&D(e);sF(591)));var t=F.O(void 0,[392],(()=>F(401)));t=F.O(t)})(); \ No newline at end of file +(()=>{"use strict";var u,D={30:()=>{const u=Vue.resource("api/v1/storage-requests{/id}/directories"),D=Vue.resource("api/v1/storage-request-files{/id}",{},{save:{method:"POST",url:"api/v1/storage-requests{/id}/files"}}),e=Vue.resource("api/v1/storage-requests{/id}",{},{approve:{method:"POST",url:"api/v1/storage-requests{/id}/approve"},reject:{method:"POST",url:"api/v1/storage-requests{/id}/reject"},extend:{method:"POST",url:"api/v1/storage-requests{/id}/extend"}});var F=biigle.$require("core.components.fileBrowser"),t=biigle.$require("messages").handleErrorResponse,C=biigle.$require("core.mixins.loader"),A=function(u){var D="",e=["kB","MB","GB","TB"];do{u/=1e3,D=e.shift()}while(u>1e3&&e.length>0);return"".concat(u.toFixed(2)," ").concat(D)},i=function(u){var D={name:"",directories:{},files:[]};return u.files&&u.files.forEach((function(u){var e=u.path.split("/"),F=e.pop(),t=D;e.forEach((function(u){t.directories.hasOwnProperty(u)||(t.directories[u]={name:u,directories:{},files:[]}),t=t.directories[u]})),t.files.push({id:u.id,name:F})})),D};function E(u,D,e,F,t,C,A,i){var E,s="function"==typeof u?u.options:u;if(D&&(s.render=D,s.staticRenderFns=e,s._compiled=!0),F&&(s.functional=!0),C&&(s._scopeId="data-v-"+C),A?(E=function(u){(u=u||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(u=__VUE_SSR_CONTEXT__),t&&t.call(this,u),u&&u._registeredComponents&&u._registeredComponents.add(A)},s._ssrRegister=E):t&&(E=i?function(){t.call(this,(s.functional?this.parent:this).$root.$options.shadowRoot)}:t),E)if(s.functional){s._injectStyles=E;var n=s.render;s.render=function(u,D){return E.call(D),n(u,D)}}else{var r=s.beforeCreate;s.beforeCreate=r?[].concat(r,E):[E]}return{exports:u,options:s}}const s=E({mixins:[C],components:{fileBrowser:F},data:function(){return{currentUploadedSize:0,files:[],finished:!1,finishedUploadedSize:0,finishedChunksSize:0,loadedUnfinishedRequest:!1,maxSize:-1,rootDirectory:{name:"",directories:{},files:[],selected:!1},selectedDirectory:null,storageRequest:null,availableQuotaBytes:0,maxFilesizeBytes:0,exceedsMaxFilesize:!1,chunkSize:0}},computed:{hasSelectedDirectory:function(){return null!==this.selectedDirectory},selectedDirectoryName:function(){return this.selectedDirectory.name},hasFiles:function(){return this.files.length>0},totalSize:function(){return this.files.reduce((function(u,D){return u+D.size}),0)},totalSizeToUpload:function(){return this.files.reduce((function(u,D){return D.saved?u:u+D.size}),0)},totalSizeToUploadForHumans:function(){return A(this.totalSizeToUpload)},totalSizeForHumans:function(){return A(this.totalSize)},uploadedSize:function(){return this.currentUploadedSize+this.finishedUploadedSize+this.finishedChunksSize},uploadedPercent:function(){return Math.round(this.uploadedSize/this.totalSizeToUpload*100)},uploadedSizeForHumans:function(){return A(this.uploadedSize)},editable:function(){return!this.loading&&!this.finished},exceedsMaxSize:function(){return-1!==this.availableQuotaBytes&&this.totalSize>this.availableQuotaBytes},canSubmit:function(){return this.hasFiles&&!this.exceedsMaxSize},availableQuota:function(){return A(this.availableQuotaBytes)},maxFilesize:function(){return A(this.maxFilesizeBytes)}},methods:{handleFilesChosen:function(u){var D=this;if(this.hasSelectedDirectory){var e=Array.from(u.target.files).filter((function(u){return u.size<=D.maxFilesizeBytes}));e.lengththis.chunkSize?this.uploadChunkedFile(u).then(D):this.uploadBlob(u.file,u.prefix).then((function(D){u.file.saved=!0,u.file.id=D.body.id})).then(D)},uploadChunkedFile:function(u){var F=this;this.finishedChunksSize=0;var t=u.prefix;u=u.file;var C=0,A=0,i=Math.ceil(u.size/this.chunkSize),E=function E(s){if(C===u.size)return Vue.Promise.resolve();var n=Math.min(C+F.chunkSize,u.size),r=new File([u.slice(C,n)],u.name,{type:u.type,lastModified:u.lastModified}),B=F.uploadBlob(r,t,A,i);return C=n,A+=1,B.then((function(){this.finishedChunksSize+=r.size}),(function(F){throw void 0!==u.id&&(this.files.filter((function(u){return u.file.saved})).length>1?D.delete({id:u.id}):(e.delete({id:this.storageRequest.id}),this.storageRequest=null),delete u.id,u.saved=!1),F})),s?B.then(E):B};return E().then((function(D){u.saved=!0,u.id=D.body.id})).then((function(){return E(!0)}))},uploadBlob:function(u,D,e,F,t){var C=this;t=t||1;var A=new FormData;A.append("file",u),A.append("prefix",D),void 0!==e&&void 0!==F&&(A.append("chunk_index",e),A.append("chunk_total",F));var i="api/v1/storage-requests/".concat(this.storageRequest.id,"/files");return this.$http.post(i,A,{uploadProgress:this.updateCurrentUploadedSize}).catch((function(A){if(A.status>=500&&t<2)return C.uploadBlob(u,D,e,F,t+1);throw A}))},updateCurrentUploadedSize:function(u){u.lengthComputable&&(this.currentUploadedSize=u.loaded)},finishSubmission:function(){var u=this;return e.update({id:this.storageRequest.id},{}).then((function(){return u.finished=!0}),t)},addExistingFiles:function(u){u.forEach(this.addExistingFile),this.syncFiles()},addExistingFile:function(u){var D=this,e=u.path.split("/"),F=e.pop(),t=this.rootDirectory;e.forEach((function(u){t.directories.hasOwnProperty(u)||Vue.set(t.directories,u,D.getNewDirectory(u)),t=t.directories[u]})),t.files.push({saved:!0,name:F,size:u.size,id:u.id})},sanitizePath:function(u){return u=(u=(u=(u=(u=u.replace(/\\/g,"/")).replace(/(?:(?![ \(\)\x2D-9A-\[\]a-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])/g,"")).replace(/^(?:(?![0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])/g,"")).replace(/(?:(?![\)0-9A-Z\]a-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])[\s\S])$/g,"")).replace(/\/+/g,"/")}},created:function(){var u=this;this.availableQuotaBytes=biigle.$require("user-storage.availableQuota"),this.maxFilesizeBytes=biigle.$require("user-storage.maxFilesize"),this.chunkSize=biigle.$require("user-storage.chunkSize"),this.storageRequest=biigle.$require("user-storage.previousRequest"),this.storageRequest&&this.storageRequest.files.length>0&&(this.addExistingFiles(this.storageRequest.files),this.loadedUnfinishedRequest=!0),window.addEventListener("beforeunload",(function(D){if(u.loading)return D.preventDefault(),D.returnValue="","This page is asking you to confirm that you want to leave - the file upload is still in progress."}))}},undefined,undefined,!1,null,null,null).exports;var n=E({props:{request:{type:Object,required:!0},expireDate:{type:Date,default:null},selected:{type:Boolean,default:!1}},computed:{pending:function(){return!this.request.expires_at},expired:function(){return Date.parse(this.request.expires_at){}},e={};function F(u){var t=e[u];if(void 0!==t)return t.exports;var C=e[u]={exports:{}};return D[u](C,C.exports,F),C.exports}F.m=D,u=[],F.O=(D,e,t,C)=>{if(!e){var A=1/0;for(n=0;n=C)&&Object.keys(F.O).every((u=>F.O[u](e[E])))?e.splice(E--,1):(i=!1,C0&&u[n-1][2]>C;n--)u[n]=u[n-1];u[n]=[e,t,C]},F.o=(u,D)=>Object.prototype.hasOwnProperty.call(u,D),(()=>{var u={355:0,392:0};F.O.j=D=>0===u[D];var D=(D,e)=>{var t,C,[A,i,E]=e,s=0;if(A.some((D=>0!==u[D]))){for(t in i)F.o(i,t)&&(F.m[t]=i[t]);if(E)var n=E(F)}for(D&&D(e);sF(30)));var t=F.O(void 0,[392],(()=>F(401)));t=F.O(t)})(); \ No newline at end of file diff --git a/src/public/mix-manifest.json b/src/public/mix-manifest.json index 5a54d89..c819a7e 100644 --- a/src/public/mix-manifest.json +++ b/src/public/mix-manifest.json @@ -1,4 +1,4 @@ { - "/assets/scripts/main.js": "/assets/scripts/main.js?id=dd2f6645e5e346b9c9aa7f0e465979df", + "/assets/scripts/main.js": "/assets/scripts/main.js?id=6c5f51334959e64934e38b24e2685862", "/assets/styles/main.css": "/assets/styles/main.css?id=16bca02d88eeae2a4e45dba3d77b7757" } diff --git a/src/resources/assets/js/api/storageRequestFiles.js b/src/resources/assets/js/api/storageRequestFiles.js index e8bb4fb..be5e3a4 100644 --- a/src/resources/assets/js/api/storageRequestFiles.js +++ b/src/resources/assets/js/api/storageRequestFiles.js @@ -4,9 +4,14 @@ * Upload a file: * resource.save({id: requestId}, {file: File, prefix: 'xxx'}).then(...) * - * Delete files: - * resource.delete({id: requestId}, {files: filePathsArray}).then(...) + * Delete a file: + * resource.delete({id: fileId}).then(...) * * @type {Vue.resource} */ -export default Vue.resource('api/v1/storage-requests{/id}/files'); +export default Vue.resource('api/v1/storage-request-files{/id}', {}, { + save: { + method: 'POST', + url: 'api/v1/storage-requests{/id}/files', + }, +}); diff --git a/src/resources/assets/js/components/storageRequestListItem.vue b/src/resources/assets/js/components/storageRequestListItem.vue index f86c05b..bbdd69e 100644 --- a/src/resources/assets/js/components/storageRequestListItem.vue +++ b/src/resources/assets/js/components/storageRequestListItem.vue @@ -5,7 +5,7 @@ :class="classObject" @click.prevent="handleSelect" > - # created with file(s). + # created with files (). pending @@ -51,6 +51,8 @@ @endpush @@ -21,9 +21,7 @@

New storage request
- - of used (%) - + {{size_for_humans($usedQuota)}} of {{size_for_humans($availableQuota)}} used ({{round($usedQuota / $availableQuota * 100)}}%)

Add directories and files below. Then submit the storage request to upload the files for review by the instance administrators. @@ -61,8 +59,8 @@ class="hidden"

- Uploaded of - (%). Processing... + Uploaded of + (%).
diff --git a/src/resources/views/index.blade.php b/src/resources/views/index.blade.php index 738437c..73c103d 100644 --- a/src/resources/views/index.blade.php +++ b/src/resources/views/index.blade.php @@ -7,7 +7,6 @@ @endpush @@ -34,13 +33,10 @@ @endcan Your storage requests
- - of used (%) + + {{size_for_humans($usedQuota)}} of {{size_for_humans($availableQuota)}} used ({{round($usedQuota / $availableQuota * 100)}}%) -

- Refresh the page after a few seconds to view your updated storage quota. -

by {{$request->user->firstname}} {{$request->user->lastname}} ({{$request->user->affiliation ?: 'no affiliation'}}) +

+ {{$request->files_count}} files · {{size_for_humans($request->size)}} +

'test']); - $disk = Storage::fake('test'); - - $user = BaseUser::factory()->create(); - - $disk->put("user-{$user->id}/dir/file.jpg", 'abc'); - - $this->assertSame(0, StorageRequest::count()); - - $this->artisan('user-storage:migrate', ['id' => $user->id])->assertExitCode(0); - - $request = StorageRequest::first(); - $this->assertNotNull($request); - $this->assertNotNull($request->created_at); - $this->assertNotNull($request->updated_at); - $this->assertNotNull($request->submitted_at); - $this->assertNotNull($request->expires_at); - $this->assertSame(['dir/file.jpg'], $request->files); - - $user = User::convert($user->refresh()); - $this->assertSame(3, $user->storage_quota_used); - } - - public function testHandleOnlyWithoutExistingRequests() - { - $user = BaseUser::factory()->create(); - StorageRequest::factory()->create(['user_id' => $user->id]); - $this->artisan('user-storage:migrate', ['id' => $user->id])->assertExitCode(1); - } -} diff --git a/tests/Console/Commands/PruneExpiredStorageRequestsTest.php b/tests/Console/Commands/PruneExpiredStorageRequestsTest.php index 7cf1abd..5ae75dc 100644 --- a/tests/Console/Commands/PruneExpiredStorageRequestsTest.php +++ b/tests/Console/Commands/PruneExpiredStorageRequestsTest.php @@ -2,8 +2,9 @@ namespace Biigle\Tests\Modules\UserStorage\Console\Commands; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestDirectory; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Illuminate\Support\Facades\Bus; use TestCase; @@ -14,30 +15,44 @@ public function testHandle() Bus::fake(); $request1 = StorageRequest::factory()->create([ - 'files' => ['dir/test.jpg'], 'expires_at' => now()->subWeeks(2), ]); + $file1 = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request1->id, + ]); $request2 = StorageRequest::factory()->create([ 'expires_at' => now()->subDay(), ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'b.jpg', + 'storage_request_id' => $request2->id, + ]); $request3 = StorageRequest::factory()->create([ 'expires_at' => now()->addDay(), ]); + $file3 = StorageRequestFile::factory()->create([ + 'path' => 'c.jpg', + 'storage_request_id' => $request3->id, + ]); $this->artisan('user-storage:prune-expired')->assertExitCode(0); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request1) { - return count($job->files) === 1 && $job->files[0] === "dir/test.jpg" && $job->user->id === $request1->user_id; + Bus::assertDispatched(function (DeleteStorageRequestDirectory $job) use ($request1) { + return $job->path === $request1->getStoragePath(); }); - Bus::assertNotDispatched(function (DeleteStorageRequestFiles $job) use ($request2) { - return $job->user->id === $request2->user_id; + Bus::assertNotDispatched(function (DeleteStorageRequestDirectory $job) use ($request2) { + return $job->path === $request2->getStoragePath(); }); - Bus::assertNotDispatched(function (DeleteStorageRequestFiles $job) use ($request3) { - return $job->user->id === $request3->user_id; + Bus::assertNotDispatched(function (DeleteStorageRequestDirectory $job) use ($request3) { + return $job->path === $request3->getStoragePath(); }); $this->assertModelMissing($request1); + $this->assertModelMissing($file1); $this->assertModelExists($request2); + $this->assertModelExists($file2); $this->assertModelExists($request3); + $this->assertModelExists($file3); } } diff --git a/tests/Console/Commands/PruneStaleStorageRequestsTest.php b/tests/Console/Commands/PruneStaleStorageRequestsTest.php index 4484629..92340a9 100644 --- a/tests/Console/Commands/PruneStaleStorageRequestsTest.php +++ b/tests/Console/Commands/PruneStaleStorageRequestsTest.php @@ -2,8 +2,9 @@ namespace Biigle\Tests\Modules\UserStorage\Console\Commands; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestDirectory; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Illuminate\Support\Facades\Bus; use TestCase; @@ -14,9 +15,12 @@ public function testHandle() Bus::fake(); $request1 = StorageRequest::factory()->create([ - 'files' => ['dir/test.jpg'], 'updated_at' => now()->subWeeks(2), ]); + $file1 = StorageRequestFile::factory()->create([ + 'storage_request_id' => $request1->id, + 'path' => 'dir/test.jpg', + ]); $request2 = StorageRequest::factory()->create([ 'updated_at' => now()->subDay(), ]); @@ -27,16 +31,17 @@ public function testHandle() $this->artisan('user-storage:prune-stale')->assertExitCode(0); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request1) { - return count($job->files) === 1 && $job->files[0] === "dir/test.jpg" && $job->user->id === $request1->user_id; + Bus::assertDispatched(function (DeleteStorageRequestDirectory $job) use ($request1) { + return $job->path === $request1->getPendingPath(); }); - Bus::assertNotDispatched(function (DeleteStorageRequestFiles $job) use ($request2) { - return $job->user->id === $request2->user_id; + Bus::assertNotDispatched(function (DeleteStorageRequestDirectory $job) use ($request2) { + return $job->path === $request2->getPendingPath(); }); - Bus::assertNotDispatched(function (DeleteStorageRequestFiles $job) use ($request3) { - return $job->user->id === $request3->user_id; + Bus::assertNotDispatched(function (DeleteStorageRequestDirectory $job) use ($request3) { + return $job->path === $request3->getPendingPath(); }); $this->assertModelMissing($request1); + $this->assertModelMissing($file1); $this->assertModelExists($request2); $this->assertModelExists($request3); diff --git a/tests/Http/Controllers/Api/StorageRequestControllerTest.php b/tests/Http/Controllers/Api/StorageRequestControllerTest.php index 7fe51be..26b362c 100644 --- a/tests/Http/Controllers/Api/StorageRequestControllerTest.php +++ b/tests/Http/Controllers/Api/StorageRequestControllerTest.php @@ -4,12 +4,15 @@ use ApiTestCase; use Biigle\Modules\UserStorage\Jobs\ApproveStorageRequest; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestDirectory; +use Biigle\Modules\UserStorage\Jobs\AssembleChunkedFile; use Biigle\Modules\UserStorage\Jobs\RejectStorageRequest; use Biigle\Modules\UserStorage\Notifications\StorageRequestRejected; use Biigle\Modules\UserStorage\Notifications\StorageRequestSubmitted; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; +use Illuminate\Bus\PendingBatch; use Illuminate\Notifications\AnonymousNotifiable; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Notification; @@ -19,9 +22,11 @@ class StorageRequestControllerTest extends ApiTestCase { public function testShow() { - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + StorageRequestFile::factory()->create([ + 'storage_request_id' => $request->id, ]); + $request->load('files'); $id = $request->id; $this->doTestApiRoute('GET', "/api/v1/storage-requests/{$id}"); @@ -50,7 +55,7 @@ public function testStore() $this->assertNotNull($request); $this->assertSame($this->guest()->id, $request->user_id); $this->assertNull($request->expires_at); - $this->assertSame([], $request->files); + $this->assertFalse($request->files()->exists()); } public function testStoreLimitOpenRequests() @@ -76,9 +81,11 @@ public function testStoreMaintenanceMode() public function testUpdate() { + Bus::fake(); Notification::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + StorageRequestFile::factory()->create([ + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -92,6 +99,7 @@ public function testUpdate() $this->assertNotNull($request->fresh()->submitted_at); Notification::assertSentTo(new AnonymousNotifiable, StorageRequestSubmitted::class); + Bus::assertNothingDispatched(); } public function testUpdateEmpty() @@ -106,7 +114,6 @@ public function testUpdateEmpty() public function testUpdateAlreadyUpdated() { $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'submitted_at' => '2022-03-11 16:03:00', ]); $id = $request->id; @@ -118,21 +125,62 @@ public function testUpdateAlreadyUpdated() public function testUpdateMaintenanceMode() { config(['user_storage.maintenance_mode' => true]); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], - ]); + $request = StorageRequest::factory()->create(); $id = $request->id; $this->be($request->user); $this->putJson("/api/v1/storage-requests/{$id}")->assertStatus(403); } + public function testUpdateWithChunkedFiles() + { + Bus::fake(); + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = StorageRequestFile::factory()->create([ + 'storage_request_id' => $request->id, + 'received_chunks' => [0, 2, 1], + 'total_chunks' => 3, + ]); + + $this->be($request->user); + $this->putJson("/api/v1/storage-requests/{$id}")->assertStatus(200); + + Bus::assertBatched(function (PendingBatch $batch) use ($file) { + $this->assertCount(1, $batch->jobs); + $this->assertInstanceOf(AssembleChunkedFile::class, $batch->jobs[0]); + $this->assertSame($file->id, $batch->jobs[0]->file->id); + + return true; + }); + } + + public function testUpdateWithUnfinishedChunkedFiles() + { + Bus::fake(); + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = StorageRequestFile::factory()->create([ + 'storage_request_id' => $request->id, + 'received_chunks' => [0], + 'total_chunks' => 2, + ]); + + $this->be($request->user); + // Chunked file did not receive all chunks yet. + $this->putJson("/api/v1/storage-requests/{$id}")->assertStatus(422); + } + public function testApprove() { Bus::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -162,9 +210,12 @@ public function testApproveEmpty() public function testApproveAlreadyApproved() { $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'expires_at' => '2022-03-11 11:22:00', ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + ]); $id = $request->id; $this->beGlobalAdmin(); @@ -176,8 +227,10 @@ public function testReject() Bus::fake(); Notification::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -195,8 +248,8 @@ public function testReject() ]) ->assertStatus(200); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request) { - return count($job->files) === 1 && $job->files[0] === "a.jpg" && $job->user->id === $request->user_id; + Bus::assertDispatched(function (DeleteStorageRequestDirectory $job) use ($request) { + return $job->path === $request->getPendingPath(); }); $this->assertNull($request->fresh()); Notification::assertSentTo([$request->user], StorageRequestRejected::class); @@ -205,9 +258,12 @@ public function testReject() public function testRejectAlreadyApproved() { $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'expires_at' => '2022-03-11 11:22:00', ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + ]); $id = $request->id; $this->beGlobalAdmin(); @@ -218,9 +274,12 @@ public function testExtend() { $expires = now()->addWeeks(3); $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'expires_at' => $expires, ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + ]); $id = $request->id; $this->doTestApiRoute('POST', "/api/v1/storage-requests/{$id}/extend"); @@ -240,9 +299,12 @@ public function testExtend() public function testExtendNotAboutToExpire() { $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'expires_at' => now()->addWeeks(5), ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + ]); $id = $request->id; $this->be($request->user); @@ -251,8 +313,10 @@ public function testExtendNotAboutToExpire() public function testExtendNotApproved() { - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -265,9 +329,12 @@ public function testDestroy() Bus::fake(); $request = StorageRequest::factory()->create([ - 'files' => ['dir/test.jpg'], 'expires_at' => '2022-03-10 15:28:00', ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + ]); $id = $request->id; $this->doTestApiRoute('DELETE', "/api/v1/storage-requests/{$id}"); @@ -278,9 +345,7 @@ public function testDestroy() $this->be($request->user); $this->deleteJson("/api/v1/storage-requests/{$id}")->assertStatus(200); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request) { - return count($job->files) === 1 && $job->files[0] === "dir/test.jpg" && $job->user->id === $request->user_id; - }); + Bus::assertDispatched(DeleteStorageRequestDirectory::class); $this->assertNull($request->fresh()); } @@ -288,17 +353,17 @@ public function testDestroyPending() { Bus::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['dir/test.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; $this->be($request->user); $this->deleteJson("/api/v1/storage-requests/{$id}")->assertStatus(200); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request) { - return count($job->files) === 1 && $job->files[0] === "dir/test.jpg" && $job->user->id === $request->user_id; - }); + Bus::assertDispatched(DeleteStorageRequestDirectory::class); $this->assertNull($request->fresh()); } diff --git a/tests/Http/Controllers/Api/StorageRequestDirectoryControllerTest.php b/tests/Http/Controllers/Api/StorageRequestDirectoryControllerTest.php index d1bf7fe..5b5af1b 100644 --- a/tests/Http/Controllers/Api/StorageRequestDirectoryControllerTest.php +++ b/tests/Http/Controllers/Api/StorageRequestDirectoryControllerTest.php @@ -3,20 +3,27 @@ namespace Biigle\Tests\Modules\UserStorage\Http\Controllers\Api; use ApiTestCase; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFile; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Queue; use Storage; class StorageRequestDirectoryControllerTest extends ApiTestCase { public function testDestory() { - Bus::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['a/a.jpg', 'b/b.jpg'], + Queue::fake(); + $request = StorageRequest::factory()->create(); + $file1 = StorageRequestFile::factory()->create([ + 'path' => 'a/a.jpg', + 'storage_request_id' => $request->id, + ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'b/b.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -38,11 +45,11 @@ public function testDestory() ]) ->assertStatus(200); - $this->assertSame(['b/b.jpg'], $request->fresh()->files); + $this->assertNull($file1->fresh()); + $this->assertNotNull($file2->fresh()); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request) { - $this->assertCount(1, $job->files); - $this->assertSame('a/a.jpg', $job->files[0]); + Queue::assertPushed(function (DeleteStorageRequestFile $job) { + $this->assertSame('a/a.jpg', $job->path); return true; }); @@ -50,8 +57,10 @@ public function testDestory() public function testDestoryNotExists() { - $request = StorageRequest::factory()->create([ - 'files' => ['a/a.jpg'], + $request = StorageRequest::factory()->create(); + $file1 = StorageRequestFile::factory()->create([ + 'path' => 'a/a.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -64,8 +73,14 @@ public function testDestoryNotExists() public function testDestoryAllFiles() { - $request = StorageRequest::factory()->create([ - 'files' => ['a/a.jpg', 'b/b.jpg'], + $request = StorageRequest::factory()->create(); + $file1 = StorageRequestFile::factory()->create([ + 'path' => 'a/a.jpg', + 'storage_request_id' => $request->id, + ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'b/b.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -78,8 +93,14 @@ public function testDestoryAllFiles() public function testDestoryActualDirectory() { - $request = StorageRequest::factory()->create([ - 'files' => ['abc/a.jpg', 'def/b.jpg'], + $request = StorageRequest::factory()->create(); + $file1 = StorageRequestFile::factory()->create([ + 'path' => 'abc/a.jpg', + 'storage_request_id' => $request->id, + ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'def/b.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; diff --git a/tests/Http/Controllers/Api/StorageRequestFileControllerTest.php b/tests/Http/Controllers/Api/StorageRequestFileControllerTest.php index d9bbe09..0d90cf3 100644 --- a/tests/Http/Controllers/Api/StorageRequestFileControllerTest.php +++ b/tests/Http/Controllers/Api/StorageRequestFileControllerTest.php @@ -3,9 +3,11 @@ namespace Biigle\Tests\Modules\UserStorage\Http\Controllers\Api; use ApiTestCase; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFile; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; +use Cache; use Exception; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Bus; @@ -39,13 +41,117 @@ public function testStore() ->assertStatus(422); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); $this->assertTrue($disk->exists("request-{$id}/test.jpg")); - $request->refresh(); - $this->assertSame(['test.jpg'], $request->files); - $user = User::convert($request->user); - $this->assertSame(44074, $user->storage_quota_used); + $file = $request->files()->first(); + $this->assertNotNull($file); + $this->assertSame('test.jpg', $file->path); + $this->assertSame(44074, $file->size); + $this->assertNull($file->total_chunks); + $this->assertNull($file->received_chunks); + } + + public function testStoreChunks() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + ]) + // Chunk total must be given with index. + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_total' => 2, + ]) + // Chunk index must be given with total. + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => -1, + 'chunk_total' => 2, + ]) + // Chunk index must not be negative. + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 1, + ]) + // Chunk total must be larger than 1. + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 2, + 'chunk_total' => 2, + ]) + // Chunk index must be lower than chunk total. + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + $this->assertTrue($disk->exists("request-{$id}/test.jpg.0")); + $f = $request->files()->first(); + $this->assertNotNull($f); + $this->assertSame('test.jpg', $f->path); + $this->assertSame(44074, $f->size); + $this->assertSame(2, $f->total_chunks); + $this->assertSame([0], $f->received_chunks); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 2, + ]) + ->assertStatus(200); + + $this->assertTrue($disk->exists("request-{$id}/test.jpg.1")); + $f->refresh(); + $this->assertSame(88148, $f->size); + $this->assertSame(2, $f->total_chunks); + $this->assertSame([0, 1], $f->received_chunks); + } + + public function testStoreDenyTooLargeNotChunked() + { + config(['user_storage.upload_chunk_size' => 40000]); + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + ]) + ->assertStatus(422); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(422); } public function testStoreTwo() @@ -60,14 +166,15 @@ public function testStoreTwo() $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test2.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); $request->refresh(); - $this->assertSame(['test.jpg', 'test2.jpg'], $request->files); + $files = $request->files()->orderBy('id')->pluck('path')->toArray(); + $this->assertSame(['test.jpg', 'test2.jpg'], $files); } public function testStorePrefix() @@ -84,10 +191,24 @@ public function testStorePrefix() 'prefix' => 'abc/def', 'file' => $file, ]) - ->assertStatus(200); + ->assertStatus(201); $this->assertTrue($disk->exists("request-{$id}/abc/def/test.jpg")); - $this->assertSame(['abc/def/test.jpg'], $request->fresh()->files); + $this->assertSame('abc/def/test.jpg', $request->files()->first()->path); + } + + public function testStoreFilenameAndPrefixLength() + { + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + $this->be($request->user); + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'prefix' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/', + 'file' => $file, + ]) + ->assertStatus(422); } public function testStorePrefixTrailingSlash() @@ -104,10 +225,10 @@ public function testStorePrefixTrailingSlash() 'prefix' => 'abc/def/', 'file' => $file, ]) - ->assertStatus(200); + ->assertStatus(201); $this->assertTrue($disk->exists("request-{$id}/abc/def/test.jpg")); - $this->assertSame(['abc/def/test.jpg'], $request->fresh()->files); + $this->assertSame('abc/def/test.jpg', $request->files()->first()->path); } public function testStorePrefixDoubleSlash() @@ -124,10 +245,10 @@ public function testStorePrefixDoubleSlash() 'prefix' => 'abc//def', 'file' => $file, ]) - ->assertStatus(200); + ->assertStatus(201); $this->assertTrue($disk->exists("request-{$id}/abc/def/test.jpg")); - $this->assertSame(['abc/def/test.jpg'], $request->fresh()->files); + $this->assertSame('abc/def/test.jpg', $request->files()->first()->path); } public function testStorePrefixUnicode() @@ -144,10 +265,10 @@ public function testStorePrefixUnicode() 'prefix' => 'abc/d北f1', 'file' => $file, ]) - ->assertStatus(200); + ->assertStatus(201); $this->assertTrue($disk->exists("request-{$id}/abc/d北f1/test.jpg")); - $this->assertSame(['abc/d北f1/test.jpg'], $request->fresh()->files); + $this->assertSame('abc/d北f1/test.jpg', $request->files()->first()->path); } public function testStorePrefixInvalidCharactersStart() @@ -194,9 +315,7 @@ public function testStorePrefixInvalidInbetween() public function testStoreTooLargeQuota() { - config(['user_storage.pending_disk' => 'test']); config(['user_storage.user_quota' => 10000]); - $disk = Storage::fake('test'); $request = StorageRequest::factory()->create(); $id = $request->id; @@ -207,21 +326,101 @@ public function testStoreTooLargeQuota() ->assertStatus(422); } - public function testStoreTooLargeFile() + public function testStoreChunkTooLargeQuota() { + Bus::fake(); config(['user_storage.pending_disk' => 'test']); - config(['user_storage.max_file_size' => 10000]); + config(['user_storage.user_quota' => 50000]); $disk = Storage::fake('test'); $request = StorageRequest::factory()->create(); $id = $request->id; + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + Cache::clear(); + $f = $request->files()->first(); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 2, + ]) + ->assertStatus(422); + + $this->assertModelMissing($f); + + Bus::assertDispatched(function (DeleteStorageRequestFile $job) { + $this->assertSame('test.jpg', $job->path); + $this->assertSame([0], $job->chunks); + + return true; + }); + } + + public function testStoreTooLargeFile() + { + config(['user_storage.max_file_size' => 10000]); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); $this->be($request->user); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) ->assertStatus(422); } + public function testStoreChunkTooLargeFile() + { + Bus::fake(); + config(['user_storage.pending_disk' => 'test']); + config(['user_storage.max_file_size' => 50000]); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + Cache::clear(); + $f = $request->files()->first(); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 2, + ]) + ->assertStatus(422); + + $this->assertModelMissing($f); + + Bus::assertDispatched(function (DeleteStorageRequestFile $job) { + $this->assertSame('test.jpg', $job->path); + $this->assertSame([0], $job->chunks); + + return true; + }); + } + public function testStoreMimeType() { config(['user_storage.pending_disk' => 'test']); @@ -241,6 +440,109 @@ public function testStoreMimeType() ->assertStatus(422); } + public function testStoreChunkMimeType() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + $file = new UploadedFile(__DIR__."/../../../files/test.txt", 'test.jpg', 'text/plain', null, true); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 2, + ]) + ->assertStatus(200); + } + + public function testStoreChunkChunkTotalMismatch() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 3, + ]) + ->assertStatus(422); + } + + public function testStoreChunkChunkIndexExists() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(201); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 0, + 'chunk_total' => 2, + ]) + ->assertStatus(422); + } + + public function testStoreChunkFirstChunkFirst() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $id = $request->id; + + $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); + + $this->be($request->user); + + $this->postJson("/api/v1/storage-requests/{$id}/files", [ + 'file' => $file, + 'chunk_index' => 1, + 'chunk_total' => 2, + ]) + ->assertStatus(422); + } + public function testStoreRequestSubmitted() { config(['user_storage.pending_disk' => 'test']); @@ -263,6 +565,11 @@ public function testStoreExistsInSameRequest() $disk = Storage::fake('test'); $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => $request->id, + 'size' => 123, + ]); $id = $request->id; $disk->put("request-{$id}/test.jpg", 'abc'); @@ -273,19 +580,19 @@ public function testStoreExistsInSameRequest() ->assertStatus(200); $this->assertNotSame('abc', $disk->get("request-{$id}/test.jpg")); + $this->assertSame(1, $request->files()->count()); + $this->assertSame(44074, $request->files()->first()->size); } public function testStoreExistsInOtherRequest() { $request = StorageRequest::factory()->create(); - StorageRequest::factory()->create([ - 'user_id' => $request->user_id, - 'files' => ['test.jpg'], - ]); - StorageRequest::factory()->create([ - 'user_id' => $request->user_id, - 'files' => ['test2.jpg', 'test3.jpg'], + $file = StorageRequestFile::factory()->create([ + 'path' => 'test.jpg', + 'storage_request_id' => StorageRequest::factory()->create([ + 'user_id' => $request->user_id, + ])->id, ]); $id = $request->id; @@ -310,7 +617,8 @@ public function testStoreExceedsQuota() $this->be($request->user); $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); + Cache::clear(); $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test2.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) ->assertStatus(422); @@ -335,10 +643,10 @@ public function testStoreExceedsConfigQuotaButNotUserQuota() $this->be($request->user); $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); $file = new UploadedFile(__DIR__."/../../../files/test.jpg", 'test2.jpg', 'image/jpeg', null, true); $this->postJson("/api/v1/storage-requests/{$id}/files", ['file' => $file]) - ->assertStatus(200); + ->assertStatus(201); } public function testStoreMaintenanceMode() @@ -371,8 +679,14 @@ public function testStoreRetryOnFailure() } public function testShow() { - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'b.jpg', + 'storage_request_id' => $request->id, ]); $id = $request->id; @@ -383,28 +697,28 @@ public function testShow() { throw new RuntimeException; }); $disk->put("request-{$id}/a.jpg", 'abc'); - $disk->put("request-{$id}/c.jpg", 'abc'); - $this->doTestApiRoute('GET', "/api/v1/storage-requests/{$id}/files?path=a.jpg"); + $this->doTestApiRoute('GET', "/api/v1/storage-request-files/{$file->id}"); $this->beUser(); - $this->get("/api/v1/storage-requests/{$id}/files?path=a.jpg")->assertStatus(404); + $this->get("/api/v1/storage-request-files/{$file->id}")->assertStatus(404); $this->be($request->user); - $this->get("/api/v1/storage-requests/{$id}/files?path=a.jpg")->assertStatus(404); + $this->get("/api/v1/storage-request-files/{$file->id}")->assertStatus(404); $this->beGlobalAdmin(); - $this->get("/api/v1/storage-requests/{$id}/files?path=a.jpg")->assertStatus(200); - $this->get("/api/v1/storage-requests/{$id}/files?path=b.jpg")->assertStatus(404); - $this->get("/api/v1/storage-requests/{$id}/files?path=c.jpg")->assertStatus(404); + $this->get("/api/v1/storage-request-files/{$file->id}")->assertStatus(200); + $this->get("/api/v1/storage-request-files/{$file2->id}")->assertStatus(404); } public function testShowApproved() { $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], 'expires_at' => '2022-03-28 14:03:00', ]); - $id = $request->id; + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); config(['user_storage.storage_disk' => 'test']); $disk = Storage::fake('test'); @@ -415,7 +729,7 @@ public function testShowApproved() { $disk->put("user-{$request->user_id}/a.jpg", 'abc'); $this->beGlobalAdmin(); - $this->get("/api/v1/storage-requests/{$id}/files?path=a.jpg")->assertStatus(200); + $this->get("/api/v1/storage-request-files/{$file->id}")->assertStatus(200); } public function testShowPublic() { @@ -423,96 +737,53 @@ public function testShowPublic() { $mock->shouldReceive('temporaryUrl')->once()->andReturn('myurl'); Storage::shouldReceive('disk')->andReturn($mock); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', ]); - $id = $request->id; $this->beGlobalAdmin(); - $this->get("/api/v1/storage-requests/{$id}/files?path=a.jpg") + $this->get("/api/v1/storage-request-files/{$file->id}") ->assertRedirect('myurl'); } - public function testShowUrlEncode() { - $request = StorageRequest::factory()->create([ - 'files' => ['my dir/a.jpg'], - ]); - $id = $request->id; - - config(['user_storage.pending_disk' => 'test']); - $disk = Storage::fake('test'); - $disk->buildTemporaryUrlsUsing(function () { - // Act as if the storage disk driver does not support temporary URLs. - throw new RuntimeException; - }); - $disk->put("request-{$id}/my dir/a.jpg", 'abc'); - - $this->beGlobalAdmin(); - $this->get("/api/v1/storage-requests/{$id}/files?path=my%20dir%2Fa.jpg") - ->assertStatus(200); - } - public function testDestory() { Bus::fake(); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + ]); + $file2 = StorageRequestFile::factory()->create([ + 'path' => 'b.jpg', + 'storage_request_id' => $file->storage_request_id, ]); - $id = $request->id; - $this->doTestApiRoute('DELETE', "/api/v1/storage-requests/{$id}/files"); + $this->doTestApiRoute('DELETE', "/api/v1/storage-request-files/{$file->id}"); $this->beUser(); - $this->deleteJson("/api/v1/storage-requests/{$id}/files", [ - 'files' => ['a.jpg'], - ]) + $this->deleteJson("/api/v1/storage-request-files/{$file->id}") ->assertStatus(403); - $this->be($request->user); - $this->deleteJson("/api/v1/storage-requests/{$id}/files") - // Files must be specified. - ->assertStatus(422); - - $this->deleteJson("/api/v1/storage-requests/{$id}/files", [ - 'files' => ['a.jpg'], - ]) + $this->be($file->request->user); + $this->deleteJson("/api/v1/storage-request-files/{$file->id}") ->assertStatus(200); - $this->assertSame(['b.jpg'], $request->fresh()->files); + $this->assertNull($file->fresh()); - Bus::assertDispatched(function (DeleteStorageRequestFiles $job) use ($request) { - $this->assertCount(1, $job->files); - $this->assertSame('a.jpg', $job->files[0]); + Bus::assertDispatched(function (DeleteStorageRequestFile $job) { + $this->assertSame('a.jpg', $job->path); return true; }); } - public function testDestoryNotExists() + public function testDestoryLastFile() { - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', ]); - $id = $request->id; - $this->be($request->user); - $this->deleteJson("/api/v1/storage-requests/{$id}/files", [ - 'files' => ['b.jpg'], - ]) - ->assertStatus(422); - } - - public function testDestoryAllFiles() - { - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], - ]); - $id = $request->id; - - $this->be($request->user); - $this->deleteJson("/api/v1/storage-requests/{$id}/files", [ - 'files' => ['a.jpg', 'b.jpg'], - ]) + $this->be($file->request->user); + $this->deleteJson("/api/v1/storage-request-files/{$file->id}") ->assertStatus(422); } diff --git a/tests/Jobs/ApproveStorageRequestTest.php b/tests/Jobs/ApproveStorageRequestTest.php index 42aa3bd..414df6e 100644 --- a/tests/Jobs/ApproveStorageRequestTest.php +++ b/tests/Jobs/ApproveStorageRequestTest.php @@ -5,6 +5,7 @@ use Biigle\Modules\UserStorage\Jobs\ApproveStorageRequest; use Biigle\Modules\UserStorage\Notifications\StorageRequestApproved; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Illuminate\Support\Facades\Notification; use Storage; use TestCase; @@ -19,8 +20,10 @@ public function testHandle() $storageDisk = Storage::fake('storage'); $pendingDisk = Storage::fake('pending'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, ]); $pendingDisk->put($request->getPendingPath('a.jpg'), 'abc'); @@ -40,8 +43,10 @@ public function testHandleSameDisk() config(['user_storage.pending_disk' => 'storage']); $storageDisk = Storage::fake('storage'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, ]); $storageDisk->put($request->getPendingPath('a.jpg'), 'abc'); diff --git a/tests/Jobs/AssembleChunkedFileTest.php b/tests/Jobs/AssembleChunkedFileTest.php new file mode 100644 index 0000000..bdf9473 --- /dev/null +++ b/tests/Jobs/AssembleChunkedFileTest.php @@ -0,0 +1,68 @@ + 'test']); + $disk = Storage::fake('test'); + + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + 'received_chunks' => [0, 1], + 'total_chunks' => 2, + ]); + + $disk->put($request->getPendingPath('a.jpg.0'), 'abc'); + $disk->put($request->getPendingPath('a.jpg.1'), 'def'); + + $job = new AssembleChunkedFile($file); + $job->handle(); + + $this->assertFalse($disk->exists($request->getPendingPath('a.jpg.0'))); + $this->assertFalse($disk->exists($request->getPendingPath('a.jpg.1'))); + $this->assertSame('abcdef', $disk->get($request->getPendingPath('a.jpg'))); + $this->assertEmpty(File::allFiles(config('user_storage.tmp_dir'))); + + $file->refresh(); + $this->assertNull($file->received_chunks); + $this->assertNull($file->total_chunks); + } + + public function testHandleNotChunked() + { + $request = StorageRequest::factory()->create(); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); + + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + + $disk->put($request->getPendingPath('a.jpg'), 'abc'); + + $this->expectException(Exception::class); + $job = new AssembleChunkedFile($file); + } +} diff --git a/tests/Jobs/DeleteStorageRequestDirectoryTest.php b/tests/Jobs/DeleteStorageRequestDirectoryTest.php new file mode 100644 index 0000000..389fbcc --- /dev/null +++ b/tests/Jobs/DeleteStorageRequestDirectoryTest.php @@ -0,0 +1,48 @@ + 'test']); + $disk = Storage::fake('test'); + $request = StorageRequest::factory()->create([ + 'expires_at' => '2022-03-10 15:46:00', + ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); + + $disk->put("user-{$request->user_id}/a.jpg", 'abc'); + + $job = new DeleteStorageRequestDirectory($request); + $job->handle(); + + $this->assertFalse($disk->exists("user-{$request->user_id}")); + } + + public function testHandlePending() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + ]); + + $disk->put("request-{$file->storage_request_id}/a.jpg", 'abc'); + + $job = new DeleteStorageRequestDirectory($file->request); + $job->handle(); + + $this->assertFalse($disk->exists("request-{$file->storage_request_id}")); + } +} diff --git a/tests/Jobs/DeleteStorageRequestFileTest.php b/tests/Jobs/DeleteStorageRequestFileTest.php new file mode 100644 index 0000000..45fd5e6 --- /dev/null +++ b/tests/Jobs/DeleteStorageRequestFileTest.php @@ -0,0 +1,89 @@ + 'test']); + $disk = Storage::fake('test'); + $request = StorageRequest::factory()->create([ + 'expires_at' => '2022-03-10 15:46:00', + ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); + + $disk->put("user-{$request->user_id}/a.jpg", 'abc'); + + $job = new DeleteStorageRequestFile($file); + $job->handle(); + + $this->assertFalse($disk->exists("user-{$request->user_id}/a.jpg")); + } + + public function testHandleClearAll() + { + config(['user_storage.storage_disk' => 'test']); + $disk = Storage::fake('test'); + $request = StorageRequest::factory()->create([ + 'expires_at' => '2022-03-10 15:46:00', + ]); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'storage_request_id' => $request->id, + ]); + + $disk->put("user-{$request->user_id}/a.jpg", 'abc'); + + $job = new DeleteStorageRequestFile($file); + $job->handle(); + + $this->assertFalse($disk->exists("user-{$request->user_id}")); + } + + public function testHandlePending() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + ]); + + $disk->put("request-{$file->storage_request_id}/a.jpg", 'abc'); + + $job = new DeleteStorageRequestFile($file); + $job->handle(); + + $this->assertFalse($disk->exists("request-{$file->storage_request_id}/a.jpg")); + } + + public function testHandleChunks() + { + config(['user_storage.pending_disk' => 'test']); + $disk = Storage::fake('test'); + $file = StorageRequestFile::factory()->create([ + 'path' => 'a.jpg', + 'received_chunks' => [0, 2], + 'total_chunks' => 3, + ]); + + $disk->put("request-{$file->storage_request_id}/a.jpg.0", 'abc'); + $disk->put("request-{$file->storage_request_id}/a.jpg.2", 'abc'); + + $job = new DeleteStorageRequestFile($file); + $job->handle(); + + $this->assertFalse($disk->exists("request-{$file->storage_request_id}/a.jpg.0")); + $this->assertFalse($disk->exists("request-{$file->storage_request_id}/a.jpg.2")); + } +} diff --git a/tests/Jobs/DeleteStorageRequestFilesTest.php b/tests/Jobs/DeleteStorageRequestFilesTest.php deleted file mode 100644 index 3737406..0000000 --- a/tests/Jobs/DeleteStorageRequestFilesTest.php +++ /dev/null @@ -1,145 +0,0 @@ - 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], - 'expires_at' => '2022-03-10 15:46:00', - ]); - - $disk->put("user-{$request->user_id}/a.jpg", 'abc'); - $disk->put("user-{$request->user_id}/c.jpg", 'abc'); - $user = User::convert($request->user); - $user->storage_quota_used = 10; - $request->user = $user; - - $job = new DeleteStorageRequestFiles($request); - $job->handle(); - - $this->assertFalse($disk->exists("user-{$request->user_id}/a.jpg")); - $this->assertTrue($disk->exists("user-{$request->user_id}/c.jpg")); - $this->assertSame(7, $user->fresh()->storage_quota_used); - } - - public function testHandleClearAll() - { - config(['user_storage.storage_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], - 'expires_at' => '2022-03-10 15:46:00', - ]); - - $disk->put("user-{$request->user_id}/a.jpg", 'abc'); - - $job = new DeleteStorageRequestFiles($request); - $job->handle(); - - $this->assertFalse($disk->exists("user-{$request->user_id}")); - } - - public function testHandlePending() - { - config(['user_storage.pending_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], - ]); - - $disk->put("request-{$request->id}/a.jpg", 'abc'); - - $job = new DeleteStorageRequestFiles($request); - $job->handle(); - - $this->assertFalse($disk->exists("request-{$request->id}")); - } - - public function testHandlePendingOnly() - { - config(['user_storage.pending_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a/a.jpg', 'a/b.jpg'], - ]); - - $disk->put("request-{$request->id}/a/a.jpg", 'abc'); - $disk->put("request-{$request->id}/a/b.jpg", 'abc'); - - $job = new DeleteStorageRequestFiles($request, ['a/a.jpg']); - $job->handle(); - - $this->assertFalse($disk->exists("request-{$request->id}/a/a.jpg")); - $this->assertTrue($disk->exists("request-{$request->id}/a/b.jpg")); - } - - public function testHandleUserDeleted() - { - config(['user_storage.storage_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg'], - 'expires_at' => '2022-03-10 15:46:00', - ]); - - $disk->put("user-{$request->user_id}/a.jpg", 'abc'); - - $job = new DeleteStorageRequestFiles($request); - $job->user = null; - $job->handle(); - - $this->assertFalse($disk->exists("user-{$request->user_id}/a.jpg")); - } - - public function testHandleOnly() - { - config(['user_storage.storage_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], - 'expires_at' => '2022-03-10 15:46:00', - ]); - - $disk->put("user-{$request->user_id}/a.jpg", 'abc'); - $disk->put("user-{$request->user_id}/b.jpg", 'abc'); - $user = User::convert($request->user); - $user->storage_quota_used = 10; - $request->user = $user; - - $job = new DeleteStorageRequestFiles($request, ['a.jpg']); - $job->handle(); - - $this->assertFalse($disk->exists("user-{$request->user_id}/a.jpg")); - $this->assertTrue($disk->exists("user-{$request->user_id}/b.jpg")); - $this->assertSame(7, $user->fresh()->storage_quota_used); - } - - public function testHandleOnlyPending() - { - config(['user_storage.pending_disk' => 'test']); - $disk = Storage::fake('test'); - $request = StorageRequest::factory()->create([ - 'files' => ['a.jpg', 'b.jpg'], - ]); - - $disk->put("request-{$request->id}/a.jpg", 'abc'); - $disk->put("request-{$request->id}/b.jpg", 'abc'); - - $job = new DeleteStorageRequestFiles($request, ['a.jpg']); - $job->handle(); - - $this->assertFalse($disk->exists("request-{$request->id}/a.jpg")); - $this->assertTrue($disk->exists("request-{$request->id}/b.jpg")); - } -} diff --git a/tests/StorageRequestFileTest.php b/tests/StorageRequestFileTest.php new file mode 100644 index 0000000..585da82 --- /dev/null +++ b/tests/StorageRequestFileTest.php @@ -0,0 +1,27 @@ +assertNotNull($this->model->path); + $this->assertNotNull($this->model->request); + $this->assertNotNull($this->model->size); + } + + public function testRequestDeletedCascade() + { + $this->model->request->delete(); + $this->assertFalse($this->model->exists()); + } +} diff --git a/tests/StorageRequestTest.php b/tests/StorageRequestTest.php index 4cb4c9c..708d834 100644 --- a/tests/StorageRequestTest.php +++ b/tests/StorageRequestTest.php @@ -2,8 +2,9 @@ namespace Biigle\Tests\Modules\UserStorage; -use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestFiles; +use Biigle\Modules\UserStorage\Jobs\DeleteStorageRequestDirectory; use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Illuminate\Support\Facades\Bus; use ModelTestCase; @@ -27,18 +28,10 @@ public function testUserDeletedCascade() { Bus::fake(); // The delete files job is only dispatched if the request has files. - $this->model->update(['files' => ['a.jpg']]); + $this->model->files()->save(StorageRequestFile::factory()->make()); $this->model->user->delete(); $this->assertNull($this->model->fresh()); - Bus::assertDispatched(DeleteStorageRequestFiles::class); - } - - public function testGetSetFiles() - { - $files = ['a.jpg', 'b.jpg']; - $this->model->files = $files; - $this->model->save(); - $this->assertSame($files, $this->model->fresh()->files); + Bus::assertDispatched(DeleteStorageRequestDirectory::class); } public function testGetPendingPath() @@ -70,7 +63,14 @@ public function testGetExpiresAtForHumans() public function testGetFilesCount() { $this->assertSame(0, $this->model->files_count); - $this->model->files = ['a.jpg']; + $this->model->files()->save(StorageRequestFile::factory()->make()); $this->assertSame(1, $this->model->files_count); } + + public function testGetSize() + { + $this->assertSame(0, $this->model->size); + $this->model->files()->save(StorageRequestFile::factory()->make(['size' => 123])); + $this->assertSame(123, $this->model->size); + } } diff --git a/tests/UserTest.php b/tests/UserTest.php index 1e38f68..995a260 100644 --- a/tests/UserTest.php +++ b/tests/UserTest.php @@ -2,8 +2,11 @@ namespace Biigle\Tests\Modules\UserStorage; +use Biigle\Modules\UserStorage\StorageRequest; +use Biigle\Modules\UserStorage\StorageRequestFile; use Biigle\Modules\UserStorage\User; use Biigle\User as BaseUser; +use Cache; use TestCase; class UserTest extends TestCase @@ -27,30 +30,43 @@ public function testGetSetStorageQuotaAvailable() $this->assertSame(1000, $user->storage_quota_available); } - public function testGetSetStorageQuotaUsed() + public function testGetStorageQuotaUsed() { - $user = User::convert(BaseUser::factory()->make()); + $user = User::convert(BaseUser::factory()->create()); + $request = StorageRequest::factory()->create([ + 'user_id' => $user->id, + ]); $this->assertSame(0, $user->storage_quota_used); - $user->storage_quota_used += 100; - $this->assertSame(100, $user->storage_quota_used); + $request->files()->createMany([ + ['path' => 'a.jpg', 'size' => 2], + ['path' => 'b.jpg', 'size' => 3], + ]); + Cache::clear(); - $user->storage_quota_used -= 101; - $this->assertSame(0, $user->storage_quota_used); + $this->assertSame(5, $user->storage_quota_used); } public function testGetStorageQuotaRemaining() { config(['user_storage.user_quota' => 500]); - $user = User::convert(BaseUser::factory()->make()); + $user = User::convert(BaseUser::factory()->create()); $this->assertSame(500, $user->storage_quota_remaining); + Cache::clear(); $user->storage_quota_available = 300; $this->assertSame(300, $user->storage_quota_remaining); + Cache::clear(); + + StorageRequestFile::factory()->create([ + 'size' => 100, + 'storage_request_id' => StorageRequest::factory()->create([ + 'user_id' => $user->id, + ])->id, + ]); - $user->storage_quota_used = 100; $this->assertSame(200, $user->storage_quota_remaining); } }