mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-23 12:20:21 +00:00
Merge pull request #205 from ssddanbrown/attachments
Implementation of File Attachments
This commit is contained in:
commit
a6c6c6e300
26 changed files with 1289 additions and 85 deletions
4
app/Exceptions/FileUploadException.php
Normal file
4
app/Exceptions/FileUploadException.php
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?php namespace BookStack\Exceptions;
|
||||
|
||||
|
||||
class FileUploadException extends PrettyException {}
|
36
app/File.php
Normal file
36
app/File.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?php namespace BookStack;
|
||||
|
||||
|
||||
class File extends Ownable
|
||||
{
|
||||
protected $fillable = ['name', 'order'];
|
||||
|
||||
/**
|
||||
* Get the downloadable file name for this upload.
|
||||
* @return mixed|string
|
||||
*/
|
||||
public function getFileName()
|
||||
{
|
||||
if (str_contains($this->name, '.')) return $this->name;
|
||||
return $this->name . '.' . $this->extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the page this file was uploaded to.
|
||||
* @return Page
|
||||
*/
|
||||
public function page()
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'uploaded_to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url of this file.
|
||||
* @return string
|
||||
*/
|
||||
public function getUrl()
|
||||
{
|
||||
return baseUrl('/files/' . $this->id);
|
||||
}
|
||||
|
||||
}
|
|
@ -3,13 +3,11 @@
|
|||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Ownable;
|
||||
use HttpRequestException;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use Illuminate\Http\Exception\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Session;
|
||||
use BookStack\User;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
|
@ -71,8 +69,13 @@ abstract class Controller extends BaseController
|
|||
*/
|
||||
protected function showPermissionError()
|
||||
{
|
||||
Session::flash('error', trans('errors.permission'));
|
||||
$response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/');
|
||||
if (request()->wantsJson()) {
|
||||
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
|
||||
} else {
|
||||
$response = redirect('/');
|
||||
session()->flash('error', trans('errors.permission'));
|
||||
}
|
||||
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
|
@ -83,7 +86,7 @@ abstract class Controller extends BaseController
|
|||
*/
|
||||
protected function checkPermission($permissionName)
|
||||
{
|
||||
if (!$this->currentUser || !$this->currentUser->can($permissionName)) {
|
||||
if (!user() || !user()->can($permissionName)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
return true;
|
||||
|
@ -125,4 +128,22 @@ abstract class Controller extends BaseController
|
|||
return response()->json(['message' => $messageText], $statusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the response for when a request fails validation.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param array $errors
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function buildFailedValidationResponse(Request $request, array $errors)
|
||||
{
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['validation' => $errors], 422);
|
||||
}
|
||||
|
||||
return redirect()->to($this->getRedirectUrl())
|
||||
->withInput($request->input())
|
||||
->withErrors($errors, $this->errorBag());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
214
app/Http/Controllers/FileController.php
Normal file
214
app/Http/Controllers/FileController.php
Normal file
|
@ -0,0 +1,214 @@
|
|||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\File;
|
||||
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)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'file' => 'required|file'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
|
||||
$this->checkPermission('file-create-all');
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
|
||||
try {
|
||||
$file = $this->fileService->saveNewUpload($uploadedFile, $pageId);
|
||||
} catch (FileUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return response()->json($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an uploaded file.
|
||||
* @param int $fileId
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function uploadUpdate($fileId, Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'file' => 'required|file'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$file = $this->file->findOrFail($fileId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('file-create', $file);
|
||||
|
||||
if (intval($pageId) !== intval($file->uploaded_to)) {
|
||||
return $this->jsonError('Page mismatch during attached file update');
|
||||
}
|
||||
|
||||
$uploadedFile = $request->file('file');
|
||||
|
||||
try {
|
||||
$file = $this->fileService->saveUpdatedUpload($uploadedFile, $file);
|
||||
} catch (FileUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return response()->json($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing file.
|
||||
* @param $fileId
|
||||
* @param Request $request
|
||||
* @return File|mixed
|
||||
*/
|
||||
public function update($fileId, Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'url|min:1|max:255'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$file = $this->file->findOrFail($fileId);
|
||||
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('file-create', $file);
|
||||
|
||||
if (intval($pageId) !== intval($file->uploaded_to)) {
|
||||
return $this->jsonError('Page mismatch during attachment update');
|
||||
}
|
||||
|
||||
$file = $this->fileService->updateFile($file, $request->all());
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach a link to a page as a file.
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function attachLink(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'name' => 'required|string|min:1|max:255',
|
||||
'link' => 'required|url|min:1|max:255'
|
||||
]);
|
||||
|
||||
$pageId = $request->get('uploaded_to');
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
|
||||
$this->checkPermission('file-create-all');
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$fileName = $request->get('name');
|
||||
$link = $request->get('link');
|
||||
$file = $this->fileService->saveNewFromLink($fileName, $link, $pageId);
|
||||
|
||||
return response()->json($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the files for a specific page.
|
||||
* @param $pageId
|
||||
* @return mixed
|
||||
*/
|
||||
public function listForPage($pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
return response()->json($page->files);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the file sorting.
|
||||
* @param $pageId
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function sortForPage($pageId, Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'files' => 'required|array',
|
||||
'files.*.id' => 'required|integer',
|
||||
]);
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$files = $request->get('files');
|
||||
$this->fileService->updateFileOrderWithinPage($files, $pageId);
|
||||
return response()->json(['message' => 'Attachment order updated']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file from storage.
|
||||
* @param $fileId
|
||||
*/
|
||||
public function get($fileId)
|
||||
{
|
||||
$file = $this->file->findOrFail($fileId);
|
||||
$page = $this->pageRepo->getById($file->uploaded_to);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
|
||||
if ($file->external) {
|
||||
return redirect($file->path);
|
||||
}
|
||||
|
||||
$fileContents = $this->fileService->getFile($file);
|
||||
return response($fileContents, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="'. $file->getFileName() .'"'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific file in the system.
|
||||
* @param $fileId
|
||||
* @return mixed
|
||||
*/
|
||||
public function delete($fileId)
|
||||
{
|
||||
$file = $this->file->findOrFail($fileId);
|
||||
$this->checkOwnablePermission('file-delete', $file);
|
||||
$this->fileService->deleteFile($file);
|
||||
return response()->json(['message' => 'Attachment deleted']);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -5,6 +5,7 @@ use BookStack\Book;
|
|||
use BookStack\Chapter;
|
||||
use BookStack\Entity;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Services\FileService;
|
||||
use Carbon\Carbon;
|
||||
use DOMDocument;
|
||||
use DOMXPath;
|
||||
|
@ -48,7 +49,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)
|
||||
{
|
||||
|
@ -633,6 +634,13 @@ class PageRepo extends EntityRepo
|
|||
$page->revisions()->delete();
|
||||
$page->permissions()->delete();
|
||||
$this->permissionService->deleteJointPermissionsForEntity($page);
|
||||
|
||||
// Delete AttachedFiles
|
||||
$fileService = app(FileService::class);
|
||||
foreach ($page->files as $file) {
|
||||
$fileService->deleteFile($file);
|
||||
}
|
||||
|
||||
$page->delete();
|
||||
}
|
||||
|
||||
|
|
204
app/Services/FileService.php
Normal file
204
app/Services/FileService.php
Normal file
|
@ -0,0 +1,204 @@
|
|||
<?php namespace BookStack\Services;
|
||||
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\File;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileService extends UploadService
|
||||
{
|
||||
|
||||
/**
|
||||
* Get a file from storage.
|
||||
* @param File $file
|
||||
* @return string
|
||||
*/
|
||||
public function getFile(File $file)
|
||||
{
|
||||
$filePath = $this->getStorageBasePath() . $file->path;
|
||||
return $this->getStorage()->get($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
$filePath = $this->putFileInStorage($fileName, $uploadedFile);
|
||||
$largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order');
|
||||
|
||||
$file = File::forceCreate([
|
||||
'name' => $fileName,
|
||||
'path' => $filePath,
|
||||
'extension' => $uploadedFile->getClientOriginalExtension(),
|
||||
'uploaded_to' => $page_id,
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'order' => $largestExistingOrder + 1
|
||||
]);
|
||||
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a upload, saving to a file and deleting any existing uploads
|
||||
* attached to that file.
|
||||
* @param UploadedFile $uploadedFile
|
||||
* @param File $file
|
||||
* @return File
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function saveUpdatedUpload(UploadedFile $uploadedFile, File $file)
|
||||
{
|
||||
if (!$file->external) {
|
||||
$this->deleteFileInStorage($file);
|
||||
}
|
||||
|
||||
$fileName = $uploadedFile->getClientOriginalName();
|
||||
$filePath = $this->putFileInStorage($fileName, $uploadedFile);
|
||||
|
||||
$file->name = $fileName;
|
||||
$file->path = $filePath;
|
||||
$file->external = false;
|
||||
$file->extension = $uploadedFile->getClientOriginalExtension();
|
||||
$file->save();
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a new File attachment from a given link and name.
|
||||
* @param string $name
|
||||
* @param string $link
|
||||
* @param int $page_id
|
||||
* @return File
|
||||
*/
|
||||
public function saveNewFromLink($name, $link, $page_id)
|
||||
{
|
||||
$largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order');
|
||||
return File::forceCreate([
|
||||
'name' => $name,
|
||||
'path' => $link,
|
||||
'external' => true,
|
||||
'extension' => '',
|
||||
'uploaded_to' => $page_id,
|
||||
'created_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'order' => $largestExistingOrder + 1
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Update the details of a file.
|
||||
* @param File $file
|
||||
* @param $requestData
|
||||
* @return File
|
||||
*/
|
||||
public function updateFile(File $file, $requestData)
|
||||
{
|
||||
$file->name = $requestData['name'];
|
||||
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
|
||||
$file->path = $requestData['link'];
|
||||
if (!$file->external) {
|
||||
$this->deleteFileInStorage($file);
|
||||
$file->external = true;
|
||||
}
|
||||
}
|
||||
$file->save();
|
||||
return $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a File from the database and storage.
|
||||
* @param File $file
|
||||
*/
|
||||
public function deleteFile(File $file)
|
||||
{
|
||||
if ($file->external) {
|
||||
$file->delete();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteFileInStorage($file);
|
||||
$file->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from the filesystem it sits on.
|
||||
* Cleans any empty leftover folders.
|
||||
* @param File $file
|
||||
*/
|
||||
protected function deleteFileInStorage(File $file)
|
||||
{
|
||||
$storedFilePath = $this->getStorageBasePath() . $file->path;
|
||||
$storage = $this->getStorage();
|
||||
$dirPath = dirname($storedFilePath);
|
||||
|
||||
$storage->delete($storedFilePath);
|
||||
if (count($storage->allFiles($dirPath)) === 0) {
|
||||
$storage->deleteDirectory($dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a file in storage with the given filename
|
||||
* @param $fileName
|
||||
* @param UploadedFile $uploadedFile
|
||||
* @return string
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
protected function putFileInStorage($fileName, UploadedFile $uploadedFile)
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
64
app/Services/UploadService.php
Normal file
64
app/Services/UploadService.php
Normal file
|
@ -0,0 +1,64 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
|
@ -56,7 +56,7 @@ return [
|
|||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'root' => base_path(),
|
||||
],
|
||||
|
||||
'ftp' => [
|
||||
|
|
71
database/migrations/2016_10_09_142037_create_files_table.php
Normal file
71
database/migrations/2016_10_09_142037_create_files_table.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?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->string('extension', 20);
|
||||
$table->integer('uploaded_to');
|
||||
|
||||
$table->boolean('external');
|
||||
$table->integer('order');
|
||||
|
||||
$table->integer('created_by');
|
||||
$table->integer('updated_by');
|
||||
|
||||
$table->index('uploaded_to');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Get roles with permissions we need to change
|
||||
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
|
||||
|
||||
// Create & attach new entity permissions
|
||||
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
|
||||
$entity = 'File';
|
||||
foreach ($ops as $op) {
|
||||
$permissionId = DB::table('role_permissions')->insertGetId([
|
||||
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
|
||||
'display_name' => $op . ' ' . $entity . 's',
|
||||
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
|
||||
'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
|
||||
]);
|
||||
DB::table('permission_role')->insert([
|
||||
'role_id' => $adminRoleId,
|
||||
'permission_id' => $permissionId
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::dropIfExists('files');
|
||||
|
||||
// Create & attach new entity permissions
|
||||
$ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
|
||||
$entity = 'File';
|
||||
foreach ($ops as $op) {
|
||||
$permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op));
|
||||
DB::table('role_permissions')->where('name', '=', $permName)->delete();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,205 @@ 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 = [];
|
||||
$scope.editFile = false;
|
||||
$scope.file = getCleanFile();
|
||||
$scope.errors = {
|
||||
link: {},
|
||||
edit: {}
|
||||
};
|
||||
|
||||
function getCleanFile() {
|
||||
return {
|
||||
page_id: pageId
|
||||
};
|
||||
}
|
||||
|
||||
// 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);
|
||||
}, checkError('sort'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by dropzone to get the endpoint to upload to.
|
||||
* @returns {string}
|
||||
*/
|
||||
$scope.getUploadUrl = function (file) {
|
||||
let suffix = (typeof file !== 'undefined') ? `/${file.id}` : '';
|
||||
return window.baseUrl(`/files/upload${suffix}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get files for the current page from the server.
|
||||
*/
|
||||
function getFiles() {
|
||||
let url = window.baseUrl(`/files/get/page/${pageId}`)
|
||||
$http.get(url).then(resp => {
|
||||
$scope.files = resp.data;
|
||||
currentOrder = resp.data.map(file => {return file.id}).join(':');
|
||||
}, checkError('get'));
|
||||
}
|
||||
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.push(data);
|
||||
});
|
||||
events.emit('success', 'File uploaded');
|
||||
};
|
||||
|
||||
/**
|
||||
* Upload and overwrite an existing file.
|
||||
* @param file
|
||||
* @param data
|
||||
*/
|
||||
$scope.uploadSuccessUpdate = function (file, data) {
|
||||
$scope.$apply(() => {
|
||||
let search = filesIndexOf(data);
|
||||
if (search !== -1) $scope.files[search] = data;
|
||||
|
||||
if ($scope.editFile) {
|
||||
$scope.editFile = angular.copy(data);
|
||||
data.link = '';
|
||||
}
|
||||
});
|
||||
events.emit('success', 'File updated');
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a file from the server and, on success, the local listing.
|
||||
* @param file
|
||||
*/
|
||||
$scope.deleteFile = function(file) {
|
||||
if (!file.deleting) {
|
||||
file.deleting = true;
|
||||
return;
|
||||
}
|
||||
$http.delete(`/files/${file.id}`).then(resp => {
|
||||
events.emit('success', resp.data.message);
|
||||
$scope.files.splice($scope.files.indexOf(file), 1);
|
||||
}, checkError('delete'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach a link to a page.
|
||||
* @param fileName
|
||||
* @param fileLink
|
||||
*/
|
||||
$scope.attachLinkSubmit = function(file) {
|
||||
file.uploaded_to = pageId;
|
||||
$http.post('/files/link', file).then(resp => {
|
||||
$scope.files.push(resp.data);
|
||||
events.emit('success', 'Link attached');
|
||||
$scope.file = getCleanFile();
|
||||
}, checkError('link'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the edit mode for a file.
|
||||
* @param fileId
|
||||
*/
|
||||
$scope.startEdit = function(file) {
|
||||
console.log(file);
|
||||
$scope.editFile = angular.copy(file);
|
||||
$scope.editFile.link = (file.external) ? file.path : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel edit mode
|
||||
*/
|
||||
$scope.cancelEdit = function() {
|
||||
$scope.editFile = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the name and link of a file.
|
||||
* @param file
|
||||
*/
|
||||
$scope.updateFile = function(file) {
|
||||
$http.put(`/files/${file.id}`, file).then(resp => {
|
||||
let search = filesIndexOf(resp.data);
|
||||
if (search !== -1) $scope.files[search] = resp.data;
|
||||
|
||||
if ($scope.editFile && !file.external) {
|
||||
$scope.editFile.link = '';
|
||||
}
|
||||
$scope.editFile = false;
|
||||
events.emit('success', 'Attachment details updated');
|
||||
}, checkError('edit'));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the url of a file.
|
||||
*/
|
||||
$scope.getFileUrl = function(file) {
|
||||
return window.baseUrl('/files/' + file.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the local files via another file object.
|
||||
* Used to search via object copies.
|
||||
* @param file
|
||||
* @returns int
|
||||
*/
|
||||
function filesIndexOf(file) {
|
||||
for (let i = 0; i < $scope.files.length; i++) {
|
||||
if ($scope.files[i].id == file.id) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an error response in a ajax request.
|
||||
* @param response
|
||||
*/
|
||||
function checkError(errorGroupName) {
|
||||
$scope.errors[errorGroupName] = {};
|
||||
return function(response) {
|
||||
if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') {
|
||||
events.emit('error', response.data.error);
|
||||
}
|
||||
if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') {
|
||||
$scope.errors[errorGroupName] = response.data.validation;
|
||||
console.log($scope.errors[errorGroupName])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}]);
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -33,6 +33,59 @@ module.exports = function (ngApp, events) {
|
|||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Common tab controls using simple jQuery functions.
|
||||
*/
|
||||
ngApp.directive('tabContainer', function() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope, element, attrs) {
|
||||
const $content = element.find('[tab-content]');
|
||||
const $buttons = element.find('[tab-button]');
|
||||
|
||||
if (attrs.tabContainer) {
|
||||
let initial = attrs.tabContainer;
|
||||
$buttons.filter(`[tab-button="${initial}"]`).addClass('selected');
|
||||
$content.hide().filter(`[tab-content="${initial}"]`).show();
|
||||
} else {
|
||||
$content.hide().first().show();
|
||||
$buttons.first().addClass('selected');
|
||||
}
|
||||
|
||||
$buttons.click(function() {
|
||||
let clickedTab = $(this);
|
||||
$buttons.removeClass('selected');
|
||||
$content.hide();
|
||||
let name = clickedTab.addClass('selected').attr('tab-button');
|
||||
$content.filter(`[tab-content="${name}"]`).show();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Sub form component to allow inner-form sections to act like thier own forms.
|
||||
*/
|
||||
ngApp.directive('subForm', function() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope, element, attrs) {
|
||||
element.on('keypress', e => {
|
||||
if (e.keyCode === 13) {
|
||||
submitEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
element.find('button[type="submit"]').click(submitEvent);
|
||||
|
||||
function submitEvent(e) {
|
||||
e.preventDefault()
|
||||
if (attrs.subForm) scope.$eval(attrs.subForm);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Image Picker
|
||||
|
@ -116,6 +169,7 @@ module.exports = function (ngApp, events) {
|
|||
uploadedTo: '@'
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder;
|
||||
var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), {
|
||||
url: scope.uploadUrl,
|
||||
init: function () {
|
||||
|
@ -488,8 +542,8 @@ module.exports = function (ngApp, events) {
|
|||
link: function (scope, elem, attrs) {
|
||||
|
||||
// Get common elements
|
||||
const $buttons = elem.find('[tab-button]');
|
||||
const $content = elem.find('[tab-content]');
|
||||
const $buttons = elem.find('[toolbox-tab-button]');
|
||||
const $content = elem.find('[toolbox-tab-content]');
|
||||
const $toggle = elem.find('[toolbox-toggle]');
|
||||
|
||||
// Handle toolbox toggle click
|
||||
|
@ -501,17 +555,17 @@ module.exports = function (ngApp, events) {
|
|||
function setActive(tabName, openToolbox) {
|
||||
$buttons.removeClass('active');
|
||||
$content.hide();
|
||||
$buttons.filter(`[tab-button="${tabName}"]`).addClass('active');
|
||||
$content.filter(`[tab-content="${tabName}"]`).show();
|
||||
$buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active');
|
||||
$content.filter(`[toolbox-tab-content="${tabName}"]`).show();
|
||||
if (openToolbox) elem.addClass('open');
|
||||
}
|
||||
|
||||
// Set the first tab content active on load
|
||||
setActive($content.first().attr('tab-content'), false);
|
||||
setActive($content.first().attr('toolbox-tab-content'), false);
|
||||
|
||||
// Handle tab button click
|
||||
$buttons.click(function (e) {
|
||||
let name = $(this).attr('tab-button');
|
||||
let name = $(this).attr('toolbox-tab-button');
|
||||
setActive(name, true);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
|
|||
border-right: 6px solid transparent;
|
||||
border-bottom: 6px solid $negative;
|
||||
}
|
||||
|
||||
|
||||
[tab-container] .nav-tabs {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #DDD;
|
||||
margin-bottom: $-m;
|
||||
.tab-item {
|
||||
padding: $-s;
|
||||
color: #666;
|
||||
&.selected {
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -150,7 +150,6 @@
|
|||
background-color: #FFF;
|
||||
border: 1px solid #DDD;
|
||||
right: $-xl*2;
|
||||
z-index: 99;
|
||||
width: 48px;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
|
@ -201,7 +200,7 @@
|
|||
color: #444;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
div[tab-content] {
|
||||
div[toolbox-tab-content] {
|
||||
padding-bottom: 45px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
@ -209,7 +208,7 @@
|
|||
min-height: 0px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
div[tab-content] .padded {
|
||||
div[toolbox-tab-content] .padded {
|
||||
flex: 1;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
@ -228,21 +227,6 @@
|
|||
padding-top: $-s;
|
||||
position: relative;
|
||||
}
|
||||
button.pos {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $-s;
|
||||
height: 45px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
&:hover{
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
.handle {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
|
@ -256,7 +240,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
[tab-content] {
|
||||
[toolbox-tab-content] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -51,4 +51,14 @@ table.list-table {
|
|||
vertical-align: middle;
|
||||
padding: $-xs;
|
||||
}
|
||||
}
|
||||
|
||||
table.file-table {
|
||||
@extend .no-style;
|
||||
td {
|
||||
padding: $-xs;
|
||||
}
|
||||
.ui-sortable-helper {
|
||||
display: table;
|
||||
}
|
||||
}
|
|
@ -3,10 +3,13 @@
|
|||
|
||||
<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 toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span>
|
||||
@if(userCan('file-create-all'))
|
||||
<span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
|
||||
<div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}">
|
||||
<h4>Page Tags</h4>
|
||||
<div class="padded tags">
|
||||
<p class="muted small">Add some tags to better categorise your content. <br> You can assign a value to a tag for more in-depth organisation.</p>
|
||||
|
@ -34,4 +37,98 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
@if(userCan('file-create-all'))
|
||||
<div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}">
|
||||
<h4>Attachments</h4>
|
||||
<div class="padded files">
|
||||
|
||||
<div id="file-list" ng-show="!editFile">
|
||||
<p class="muted small">Upload some files or attach some link to display on your page. This are visible in the page sidebar.</p>
|
||||
|
||||
<div tab-container>
|
||||
<div class="nav-tabs">
|
||||
<div tab-button="list" class="tab-item">File List</div>
|
||||
<div tab-button="file" class="tab-item">Upload File</div>
|
||||
<div tab-button="link" class="tab-item">Attach Link</div>
|
||||
</div>
|
||||
<div tab-content="list">
|
||||
<table class="file-table" 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>
|
||||
<a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a>
|
||||
<div ng-if="file.deleting">
|
||||
<span class="neg small">Click delete again to confirm you want to delete this attachment.</span>
|
||||
<br>
|
||||
<span class="text-primary small" ng-click="file.deleting=false;">Cancel</span>
|
||||
</div>
|
||||
</td>
|
||||
<td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td>
|
||||
<td width="5"></td>
|
||||
<td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="small muted" ng-if="files.length == 0">
|
||||
No files have been uploaded.
|
||||
</p>
|
||||
</div>
|
||||
<div tab-content="file">
|
||||
<drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone>
|
||||
</div>
|
||||
<div tab-content="link" sub-form="attachLinkSubmit(file)">
|
||||
<p class="muted small">You can attach a link if you'd prefer not to upload a file. This can be a link to another page or a link to a file in the cloud.</p>
|
||||
<div class="form-group">
|
||||
<label for="attachment-via-link">Link Name</label>
|
||||
<input type="text" placeholder="Link name" ng-model="file.name">
|
||||
<p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="attachment-via-link">Link to file</label>
|
||||
<input type="text" placeholder="Url of site or file" ng-model="file.link">
|
||||
<p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p>
|
||||
</div>
|
||||
<button type="submit" class="button pos">Attach</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)">
|
||||
<h5>Edit File</h5>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="attachment-name-edit">File Name</label>
|
||||
<input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name">
|
||||
<p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p>
|
||||
</div>
|
||||
|
||||
<div tab-container="@{{ editFile.external ? 'link' : 'file' }}">
|
||||
<div class="nav-tabs">
|
||||
<div tab-button="file" class="tab-item">Upload File</div>
|
||||
<div tab-button="link" class="tab-item">Set Link</div>
|
||||
</div>
|
||||
<div tab-content="file">
|
||||
<drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone>
|
||||
<br>
|
||||
</div>
|
||||
<div tab-content="link">
|
||||
<div class="form-group">
|
||||
<label for="attachment-link-edit">Link to file</label>
|
||||
<input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link">
|
||||
<p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button" ng-click="cancelEdit()">Back</button>
|
||||
<button type="submit" class="button pos">Save</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
|
@ -1,6 +1,15 @@
|
|||
|
||||
<div class="book-tree" ng-non-bindable>
|
||||
|
||||
@if (isset($page) && $page->files->count() > 0)
|
||||
<h6 class="text-muted">Attachments</h6>
|
||||
@foreach($page->files as $file)
|
||||
<div class="attachment">
|
||||
<a href="{{ $file->getUrl() }}" @if($file->external) target="_blank" @endif><i class="zmdi zmdi-{{ $file->external ? 'open-in-new' : 'file' }}"></i> {{ $file->name }}</a>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if (isset($pageNav) && $pageNav)
|
||||
<h6 class="text-muted">Page Navigation</h6>
|
||||
<div class="sidebar-page-nav menu">
|
||||
|
@ -10,8 +19,6 @@
|
|||
</li>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
|
||||
@endif
|
||||
|
||||
<h6 class="text-muted">Book Navigation</h6>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
.nav-tabs a.selected, .nav-tabs .tab-item.selected {
|
||||
border-bottom-color: {{ setting('app-color') }};
|
||||
}
|
||||
p.primary:hover, p .primary:hover, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
|
||||
.text-primary, p.primary, p .primary, span.primary:hover, .text-primary:hover, a, a:hover, a:focus, .text-button, .text-button:hover, .text-button:focus {
|
||||
color: {{ setting('app-color') }};
|
||||
}
|
||||
</style>
|
|
@ -106,6 +106,19 @@
|
|||
<label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Attached <br>Files</td>
|
||||
<td>@include('settings/roles/checkbox', ['permission' => 'file-create-all'])</td>
|
||||
<td style="line-height:1.2;"><small class="faded">Controlled by the asset they are uploaded to</small></td>
|
||||
<td>
|
||||
<label>@include('settings/roles/checkbox', ['permission' => 'file-update-own']) Own</label>
|
||||
<label>@include('settings/roles/checkbox', ['permission' => 'file-update-all']) All</label>
|
||||
</td>
|
||||
<td>
|
||||
<label>@include('settings/roles/checkbox', ['permission' => 'file-delete-own']) Own</label>
|
||||
<label>@include('settings/roles/checkbox', ['permission' => 'file-delete-all']) All</label>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -87,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () {
|
|||
Route::delete('/{imageId}', 'ImageController@destroy');
|
||||
});
|
||||
|
||||
// File routes
|
||||
Route::get('/files/{id}', 'FileController@get');
|
||||
Route::post('/files/upload', 'FileController@upload');
|
||||
Route::post('/files/upload/{id}', 'FileController@uploadUpdate');
|
||||
Route::post('/files/link', 'FileController@attachLink');
|
||||
Route::put('/files/{id}', 'FileController@update');
|
||||
Route::get('/files/get/page/{pageId}', 'FileController@listForPage');
|
||||
Route::put('/files/sort/page/{pageId}', 'FileController@sortForPage');
|
||||
Route::delete('/files/{id}', 'FileController@delete');
|
||||
|
||||
// AJAX routes
|
||||
Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft');
|
||||
Route::get('/ajax/page/{id}', 'PageController@getPageAjax');
|
||||
|
|
2
storage/uploads/files/.gitignore
vendored
Executable file
2
storage/uploads/files/.gitignore
vendored
Executable file
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
201
tests/AttachmentTest.php
Normal file
201
tests/AttachmentTest.php
Normal file
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
class AttachmentTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Get a test file that can be uploaded
|
||||
* @param $fileName
|
||||
* @return \Illuminate\Http\UploadedFile
|
||||
*/
|
||||
protected function getTestFile($fileName)
|
||||
{
|
||||
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file with the given name.
|
||||
* @param $name
|
||||
* @param int $uploadedTo
|
||||
* @return string
|
||||
*/
|
||||
protected function uploadFile($name, $uploadedTo = 0)
|
||||
{
|
||||
$file = $this->getTestFile($name);
|
||||
return $this->call('POST', '/files/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected upload path for a file.
|
||||
* @param $fileName
|
||||
* @return string
|
||||
*/
|
||||
protected function getUploadPath($fileName)
|
||||
{
|
||||
return 'uploads/files/' . Date('Y-m-M') . '/' . $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all uploaded files.
|
||||
* To assist with cleanup.
|
||||
*/
|
||||
protected function deleteUploads()
|
||||
{
|
||||
$fileService = $this->app->make(\BookStack\Services\FileService::class);
|
||||
foreach (\BookStack\File::all() as $file) {
|
||||
$fileService->deleteFile($file);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_file_upload()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$admin = $this->getAdmin();
|
||||
$fileName = 'upload_test_file.txt';
|
||||
|
||||
$expectedResp = [
|
||||
'name' => $fileName,
|
||||
'uploaded_to'=> $page->id,
|
||||
'extension' => 'txt',
|
||||
'order' => 1,
|
||||
'created_by' => $admin->id,
|
||||
'updated_by' => $admin->id,
|
||||
'path' => $this->getUploadPath($fileName)
|
||||
];
|
||||
|
||||
$this->uploadFile($fileName, $page->id);
|
||||
$this->assertResponseOk();
|
||||
$this->seeJsonContains($expectedResp);
|
||||
$this->seeInDatabase('files', $expectedResp);
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_file_display_and_access()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$admin = $this->getAdmin();
|
||||
$fileName = 'upload_test_file.txt';
|
||||
|
||||
$this->uploadFile($fileName, $page->id);
|
||||
$this->assertResponseOk();
|
||||
$this->visit($page->getUrl())
|
||||
->seeLink($fileName)
|
||||
->click($fileName)
|
||||
->see('Hi, This is a test file for testing the upload process.');
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_attaching_link_to_page()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$admin = $this->getAdmin();
|
||||
$this->asAdmin();
|
||||
|
||||
$this->call('POST', 'files/link', [
|
||||
'link' => 'https://example.com',
|
||||
'name' => 'Example Attachment Link',
|
||||
'uploaded_to' => $page->id,
|
||||
]);
|
||||
|
||||
$expectedResp = [
|
||||
'path' => 'https://example.com',
|
||||
'name' => 'Example Attachment Link',
|
||||
'uploaded_to' => $page->id,
|
||||
'created_by' => $admin->id,
|
||||
'updated_by' => $admin->id,
|
||||
'external' => true,
|
||||
'order' => 1,
|
||||
'extension' => ''
|
||||
];
|
||||
|
||||
$this->assertResponseOk();
|
||||
$this->seeJsonContains($expectedResp);
|
||||
$this->seeInDatabase('files', $expectedResp);
|
||||
|
||||
$this->visit($page->getUrl())->seeLink('Example Attachment Link')
|
||||
->click('Example Attachment Link')->seePageIs('https://example.com');
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_attachment_updating()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
|
||||
$this->call('POST', 'files/link', [
|
||||
'link' => 'https://example.com',
|
||||
'name' => 'Example Attachment Link',
|
||||
'uploaded_to' => $page->id,
|
||||
]);
|
||||
|
||||
$attachmentId = \BookStack\File::first()->id;
|
||||
|
||||
$this->call('PUT', 'files/' . $attachmentId, [
|
||||
'uploaded_to' => $page->id,
|
||||
'name' => 'My new attachment name',
|
||||
'link' => 'https://test.example.com'
|
||||
]);
|
||||
|
||||
$expectedResp = [
|
||||
'path' => 'https://test.example.com',
|
||||
'name' => 'My new attachment name',
|
||||
'uploaded_to' => $page->id
|
||||
];
|
||||
|
||||
$this->assertResponseOk();
|
||||
$this->seeJsonContains($expectedResp);
|
||||
$this->seeInDatabase('files', $expectedResp);
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_file_deletion()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$fileName = 'deletion_test.txt';
|
||||
$this->uploadFile($fileName, $page->id);
|
||||
|
||||
$filePath = base_path('storage/' . $this->getUploadPath($fileName));
|
||||
|
||||
$this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
|
||||
|
||||
$attachmentId = \BookStack\File::first()->id;
|
||||
$this->call('DELETE', 'files/' . $attachmentId);
|
||||
|
||||
$this->dontSeeInDatabase('files', [
|
||||
'name' => $fileName
|
||||
]);
|
||||
$this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_attachment_deletion_on_page_deletion()
|
||||
{
|
||||
$page = \BookStack\Page::first();
|
||||
$this->asAdmin();
|
||||
$fileName = 'deletion_test.txt';
|
||||
$this->uploadFile($fileName, $page->id);
|
||||
|
||||
$filePath = base_path('storage/' . $this->getUploadPath($fileName));
|
||||
|
||||
$this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist');
|
||||
$this->seeInDatabase('files', [
|
||||
'name' => $fileName
|
||||
]);
|
||||
|
||||
$this->call('DELETE', $page->getUrl());
|
||||
|
||||
$this->dontSeeInDatabase('files', [
|
||||
'name' => $fileName
|
||||
]);
|
||||
$this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected');
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ class ImageTest extends TestCase
|
|||
*/
|
||||
protected function getTestImage($fileName)
|
||||
{
|
||||
return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238);
|
||||
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
1
tests/test-data/test-file.txt
Normal file
1
tests/test-data/test-file.txt
Normal file
|
@ -0,0 +1 @@
|
|||
Hi, This is a test file for testing the upload process.
|
Before ![]() (image error) Size: 5.1 KiB After ![]() (image error) Size: 5.1 KiB ![]() ![]() |
Loading…
Add table
Reference in a new issue