From 8ea3855e02aa5ff7782dc65e1eee8b8b24f28ce6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 2 Nov 2024 20:48:21 +0000 Subject: [PATCH] ZIP Import: Added upload handling Split attachment service storage work out so it can be shared. --- app/Exceptions/ZipValidationException.php | 12 ++ app/Exports/Controllers/ImportController.php | 41 ++----- app/Exports/ImportRepo.php | 48 ++++++++ app/Uploads/AttachmentService.php | 86 ++------------ app/Uploads/FileStorage.php | 111 +++++++++++++++++++ 5 files changed, 195 insertions(+), 103 deletions(-) create mode 100644 app/Exceptions/ZipValidationException.php create mode 100644 app/Exports/ImportRepo.php create mode 100644 app/Uploads/FileStorage.php diff --git a/app/Exceptions/ZipValidationException.php b/app/Exceptions/ZipValidationException.php new file mode 100644 index 000000000..aaaee792e --- /dev/null +++ b/app/Exceptions/ZipValidationException.php @@ -0,0 +1,12 @@ +middleware('can:content-import'); } @@ -27,35 +28,17 @@ class ImportController extends Controller public function upload(Request $request) { $this->validate($request, [ - 'file' => ['required', 'file'] + 'file' => ['required', ...AttachmentService::getFileValidationRules()] ]); $file = $request->file('file'); - $zipPath = $file->getRealPath(); - - $errors = (new ZipExportValidator($zipPath))->validate(); - if ($errors) { - session()->flash('validation_errors', $errors); + try { + $import = $this->imports->storeFromUpload($file); + } catch (ZipValidationException $exception) { + session()->flash('validation_errors', $exception->errors); return redirect('/import'); } - $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); - $import = new Import(); - $import->name = $zipEntityInfo['name']; - $import->book_count = $zipEntityInfo['book_count']; - $import->chapter_count = $zipEntityInfo['chapter_count']; - $import->page_count = $zipEntityInfo['page_count']; - $import->created_by = user()->id; - $import->size = filesize($zipPath); - // TODO - Set path - // TODO - Save - - // TODO - Split out attachment service to separate out core filesystem/disk stuff - // To reuse for import handling - - dd('passed'); - // TODO - Upload to storage - // TODO - Store info/results for display: - // TODO - Send user to next import stage + return redirect("imports/{$import->id}"); } } diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php new file mode 100644 index 000000000..c8157967b --- /dev/null +++ b/app/Exports/ImportRepo.php @@ -0,0 +1,48 @@ +getRealPath(); + + $errors = (new ZipExportValidator($zipPath))->validate(); + if ($errors) { + throw new ZipValidationException($errors); + } + + $zipEntityInfo = (new ZipExportReader($zipPath))->getEntityInfo(); + $import = new Import(); + $import->name = $zipEntityInfo['name']; + $import->book_count = $zipEntityInfo['book_count']; + $import->chapter_count = $zipEntityInfo['chapter_count']; + $import->page_count = $zipEntityInfo['page_count']; + $import->created_by = user()->id; + $import->size = filesize($zipPath); + + $path = $this->storage->uploadFile( + $file, + 'uploads/files/imports/', + '', + 'zip' + ); + + $import->path = $path; + $import->save(); + + return $import; + } +} diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index 227649d8f..fa53c4ae4 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -4,59 +4,15 @@ namespace BookStack\Uploads; use BookStack\Exceptions\FileUploadException; use Exception; -use Illuminate\Contracts\Filesystem\Filesystem as Storage; -use Illuminate\Filesystem\FilesystemManager; -use Illuminate\Support\Facades\Log; -use Illuminate\Support\Str; -use League\Flysystem\WhitespacePathNormalizer; use Symfony\Component\HttpFoundation\File\UploadedFile; class AttachmentService { public function __construct( - protected FilesystemManager $fileSystem + protected FileStorage $storage, ) { } - /** - * Get the storage that will be used for storing files. - */ - protected function getStorageDisk(): Storage - { - return $this->fileSystem->disk($this->getStorageDiskName()); - } - - /** - * Get the name of the storage disk to use. - */ - protected function getStorageDiskName(): string - { - $storageType = config('filesystems.attachments'); - - // Change to our secure-attachment disk if any of the local options - // are used to prevent escaping that location. - if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { - $storageType = 'local_secure_attachments'; - } - - return $storageType; - } - - /** - * Change the originally provided path to fit any disk-specific requirements. - * This also ensures the path is kept to the expected root folders. - */ - protected function adjustPathForStorageDisk(string $path): string - { - $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); - - if ($this->getStorageDiskName() === 'local_secure_attachments') { - return $path; - } - - return 'uploads/files/' . $path; - } - /** * Stream an attachment from storage. * @@ -64,7 +20,7 @@ class AttachmentService */ public function streamAttachmentFromStorage(Attachment $attachment) { - return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getReadStream($attachment->path); } /** @@ -72,7 +28,7 @@ class AttachmentService */ public function getAttachmentFileSize(Attachment $attachment): int { - return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path)); + return $this->storage->getSize($attachment->path); } /** @@ -195,15 +151,9 @@ class AttachmentService * Delete a file from the filesystem it sits on. * Cleans any empty leftover folders. */ - protected function deleteFileInStorage(Attachment $attachment) + protected function deleteFileInStorage(Attachment $attachment): void { - $storage = $this->getStorageDisk(); - $dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path)); - - $storage->delete($this->adjustPathForStorageDisk($attachment->path)); - if (count($storage->allFiles($dirPath)) === 0) { - $storage->deleteDirectory($dirPath); - } + $this->storage->delete($attachment->path); } /** @@ -213,32 +163,20 @@ class AttachmentService */ protected function putFileInStorage(UploadedFile $uploadedFile): string { - $storage = $this->getStorageDisk(); $basePath = 'uploads/files/' . date('Y-m-M') . '/'; - $uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension(); - while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { - $uploadFileName = Str::random(3) . $uploadFileName; - } - - $attachmentStream = fopen($uploadedFile->getRealPath(), 'r'); - $attachmentPath = $basePath . $uploadFileName; - - try { - $storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream); - } catch (Exception $e) { - Log::error('Error when attempting file upload:' . $e->getMessage()); - - throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath])); - } - - return $attachmentPath; + return $this->storage->uploadFile( + $uploadedFile, + $basePath, + $uploadedFile->getClientOriginalExtension(), + '' + ); } /** * Get the file validation rules for attachments. */ - public function getFileValidationRules(): array + public static function getFileValidationRules(): array { return ['file', 'max:' . (config('app.upload_limit') * 1000)]; } diff --git a/app/Uploads/FileStorage.php b/app/Uploads/FileStorage.php new file mode 100644 index 000000000..278484e51 --- /dev/null +++ b/app/Uploads/FileStorage.php @@ -0,0 +1,111 @@ +getStorageDisk()->readStream($this->adjustPathForStorageDisk($path)); + } + + public function getSize(string $path): int + { + return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path)); + } + + public function delete(string $path, bool $removeEmptyDir = false): void + { + $storage = $this->getStorageDisk(); + $adjustedPath = $this->adjustPathForStorageDisk($path); + $dir = dirname($adjustedPath); + + $storage->delete($adjustedPath); + if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) { + $storage->deleteDirectory($dir); + } + } + + /** + * @throws FileUploadException + */ + public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string + { + $storage = $this->getStorageDisk(); + $basePath = trim($subDirectory, '/') . '/'; + + $uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : ''); + while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) { + $uploadFileName = Str::random(3) . $uploadFileName; + } + + $fileStream = fopen($file->getRealPath(), 'r'); + $filePath = $basePath . $uploadFileName; + + try { + $storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream); + } catch (Exception $e) { + Log::error('Error when attempting file upload:' . $e->getMessage()); + + throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath])); + } + + return $filePath; + } + + /** + * Get the storage that will be used for storing files. + */ + protected function getStorageDisk(): Storage + { + return $this->fileSystem->disk($this->getStorageDiskName()); + } + + /** + * Get the name of the storage disk to use. + */ + protected function getStorageDiskName(): string + { + $storageType = config('filesystems.attachments'); + + // Change to our secure-attachment disk if any of the local options + // are used to prevent escaping that location. + if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') { + $storageType = 'local_secure_attachments'; + } + + return $storageType; + } + + /** + * Change the originally provided path to fit any disk-specific requirements. + * This also ensures the path is kept to the expected root folders. + */ + protected function adjustPathForStorageDisk(string $path): string + { + $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path)); + + if ($this->getStorageDiskName() === 'local_secure_attachments') { + return $path; + } + + return 'uploads/files/' . $path; + } +}