diff --git a/app/Exceptions/FileUploadException.php b/app/Exceptions/FileUploadException.php new file mode 100644 index 000000000..af976072a --- /dev/null +++ b/app/Exceptions/FileUploadException.php @@ -0,0 +1,4 @@ +belongsTo(Page::class, 'uploaded_to'); + } + + +} diff --git a/app/Http/Controllers/FileController.php b/app/Http/Controllers/FileController.php new file mode 100644 index 000000000..b97112c1c --- /dev/null +++ b/app/Http/Controllers/FileController.php @@ -0,0 +1,91 @@ +fileService = $fileService; + $this->file = $file; + $this->pageRepo = $pageRepo; + } + + + /** + * Endpoint at which files are uploaded to. + * @param Request $request + */ + public function upload(Request $request) + { + // TODO - Add file upload permission check + // TODO - ensure user has permission to edit relevant page. + // TODO - ensure uploads are deleted on page delete. + + $this->validate($request, [ + 'uploaded_to' => 'required|integer|exists:pages,id' + ]); + + $uploadedFile = $request->file('file'); + $pageId = $request->get('uploaded_to'); + + try { + $file = $this->fileService->saveNewUpload($uploadedFile, $pageId); + } catch (FileUploadException $e) { + return response($e->getMessage(), 500); + } + + return response()->json($file); + } + + /** + * Get the files for a specific page. + * @param $pageId + * @return mixed + */ + public function getFilesForPage($pageId) + { + // TODO - check view permission on page? + $page = $this->pageRepo->getById($pageId); + return response()->json($page->files); + } + + /** + * Update the file sorting. + * @param $pageId + * @param Request $request + * @return mixed + */ + public function sortFilesForPage($pageId, Request $request) + { + $this->validate($request, [ + 'files' => 'required|array', + 'files.*.id' => 'required|integer', + ]); + $page = $this->pageRepo->getById($pageId); + $files = $request->get('files'); + $this->fileService->updateFileOrderWithinPage($files, $pageId); + return response()->json(['message' => 'File order updated']); + } + + +} diff --git a/app/Page.php b/app/Page.php index 1961a4f7f..94bcce2b5 100644 --- a/app/Page.php +++ b/app/Page.php @@ -54,6 +54,15 @@ class Page extends Entity return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc'); } + /** + * Get the files attached to this page. + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function files() + { + return $this->hasMany(File::class, 'uploaded_to')->orderBy('order', 'asc'); + } + /** * Get the url for this page. * @param string|bool $path diff --git a/app/Repos/PageRepo.php b/app/Repos/PageRepo.php index 537dd9bd0..dc7bdb403 100644 --- a/app/Repos/PageRepo.php +++ b/app/Repos/PageRepo.php @@ -48,7 +48,7 @@ class PageRepo extends EntityRepo * Get a page via a specific ID. * @param $id * @param bool $allowDrafts - * @return mixed + * @return Page */ public function getById($id, $allowDrafts = false) { diff --git a/app/Services/FileService.php b/app/Services/FileService.php new file mode 100644 index 000000000..689fa0600 --- /dev/null +++ b/app/Services/FileService.php @@ -0,0 +1,79 @@ +getClientOriginalName(); + $fileData = file_get_contents($uploadedFile->getRealPath()); + + $storage = $this->getStorage(); + $fileBasePath = 'uploads/files/' . Date('Y-m-M') . '/'; + $storageBasePath = $this->getStorageBasePath() . $fileBasePath; + + $uploadFileName = $fileName; + while ($storage->exists($storageBasePath . $uploadFileName)) { + $uploadFileName = str_random(3) . $uploadFileName; + } + + $filePath = $fileBasePath . $uploadFileName; + $fileStoragePath = $this->getStorageBasePath() . $filePath; + + try { + $storage->put($fileStoragePath, $fileData); + } catch (Exception $e) { + throw new FileUploadException('File path ' . $fileStoragePath . ' could not be uploaded to. Ensure it is writable to the server.'); + } + + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order'); + + $file = File::forceCreate([ + 'name' => $fileName, + 'path' => $filePath, + 'uploaded_to' => $page_id, + 'created_by' => user()->id, + 'updated_by' => user()->id, + 'order' => $largestExistingOrder + 1 + ]); + + return $file; + } + + /** + * Get the file storage base path, amended for storage type. + * This allows us to keep a generic path in the database. + * @return string + */ + private function getStorageBasePath() + { + return $this->isLocal() ? 'storage/' : ''; + } + + /** + * Updates the file ordering for a listing of attached files. + * @param array $fileList + * @param $pageId + */ + public function updateFileOrderWithinPage($fileList, $pageId) + { + foreach ($fileList as $index => $file) { + File::where('uploaded_to', '=', $pageId)->where('id', '=', $file['id'])->update(['order' => $index]); + } + } + +} \ No newline at end of file diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index a56626246..dfe2cf453 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; use Illuminate\Contracts\Filesystem\Factory as FileSystem; use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; use Illuminate\Contracts\Cache\Repository as Cache; -use Setting; use Symfony\Component\HttpFoundation\File\UploadedFile; -class ImageService +class ImageService extends UploadService { protected $imageTool; - protected $fileSystem; protected $cache; - - /** - * @var FileSystemInstance - */ - protected $storageInstance; protected $storageUrl; /** @@ -34,8 +27,8 @@ class ImageService public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) { $this->imageTool = $imageTool; - $this->fileSystem = $fileSystem; $this->cache = $cache; + parent::__construct($fileSystem); } /** @@ -88,6 +81,9 @@ class ImageService if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; + + if ($this->isLocal()) $imagePath = '/public' . $imagePath; + while ($storage->exists($imagePath . $imageName)) { $imageName = str_random(3) . $imageName; } @@ -100,6 +96,8 @@ class ImageService throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); } + if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath); + $imageDetails = [ 'name' => $imageName, 'path' => $fullPath, @@ -119,6 +117,16 @@ class ImageService return $image; } + /** + * Get the storage path, Dependant of storage type. + * @param Image $image + * @return mixed|string + */ + protected function getPath(Image $image) + { + return ($this->isLocal()) ? ('public/' . $image->path) : $image->path; + } + /** * Get the thumbnail for an image. * If $keepRatio is true only the width will be used. @@ -135,7 +143,8 @@ class ImageService public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) { $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; - $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path); + $imagePath = $this->getPath($image); + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { return $this->getPublicUrl($thumbFilePath); @@ -148,7 +157,7 @@ class ImageService } try { - $thumb = $this->imageTool->make($storage->get($image->path)); + $thumb = $this->imageTool->make($storage->get($imagePath)); } catch (Exception $e) { if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); @@ -183,8 +192,8 @@ class ImageService { $storage = $this->getStorage(); - $imageFolder = dirname($image->path); - $imageFileName = basename($image->path); + $imageFolder = dirname($this->getPath($image)); + $imageFileName = basename($this->getPath($image)); $allImages = collect($storage->allFiles($imageFolder)); $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { @@ -222,35 +231,9 @@ class ImageService return $image; } - /** - * Get the storage that will be used for storing images. - * @return FileSystemInstance - */ - private function getStorage() - { - if ($this->storageInstance !== null) return $this->storageInstance; - - $storageType = config('filesystems.default'); - $this->storageInstance = $this->fileSystem->disk($storageType); - - return $this->storageInstance; - } - - /** - * Check whether or not a folder is empty. - * @param $path - * @return int - */ - private function isFolderEmpty($path) - { - $files = $this->getStorage()->files($path); - $folders = $this->getStorage()->directories($path); - return count($files) === 0 && count($folders) === 0; - } - /** * Gets a public facing url for an image by checking relevant environment variables. - * @param $filePath + * @param string $filePath * @return string */ private function getPublicUrl($filePath) @@ -273,6 +256,8 @@ class ImageService $this->storageUrl = $storageUrl; } + if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath); + return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; } diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php new file mode 100644 index 000000000..44d4bb4f7 --- /dev/null +++ b/app/Services/UploadService.php @@ -0,0 +1,64 @@ +fileSystem = $fileSystem; + } + + /** + * Get the storage that will be used for storing images. + * @return FileSystemInstance + */ + protected function getStorage() + { + if ($this->storageInstance !== null) return $this->storageInstance; + + $storageType = config('filesystems.default'); + $this->storageInstance = $this->fileSystem->disk($storageType); + + return $this->storageInstance; + } + + + /** + * Check whether or not a folder is empty. + * @param $path + * @return bool + */ + protected function isFolderEmpty($path) + { + $files = $this->getStorage()->files($path); + $folders = $this->getStorage()->directories($path); + return (count($files) === 0 && count($folders) === 0); + } + + /** + * Check if using a local filesystem. + * @return bool + */ + protected function isLocal() + { + return strtolower(config('filesystems.default')) === 'local'; + } +} \ No newline at end of file diff --git a/config/filesystems.php b/config/filesystems.php index dbcb03db1..836f68d3d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -56,7 +56,7 @@ return [ 'local' => [ 'driver' => 'local', - 'root' => public_path(), + 'root' => base_path(), ], 'ftp' => [ diff --git a/database/migrations/2016_10_09_142037_create_files_table.php b/database/migrations/2016_10_09_142037_create_files_table.php new file mode 100644 index 000000000..4eaa86aeb --- /dev/null +++ b/database/migrations/2016_10_09_142037_create_files_table.php @@ -0,0 +1,42 @@ +increments('id'); + $table->string('name'); + $table->string('path'); + $table->integer('uploaded_to'); + + $table->boolean('external'); + $table->integer('order'); + + $table->integer('created_by'); + $table->integer('updated_by'); + + $table->index('uploaded_to'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('files'); + } +} diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index a64bdfa8c..52477a4ad 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -460,7 +460,7 @@ module.exports = function (ngApp, events) { * Get all tags for the current book and add into scope. */ function getTags() { - let url = window.baseUrl('/ajax/tags/get/page/' + pageId); + let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`); $http.get(url).then((responseData) => { $scope.tags = responseData.data; addEmptyTag(); @@ -529,6 +529,74 @@ module.exports = function (ngApp, events) { }]); + + ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs', + function ($scope, $http, $attrs) { + + const pageId = $scope.uploadedTo = $attrs.pageId; + let currentOrder = ''; + $scope.files = []; + + // Angular-UI-Sort options + $scope.sortOptions = { + handle: '.handle', + items: '> tr', + containment: "parent", + axis: "y", + stop: sortUpdate, + }; + + /** + * Event listener for sort changes. + * Updates the file ordering on the server. + * @param event + * @param ui + */ + function sortUpdate(event, ui) { + let newOrder = $scope.files.map(file => {return file.id}).join(':'); + if (newOrder === currentOrder) return; + + currentOrder = newOrder; + $http.put(`/files/sort/page/${pageId}`, {files: $scope.files}).then(resp => { + events.emit('success', resp.data.message); + }); + } + + /** + * Used by dropzone to get the endpoint to upload to. + * @returns {string} + */ + $scope.getUploadUrl = function () { + return window.baseUrl('/files/upload'); + }; + + /** + * Get files for the current page from the server. + */ + function getFiles() { + let url = window.baseUrl(`/files/get/page/${pageId}`) + $http.get(url).then(responseData => { + $scope.files = responseData.data; + currentOrder = responseData.data.map(file => {return file.id}).join(':'); + }); + } + getFiles(); + + /** + * Runs on file upload, Adds an file to local file list + * and shows a success message to the user. + * @param file + * @param data + */ + $scope.uploadSuccess = function (file, data) { + $scope.$apply(() => { + $scope.files.unshift(data); + }); + events.emit('success', 'File uploaded'); + }; + + }]); + }; diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss index ccb69b44e..7de42d43c 100644 --- a/resources/assets/sass/_components.scss +++ b/resources/assets/sass/_components.scss @@ -43,10 +43,6 @@ } } -//body.ie .popup-body { -// min-height: 100%; -//} - .corner-button { position: absolute; top: 0; @@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { min-height: 70vh; } -#image-manager .dropzone-container { +.dropzone-container { position: relative; border: 3px dashed #DDD; } diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php index a03a208b6..3a344b651 100644 --- a/resources/views/pages/form-toolbox.blade.php +++ b/resources/views/pages/form-toolbox.blade.php @@ -4,6 +4,7 @@
+
@@ -34,4 +35,22 @@
+
+

Attached Files

+
+

Upload some files to display on your page. This are visible in the page sidebar.

+ + + + + + + + + + +
+
+
+ \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 19541734b..7e05af483 100644 --- a/routes/web.php +++ b/routes/web.php @@ -87,6 +87,11 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/{imageId}', 'ImageController@destroy'); }); + // File routes + Route::post('/files/upload', 'FileController@upload'); + Route::get('/files/get/page/{pageId}', 'FileController@getFilesForPage'); + Route::put('/files/sort/page/{pageId}', 'FileController@sortFilesForPage'); + // AJAX routes Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); diff --git a/storage/uploads/files/.gitignore b/storage/uploads/files/.gitignore new file mode 100755 index 000000000..d6b7ef32c --- /dev/null +++ b/storage/uploads/files/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore