Created base models and started user-facing controls.
--- /dev/null
+<?php namespace BookStack\Exceptions;
+
+
+class FileUploadException extends PrettyException {}
\ No newline at end of file
--- /dev/null
+<?php namespace BookStack;
+
+
+class File extends Ownable
+{
+ protected $fillable = ['name', 'order'];
+
+ /**
+ * Get the page this file was uploaded to.
+ * @return mixed
+ */
+ public function page()
+ {
+ return $this->belongsTo(Page::class, 'uploaded_to');
+ }
+
+
+}
--- /dev/null
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Exceptions\FileUploadException;
+use BookStack\File;
+use BookStack\Page;
+use BookStack\Repos\PageRepo;
+use BookStack\Services\FileService;
+use Illuminate\Http\Request;
+
+use BookStack\Http\Requests;
+
+class FileController extends Controller
+{
+ protected $fileService;
+ protected $file;
+ protected $pageRepo;
+
+ /**
+ * FileController constructor.
+ * @param FileService $fileService
+ * @param File $file
+ * @param PageRepo $pageRepo
+ */
+ public function __construct(FileService $fileService, File $file, PageRepo $pageRepo)
+ {
+ $this->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']);
+ }
+
+
+}
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
* Get a page via a specific ID.
* @param $id
* @param bool $allowDrafts
- * @return mixed
+ * @return Page
*/
public function getById($id, $allowDrafts = false)
{
--- /dev/null
+<?php namespace BookStack\Services;
+
+
+use BookStack\Exceptions\FileUploadException;
+use BookStack\File;
+use Exception;
+use Illuminate\Support\Collection;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+class FileService extends UploadService
+{
+
+ /**
+ * Store a new file upon user upload.
+ * @param UploadedFile $uploadedFile
+ * @param int $page_id
+ * @return File
+ * @throws FileUploadException
+ */
+ public function saveNewUpload(UploadedFile $uploadedFile, $page_id)
+ {
+ $fileName = $uploadedFile->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
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;
/**
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
- $this->fileSystem = $fileSystem;
$this->cache = $cache;
+ parent::__construct($fileSystem);
}
/**
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;
}
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,
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.
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);
}
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.');
{
$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) {
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)
$this->storageUrl = $storageUrl;
}
+ if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath);
+
return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath;
}
--- /dev/null
+<?php namespace BookStack\Services;
+
+use Illuminate\Contracts\Filesystem\Factory as FileSystem;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+
+class UploadService
+{
+
+ /**
+ * @var FileSystem
+ */
+ protected $fileSystem;
+
+ /**
+ * @var FileSystemInstance
+ */
+ protected $storageInstance;
+
+
+ /**
+ * FileService constructor.
+ * @param $fileSystem
+ */
+ public function __construct(FileSystem $fileSystem)
+ {
+ $this->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
'local' => [
'driver' => 'local',
- 'root' => public_path(),
+ 'root' => base_path(),
],
'ftp' => [
--- /dev/null
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateFilesTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('files', function (Blueprint $table) {
+ $table->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');
+ }
+}
* 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();
}]);
+
+ 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');
+ };
+
+ }]);
+
};
}
}
-//body.ie .popup-body {
-// min-height: 100%;
-//}
-
.corner-button {
position: absolute;
top: 0;
min-height: 70vh;
}
-#image-manager .dropzone-container {
+.dropzone-container {
position: relative;
border: 3px dashed #DDD;
}
<div class="tabs primary-background-light">
<span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span>
<span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
+ <span tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
</div>
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
</div>
</div>
+ <div tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
+ <h4>Attached Files</h4>
+ <div class="padded files">
+ <p class="muted small">Upload some files to display on your page. This are visible in the page sidebar.</p>
+ <drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
+
+ <table class="no-style" tag-autosuggestions style="width: 100%;">
+ <tbody ui-sortable="sortOptions" ng-model="files" >
+ <tr ng-repeat="file in files track by $index">
+ <td width="20" ><i class="handle zmdi zmdi-menu"></i></td>
+ <td ng-bind="file.name"></td>
+ <td width="10" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
</div>
\ No newline at end of file
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');
--- /dev/null
+*
+!.gitignore