Committed by
GitHub
Merge pull request #205 from ssddanbrown/attachments
Implementation of File Attachments
Showing
26 changed files
with
1289 additions
and
85 deletions
app/Exceptions/FileUploadException.php
0 → 100644
app/File.php
0 → 100644
| 1 | +<?php namespace BookStack; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +class File extends Ownable | ||
| 5 | +{ | ||
| 6 | + protected $fillable = ['name', 'order']; | ||
| 7 | + | ||
| 8 | + /** | ||
| 9 | + * Get the downloadable file name for this upload. | ||
| 10 | + * @return mixed|string | ||
| 11 | + */ | ||
| 12 | + public function getFileName() | ||
| 13 | + { | ||
| 14 | + if (str_contains($this->name, '.')) return $this->name; | ||
| 15 | + return $this->name . '.' . $this->extension; | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | + /** | ||
| 19 | + * Get the page this file was uploaded to. | ||
| 20 | + * @return Page | ||
| 21 | + */ | ||
| 22 | + public function page() | ||
| 23 | + { | ||
| 24 | + return $this->belongsTo(Page::class, 'uploaded_to'); | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + /** | ||
| 28 | + * Get the url of this file. | ||
| 29 | + * @return string | ||
| 30 | + */ | ||
| 31 | + public function getUrl() | ||
| 32 | + { | ||
| 33 | + return baseUrl('/files/' . $this->id); | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | +} |
| ... | @@ -3,13 +3,11 @@ | ... | @@ -3,13 +3,11 @@ |
| 3 | namespace BookStack\Http\Controllers; | 3 | namespace BookStack\Http\Controllers; |
| 4 | 4 | ||
| 5 | use BookStack\Ownable; | 5 | use BookStack\Ownable; |
| 6 | -use HttpRequestException; | ||
| 7 | use Illuminate\Foundation\Bus\DispatchesJobs; | 6 | use Illuminate\Foundation\Bus\DispatchesJobs; |
| 8 | use Illuminate\Http\Exception\HttpResponseException; | 7 | use Illuminate\Http\Exception\HttpResponseException; |
| 8 | +use Illuminate\Http\Request; | ||
| 9 | use Illuminate\Routing\Controller as BaseController; | 9 | use Illuminate\Routing\Controller as BaseController; |
| 10 | use Illuminate\Foundation\Validation\ValidatesRequests; | 10 | use Illuminate\Foundation\Validation\ValidatesRequests; |
| 11 | -use Illuminate\Support\Facades\Auth; | ||
| 12 | -use Illuminate\Support\Facades\Session; | ||
| 13 | use BookStack\User; | 11 | use BookStack\User; |
| 14 | 12 | ||
| 15 | abstract class Controller extends BaseController | 13 | abstract class Controller extends BaseController |
| ... | @@ -71,8 +69,13 @@ abstract class Controller extends BaseController | ... | @@ -71,8 +69,13 @@ abstract class Controller extends BaseController |
| 71 | */ | 69 | */ |
| 72 | protected function showPermissionError() | 70 | protected function showPermissionError() |
| 73 | { | 71 | { |
| 74 | - Session::flash('error', trans('errors.permission')); | 72 | + if (request()->wantsJson()) { |
| 75 | - $response = request()->wantsJson() ? response()->json(['error' => trans('errors.permissionJson')], 403) : redirect('/'); | 73 | + $response = response()->json(['error' => trans('errors.permissionJson')], 403); |
| 74 | + } else { | ||
| 75 | + $response = redirect('/'); | ||
| 76 | + session()->flash('error', trans('errors.permission')); | ||
| 77 | + } | ||
| 78 | + | ||
| 76 | throw new HttpResponseException($response); | 79 | throw new HttpResponseException($response); |
| 77 | } | 80 | } |
| 78 | 81 | ||
| ... | @@ -83,7 +86,7 @@ abstract class Controller extends BaseController | ... | @@ -83,7 +86,7 @@ abstract class Controller extends BaseController |
| 83 | */ | 86 | */ |
| 84 | protected function checkPermission($permissionName) | 87 | protected function checkPermission($permissionName) |
| 85 | { | 88 | { |
| 86 | - if (!$this->currentUser || !$this->currentUser->can($permissionName)) { | 89 | + if (!user() || !user()->can($permissionName)) { |
| 87 | $this->showPermissionError(); | 90 | $this->showPermissionError(); |
| 88 | } | 91 | } |
| 89 | return true; | 92 | return true; |
| ... | @@ -125,4 +128,22 @@ abstract class Controller extends BaseController | ... | @@ -125,4 +128,22 @@ abstract class Controller extends BaseController |
| 125 | return response()->json(['message' => $messageText], $statusCode); | 128 | return response()->json(['message' => $messageText], $statusCode); |
| 126 | } | 129 | } |
| 127 | 130 | ||
| 131 | + /** | ||
| 132 | + * Create the response for when a request fails validation. | ||
| 133 | + * | ||
| 134 | + * @param \Illuminate\Http\Request $request | ||
| 135 | + * @param array $errors | ||
| 136 | + * @return \Symfony\Component\HttpFoundation\Response | ||
| 137 | + */ | ||
| 138 | + protected function buildFailedValidationResponse(Request $request, array $errors) | ||
| 139 | + { | ||
| 140 | + if ($request->expectsJson()) { | ||
| 141 | + return response()->json(['validation' => $errors], 422); | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + return redirect()->to($this->getRedirectUrl()) | ||
| 145 | + ->withInput($request->input()) | ||
| 146 | + ->withErrors($errors, $this->errorBag()); | ||
| 147 | + } | ||
| 148 | + | ||
| 128 | } | 149 | } | ... | ... |
app/Http/Controllers/FileController.php
0 → 100644
| 1 | +<?php namespace BookStack\Http\Controllers; | ||
| 2 | + | ||
| 3 | +use BookStack\Exceptions\FileUploadException; | ||
| 4 | +use BookStack\File; | ||
| 5 | +use BookStack\Repos\PageRepo; | ||
| 6 | +use BookStack\Services\FileService; | ||
| 7 | +use Illuminate\Http\Request; | ||
| 8 | + | ||
| 9 | +use BookStack\Http\Requests; | ||
| 10 | + | ||
| 11 | +class FileController extends Controller | ||
| 12 | +{ | ||
| 13 | + protected $fileService; | ||
| 14 | + protected $file; | ||
| 15 | + protected $pageRepo; | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * FileController constructor. | ||
| 19 | + * @param FileService $fileService | ||
| 20 | + * @param File $file | ||
| 21 | + * @param PageRepo $pageRepo | ||
| 22 | + */ | ||
| 23 | + public function __construct(FileService $fileService, File $file, PageRepo $pageRepo) | ||
| 24 | + { | ||
| 25 | + $this->fileService = $fileService; | ||
| 26 | + $this->file = $file; | ||
| 27 | + $this->pageRepo = $pageRepo; | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + | ||
| 31 | + /** | ||
| 32 | + * Endpoint at which files are uploaded to. | ||
| 33 | + * @param Request $request | ||
| 34 | + */ | ||
| 35 | + public function upload(Request $request) | ||
| 36 | + { | ||
| 37 | + $this->validate($request, [ | ||
| 38 | + 'uploaded_to' => 'required|integer|exists:pages,id', | ||
| 39 | + 'file' => 'required|file' | ||
| 40 | + ]); | ||
| 41 | + | ||
| 42 | + $pageId = $request->get('uploaded_to'); | ||
| 43 | + $page = $this->pageRepo->getById($pageId); | ||
| 44 | + | ||
| 45 | + $this->checkPermission('file-create-all'); | ||
| 46 | + $this->checkOwnablePermission('page-update', $page); | ||
| 47 | + | ||
| 48 | + $uploadedFile = $request->file('file'); | ||
| 49 | + | ||
| 50 | + try { | ||
| 51 | + $file = $this->fileService->saveNewUpload($uploadedFile, $pageId); | ||
| 52 | + } catch (FileUploadException $e) { | ||
| 53 | + return response($e->getMessage(), 500); | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + return response()->json($file); | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + /** | ||
| 60 | + * Update an uploaded file. | ||
| 61 | + * @param int $fileId | ||
| 62 | + * @param Request $request | ||
| 63 | + * @return mixed | ||
| 64 | + */ | ||
| 65 | + public function uploadUpdate($fileId, Request $request) | ||
| 66 | + { | ||
| 67 | + $this->validate($request, [ | ||
| 68 | + 'uploaded_to' => 'required|integer|exists:pages,id', | ||
| 69 | + 'file' => 'required|file' | ||
| 70 | + ]); | ||
| 71 | + | ||
| 72 | + $pageId = $request->get('uploaded_to'); | ||
| 73 | + $page = $this->pageRepo->getById($pageId); | ||
| 74 | + $file = $this->file->findOrFail($fileId); | ||
| 75 | + | ||
| 76 | + $this->checkOwnablePermission('page-update', $page); | ||
| 77 | + $this->checkOwnablePermission('file-create', $file); | ||
| 78 | + | ||
| 79 | + if (intval($pageId) !== intval($file->uploaded_to)) { | ||
| 80 | + return $this->jsonError('Page mismatch during attached file update'); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + $uploadedFile = $request->file('file'); | ||
| 84 | + | ||
| 85 | + try { | ||
| 86 | + $file = $this->fileService->saveUpdatedUpload($uploadedFile, $file); | ||
| 87 | + } catch (FileUploadException $e) { | ||
| 88 | + return response($e->getMessage(), 500); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + return response()->json($file); | ||
| 92 | + } | ||
| 93 | + | ||
| 94 | + /** | ||
| 95 | + * Update the details of an existing file. | ||
| 96 | + * @param $fileId | ||
| 97 | + * @param Request $request | ||
| 98 | + * @return File|mixed | ||
| 99 | + */ | ||
| 100 | + public function update($fileId, Request $request) | ||
| 101 | + { | ||
| 102 | + $this->validate($request, [ | ||
| 103 | + 'uploaded_to' => 'required|integer|exists:pages,id', | ||
| 104 | + 'name' => 'required|string|min:1|max:255', | ||
| 105 | + 'link' => 'url|min:1|max:255' | ||
| 106 | + ]); | ||
| 107 | + | ||
| 108 | + $pageId = $request->get('uploaded_to'); | ||
| 109 | + $page = $this->pageRepo->getById($pageId); | ||
| 110 | + $file = $this->file->findOrFail($fileId); | ||
| 111 | + | ||
| 112 | + $this->checkOwnablePermission('page-update', $page); | ||
| 113 | + $this->checkOwnablePermission('file-create', $file); | ||
| 114 | + | ||
| 115 | + if (intval($pageId) !== intval($file->uploaded_to)) { | ||
| 116 | + return $this->jsonError('Page mismatch during attachment update'); | ||
| 117 | + } | ||
| 118 | + | ||
| 119 | + $file = $this->fileService->updateFile($file, $request->all()); | ||
| 120 | + return $file; | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + /** | ||
| 124 | + * Attach a link to a page as a file. | ||
| 125 | + * @param Request $request | ||
| 126 | + * @return mixed | ||
| 127 | + */ | ||
| 128 | + public function attachLink(Request $request) | ||
| 129 | + { | ||
| 130 | + $this->validate($request, [ | ||
| 131 | + 'uploaded_to' => 'required|integer|exists:pages,id', | ||
| 132 | + 'name' => 'required|string|min:1|max:255', | ||
| 133 | + 'link' => 'required|url|min:1|max:255' | ||
| 134 | + ]); | ||
| 135 | + | ||
| 136 | + $pageId = $request->get('uploaded_to'); | ||
| 137 | + $page = $this->pageRepo->getById($pageId); | ||
| 138 | + | ||
| 139 | + $this->checkPermission('file-create-all'); | ||
| 140 | + $this->checkOwnablePermission('page-update', $page); | ||
| 141 | + | ||
| 142 | + $fileName = $request->get('name'); | ||
| 143 | + $link = $request->get('link'); | ||
| 144 | + $file = $this->fileService->saveNewFromLink($fileName, $link, $pageId); | ||
| 145 | + | ||
| 146 | + return response()->json($file); | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + /** | ||
| 150 | + * Get the files for a specific page. | ||
| 151 | + * @param $pageId | ||
| 152 | + * @return mixed | ||
| 153 | + */ | ||
| 154 | + public function listForPage($pageId) | ||
| 155 | + { | ||
| 156 | + $page = $this->pageRepo->getById($pageId); | ||
| 157 | + $this->checkOwnablePermission('page-view', $page); | ||
| 158 | + return response()->json($page->files); | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + /** | ||
| 162 | + * Update the file sorting. | ||
| 163 | + * @param $pageId | ||
| 164 | + * @param Request $request | ||
| 165 | + * @return mixed | ||
| 166 | + */ | ||
| 167 | + public function sortForPage($pageId, Request $request) | ||
| 168 | + { | ||
| 169 | + $this->validate($request, [ | ||
| 170 | + 'files' => 'required|array', | ||
| 171 | + 'files.*.id' => 'required|integer', | ||
| 172 | + ]); | ||
| 173 | + $page = $this->pageRepo->getById($pageId); | ||
| 174 | + $this->checkOwnablePermission('page-update', $page); | ||
| 175 | + | ||
| 176 | + $files = $request->get('files'); | ||
| 177 | + $this->fileService->updateFileOrderWithinPage($files, $pageId); | ||
| 178 | + return response()->json(['message' => 'Attachment order updated']); | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + /** | ||
| 182 | + * Get a file from storage. | ||
| 183 | + * @param $fileId | ||
| 184 | + */ | ||
| 185 | + public function get($fileId) | ||
| 186 | + { | ||
| 187 | + $file = $this->file->findOrFail($fileId); | ||
| 188 | + $page = $this->pageRepo->getById($file->uploaded_to); | ||
| 189 | + $this->checkOwnablePermission('page-view', $page); | ||
| 190 | + | ||
| 191 | + if ($file->external) { | ||
| 192 | + return redirect($file->path); | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + $fileContents = $this->fileService->getFile($file); | ||
| 196 | + return response($fileContents, 200, [ | ||
| 197 | + 'Content-Type' => 'application/octet-stream', | ||
| 198 | + 'Content-Disposition' => 'attachment; filename="'. $file->getFileName() .'"' | ||
| 199 | + ]); | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + /** | ||
| 203 | + * Delete a specific file in the system. | ||
| 204 | + * @param $fileId | ||
| 205 | + * @return mixed | ||
| 206 | + */ | ||
| 207 | + public function delete($fileId) | ||
| 208 | + { | ||
| 209 | + $file = $this->file->findOrFail($fileId); | ||
| 210 | + $this->checkOwnablePermission('file-delete', $file); | ||
| 211 | + $this->fileService->deleteFile($file); | ||
| 212 | + return response()->json(['message' => 'Attachment deleted']); | ||
| 213 | + } | ||
| 214 | +} |
| ... | @@ -55,6 +55,15 @@ class Page extends Entity | ... | @@ -55,6 +55,15 @@ class Page extends Entity |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | /** | 57 | /** |
| 58 | + * Get the files attached to this page. | ||
| 59 | + * @return \Illuminate\Database\Eloquent\Relations\HasMany | ||
| 60 | + */ | ||
| 61 | + public function files() | ||
| 62 | + { | ||
| 63 | + return $this->hasMany(File::class, 'uploaded_to')->orderBy('order', 'asc'); | ||
| 64 | + } | ||
| 65 | + | ||
| 66 | + /** | ||
| 58 | * Get the url for this page. | 67 | * Get the url for this page. |
| 59 | * @param string|bool $path | 68 | * @param string|bool $path |
| 60 | * @return string | 69 | * @return string | ... | ... |
| ... | @@ -5,6 +5,7 @@ use BookStack\Book; | ... | @@ -5,6 +5,7 @@ use BookStack\Book; |
| 5 | use BookStack\Chapter; | 5 | use BookStack\Chapter; |
| 6 | use BookStack\Entity; | 6 | use BookStack\Entity; |
| 7 | use BookStack\Exceptions\NotFoundException; | 7 | use BookStack\Exceptions\NotFoundException; |
| 8 | +use BookStack\Services\FileService; | ||
| 8 | use Carbon\Carbon; | 9 | use Carbon\Carbon; |
| 9 | use DOMDocument; | 10 | use DOMDocument; |
| 10 | use DOMXPath; | 11 | use DOMXPath; |
| ... | @@ -48,7 +49,7 @@ class PageRepo extends EntityRepo | ... | @@ -48,7 +49,7 @@ class PageRepo extends EntityRepo |
| 48 | * Get a page via a specific ID. | 49 | * Get a page via a specific ID. |
| 49 | * @param $id | 50 | * @param $id |
| 50 | * @param bool $allowDrafts | 51 | * @param bool $allowDrafts |
| 51 | - * @return mixed | 52 | + * @return Page |
| 52 | */ | 53 | */ |
| 53 | public function getById($id, $allowDrafts = false) | 54 | public function getById($id, $allowDrafts = false) |
| 54 | { | 55 | { |
| ... | @@ -633,6 +634,13 @@ class PageRepo extends EntityRepo | ... | @@ -633,6 +634,13 @@ class PageRepo extends EntityRepo |
| 633 | $page->revisions()->delete(); | 634 | $page->revisions()->delete(); |
| 634 | $page->permissions()->delete(); | 635 | $page->permissions()->delete(); |
| 635 | $this->permissionService->deleteJointPermissionsForEntity($page); | 636 | $this->permissionService->deleteJointPermissionsForEntity($page); |
| 637 | + | ||
| 638 | + // Delete AttachedFiles | ||
| 639 | + $fileService = app(FileService::class); | ||
| 640 | + foreach ($page->files as $file) { | ||
| 641 | + $fileService->deleteFile($file); | ||
| 642 | + } | ||
| 643 | + | ||
| 636 | $page->delete(); | 644 | $page->delete(); |
| 637 | } | 645 | } |
| 638 | 646 | ... | ... |
app/Services/FileService.php
0 → 100644
| 1 | +<?php namespace BookStack\Services; | ||
| 2 | + | ||
| 3 | + | ||
| 4 | +use BookStack\Exceptions\FileUploadException; | ||
| 5 | +use BookStack\File; | ||
| 6 | +use Exception; | ||
| 7 | +use Illuminate\Contracts\Filesystem\FileNotFoundException; | ||
| 8 | +use Illuminate\Support\Collection; | ||
| 9 | +use Symfony\Component\HttpFoundation\File\UploadedFile; | ||
| 10 | + | ||
| 11 | +class FileService extends UploadService | ||
| 12 | +{ | ||
| 13 | + | ||
| 14 | + /** | ||
| 15 | + * Get a file from storage. | ||
| 16 | + * @param File $file | ||
| 17 | + * @return string | ||
| 18 | + */ | ||
| 19 | + public function getFile(File $file) | ||
| 20 | + { | ||
| 21 | + $filePath = $this->getStorageBasePath() . $file->path; | ||
| 22 | + return $this->getStorage()->get($filePath); | ||
| 23 | + } | ||
| 24 | + | ||
| 25 | + /** | ||
| 26 | + * Store a new file upon user upload. | ||
| 27 | + * @param UploadedFile $uploadedFile | ||
| 28 | + * @param int $page_id | ||
| 29 | + * @return File | ||
| 30 | + * @throws FileUploadException | ||
| 31 | + */ | ||
| 32 | + public function saveNewUpload(UploadedFile $uploadedFile, $page_id) | ||
| 33 | + { | ||
| 34 | + $fileName = $uploadedFile->getClientOriginalName(); | ||
| 35 | + $filePath = $this->putFileInStorage($fileName, $uploadedFile); | ||
| 36 | + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order'); | ||
| 37 | + | ||
| 38 | + $file = File::forceCreate([ | ||
| 39 | + 'name' => $fileName, | ||
| 40 | + 'path' => $filePath, | ||
| 41 | + 'extension' => $uploadedFile->getClientOriginalExtension(), | ||
| 42 | + 'uploaded_to' => $page_id, | ||
| 43 | + 'created_by' => user()->id, | ||
| 44 | + 'updated_by' => user()->id, | ||
| 45 | + 'order' => $largestExistingOrder + 1 | ||
| 46 | + ]); | ||
| 47 | + | ||
| 48 | + return $file; | ||
| 49 | + } | ||
| 50 | + | ||
| 51 | + /** | ||
| 52 | + * Store a upload, saving to a file and deleting any existing uploads | ||
| 53 | + * attached to that file. | ||
| 54 | + * @param UploadedFile $uploadedFile | ||
| 55 | + * @param File $file | ||
| 56 | + * @return File | ||
| 57 | + * @throws FileUploadException | ||
| 58 | + */ | ||
| 59 | + public function saveUpdatedUpload(UploadedFile $uploadedFile, File $file) | ||
| 60 | + { | ||
| 61 | + if (!$file->external) { | ||
| 62 | + $this->deleteFileInStorage($file); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + $fileName = $uploadedFile->getClientOriginalName(); | ||
| 66 | + $filePath = $this->putFileInStorage($fileName, $uploadedFile); | ||
| 67 | + | ||
| 68 | + $file->name = $fileName; | ||
| 69 | + $file->path = $filePath; | ||
| 70 | + $file->external = false; | ||
| 71 | + $file->extension = $uploadedFile->getClientOriginalExtension(); | ||
| 72 | + $file->save(); | ||
| 73 | + return $file; | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + /** | ||
| 77 | + * Save a new File attachment from a given link and name. | ||
| 78 | + * @param string $name | ||
| 79 | + * @param string $link | ||
| 80 | + * @param int $page_id | ||
| 81 | + * @return File | ||
| 82 | + */ | ||
| 83 | + public function saveNewFromLink($name, $link, $page_id) | ||
| 84 | + { | ||
| 85 | + $largestExistingOrder = File::where('uploaded_to', '=', $page_id)->max('order'); | ||
| 86 | + return File::forceCreate([ | ||
| 87 | + 'name' => $name, | ||
| 88 | + 'path' => $link, | ||
| 89 | + 'external' => true, | ||
| 90 | + 'extension' => '', | ||
| 91 | + 'uploaded_to' => $page_id, | ||
| 92 | + 'created_by' => user()->id, | ||
| 93 | + 'updated_by' => user()->id, | ||
| 94 | + 'order' => $largestExistingOrder + 1 | ||
| 95 | + ]); | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + /** | ||
| 99 | + * Get the file storage base path, amended for storage type. | ||
| 100 | + * This allows us to keep a generic path in the database. | ||
| 101 | + * @return string | ||
| 102 | + */ | ||
| 103 | + private function getStorageBasePath() | ||
| 104 | + { | ||
| 105 | + return $this->isLocal() ? 'storage/' : ''; | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + /** | ||
| 109 | + * Updates the file ordering for a listing of attached files. | ||
| 110 | + * @param array $fileList | ||
| 111 | + * @param $pageId | ||
| 112 | + */ | ||
| 113 | + public function updateFileOrderWithinPage($fileList, $pageId) | ||
| 114 | + { | ||
| 115 | + foreach ($fileList as $index => $file) { | ||
| 116 | + File::where('uploaded_to', '=', $pageId)->where('id', '=', $file['id'])->update(['order' => $index]); | ||
| 117 | + } | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + | ||
| 121 | + /** | ||
| 122 | + * Update the details of a file. | ||
| 123 | + * @param File $file | ||
| 124 | + * @param $requestData | ||
| 125 | + * @return File | ||
| 126 | + */ | ||
| 127 | + public function updateFile(File $file, $requestData) | ||
| 128 | + { | ||
| 129 | + $file->name = $requestData['name']; | ||
| 130 | + if (isset($requestData['link']) && trim($requestData['link']) !== '') { | ||
| 131 | + $file->path = $requestData['link']; | ||
| 132 | + if (!$file->external) { | ||
| 133 | + $this->deleteFileInStorage($file); | ||
| 134 | + $file->external = true; | ||
| 135 | + } | ||
| 136 | + } | ||
| 137 | + $file->save(); | ||
| 138 | + return $file; | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + /** | ||
| 142 | + * Delete a File from the database and storage. | ||
| 143 | + * @param File $file | ||
| 144 | + */ | ||
| 145 | + public function deleteFile(File $file) | ||
| 146 | + { | ||
| 147 | + if ($file->external) { | ||
| 148 | + $file->delete(); | ||
| 149 | + return; | ||
| 150 | + } | ||
| 151 | + | ||
| 152 | + $this->deleteFileInStorage($file); | ||
| 153 | + $file->delete(); | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + /** | ||
| 157 | + * Delete a file from the filesystem it sits on. | ||
| 158 | + * Cleans any empty leftover folders. | ||
| 159 | + * @param File $file | ||
| 160 | + */ | ||
| 161 | + protected function deleteFileInStorage(File $file) | ||
| 162 | + { | ||
| 163 | + $storedFilePath = $this->getStorageBasePath() . $file->path; | ||
| 164 | + $storage = $this->getStorage(); | ||
| 165 | + $dirPath = dirname($storedFilePath); | ||
| 166 | + | ||
| 167 | + $storage->delete($storedFilePath); | ||
| 168 | + if (count($storage->allFiles($dirPath)) === 0) { | ||
| 169 | + $storage->deleteDirectory($dirPath); | ||
| 170 | + } | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + /** | ||
| 174 | + * Store a file in storage with the given filename | ||
| 175 | + * @param $fileName | ||
| 176 | + * @param UploadedFile $uploadedFile | ||
| 177 | + * @return string | ||
| 178 | + * @throws FileUploadException | ||
| 179 | + */ | ||
| 180 | + protected function putFileInStorage($fileName, UploadedFile $uploadedFile) | ||
| 181 | + { | ||
| 182 | + $fileData = file_get_contents($uploadedFile->getRealPath()); | ||
| 183 | + | ||
| 184 | + $storage = $this->getStorage(); | ||
| 185 | + $fileBasePath = 'uploads/files/' . Date('Y-m-M') . '/'; | ||
| 186 | + $storageBasePath = $this->getStorageBasePath() . $fileBasePath; | ||
| 187 | + | ||
| 188 | + $uploadFileName = $fileName; | ||
| 189 | + while ($storage->exists($storageBasePath . $uploadFileName)) { | ||
| 190 | + $uploadFileName = str_random(3) . $uploadFileName; | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + $filePath = $fileBasePath . $uploadFileName; | ||
| 194 | + $fileStoragePath = $this->getStorageBasePath() . $filePath; | ||
| 195 | + | ||
| 196 | + try { | ||
| 197 | + $storage->put($fileStoragePath, $fileData); | ||
| 198 | + } catch (Exception $e) { | ||
| 199 | + throw new FileUploadException('File path ' . $fileStoragePath . ' could not be uploaded to. Ensure it is writable to the server.'); | ||
| 200 | + } | ||
| 201 | + return $filePath; | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; | ... | @@ -9,20 +9,13 @@ use Intervention\Image\ImageManager; |
| 9 | use Illuminate\Contracts\Filesystem\Factory as FileSystem; | 9 | use Illuminate\Contracts\Filesystem\Factory as FileSystem; |
| 10 | use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; | 10 | use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; |
| 11 | use Illuminate\Contracts\Cache\Repository as Cache; | 11 | use Illuminate\Contracts\Cache\Repository as Cache; |
| 12 | -use Setting; | ||
| 13 | use Symfony\Component\HttpFoundation\File\UploadedFile; | 12 | use Symfony\Component\HttpFoundation\File\UploadedFile; |
| 14 | 13 | ||
| 15 | -class ImageService | 14 | +class ImageService extends UploadService |
| 16 | { | 15 | { |
| 17 | 16 | ||
| 18 | protected $imageTool; | 17 | protected $imageTool; |
| 19 | - protected $fileSystem; | ||
| 20 | protected $cache; | 18 | protected $cache; |
| 21 | - | ||
| 22 | - /** | ||
| 23 | - * @var FileSystemInstance | ||
| 24 | - */ | ||
| 25 | - protected $storageInstance; | ||
| 26 | protected $storageUrl; | 19 | protected $storageUrl; |
| 27 | 20 | ||
| 28 | /** | 21 | /** |
| ... | @@ -34,8 +27,8 @@ class ImageService | ... | @@ -34,8 +27,8 @@ class ImageService |
| 34 | public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) | 27 | public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache) |
| 35 | { | 28 | { |
| 36 | $this->imageTool = $imageTool; | 29 | $this->imageTool = $imageTool; |
| 37 | - $this->fileSystem = $fileSystem; | ||
| 38 | $this->cache = $cache; | 30 | $this->cache = $cache; |
| 31 | + parent::__construct($fileSystem); | ||
| 39 | } | 32 | } |
| 40 | 33 | ||
| 41 | /** | 34 | /** |
| ... | @@ -88,6 +81,9 @@ class ImageService | ... | @@ -88,6 +81,9 @@ class ImageService |
| 88 | if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; | 81 | if ($secureUploads) $imageName = str_random(16) . '-' . $imageName; |
| 89 | 82 | ||
| 90 | $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; | 83 | $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/'; |
| 84 | + | ||
| 85 | + if ($this->isLocal()) $imagePath = '/public' . $imagePath; | ||
| 86 | + | ||
| 91 | while ($storage->exists($imagePath . $imageName)) { | 87 | while ($storage->exists($imagePath . $imageName)) { |
| 92 | $imageName = str_random(3) . $imageName; | 88 | $imageName = str_random(3) . $imageName; |
| 93 | } | 89 | } |
| ... | @@ -100,6 +96,8 @@ class ImageService | ... | @@ -100,6 +96,8 @@ class ImageService |
| 100 | throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); | 96 | throw new ImageUploadException('Image Path ' . $fullPath . ' is not writable by the server.'); |
| 101 | } | 97 | } |
| 102 | 98 | ||
| 99 | + if ($this->isLocal()) $fullPath = str_replace_first('/public', '', $fullPath); | ||
| 100 | + | ||
| 103 | $imageDetails = [ | 101 | $imageDetails = [ |
| 104 | 'name' => $imageName, | 102 | 'name' => $imageName, |
| 105 | 'path' => $fullPath, | 103 | 'path' => $fullPath, |
| ... | @@ -120,6 +118,16 @@ class ImageService | ... | @@ -120,6 +118,16 @@ class ImageService |
| 120 | } | 118 | } |
| 121 | 119 | ||
| 122 | /** | 120 | /** |
| 121 | + * Get the storage path, Dependant of storage type. | ||
| 122 | + * @param Image $image | ||
| 123 | + * @return mixed|string | ||
| 124 | + */ | ||
| 125 | + protected function getPath(Image $image) | ||
| 126 | + { | ||
| 127 | + return ($this->isLocal()) ? ('public/' . $image->path) : $image->path; | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + /** | ||
| 123 | * Get the thumbnail for an image. | 131 | * Get the thumbnail for an image. |
| 124 | * If $keepRatio is true only the width will be used. | 132 | * If $keepRatio is true only the width will be used. |
| 125 | * Checks the cache then storage to avoid creating / accessing the filesystem on every check. | 133 | * Checks the cache then storage to avoid creating / accessing the filesystem on every check. |
| ... | @@ -135,7 +143,8 @@ class ImageService | ... | @@ -135,7 +143,8 @@ class ImageService |
| 135 | public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) | 143 | public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) |
| 136 | { | 144 | { |
| 137 | $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; | 145 | $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/'; |
| 138 | - $thumbFilePath = dirname($image->path) . $thumbDirName . basename($image->path); | 146 | + $imagePath = $this->getPath($image); |
| 147 | + $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath); | ||
| 139 | 148 | ||
| 140 | if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { | 149 | if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) { |
| 141 | return $this->getPublicUrl($thumbFilePath); | 150 | return $this->getPublicUrl($thumbFilePath); |
| ... | @@ -148,7 +157,7 @@ class ImageService | ... | @@ -148,7 +157,7 @@ class ImageService |
| 148 | } | 157 | } |
| 149 | 158 | ||
| 150 | try { | 159 | try { |
| 151 | - $thumb = $this->imageTool->make($storage->get($image->path)); | 160 | + $thumb = $this->imageTool->make($storage->get($imagePath)); |
| 152 | } catch (Exception $e) { | 161 | } catch (Exception $e) { |
| 153 | if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { | 162 | if ($e instanceof \ErrorException || $e instanceof NotSupportedException) { |
| 154 | throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); | 163 | throw new ImageUploadException('The server cannot create thumbnails. Please check you have the GD PHP extension installed.'); |
| ... | @@ -183,8 +192,8 @@ class ImageService | ... | @@ -183,8 +192,8 @@ class ImageService |
| 183 | { | 192 | { |
| 184 | $storage = $this->getStorage(); | 193 | $storage = $this->getStorage(); |
| 185 | 194 | ||
| 186 | - $imageFolder = dirname($image->path); | 195 | + $imageFolder = dirname($this->getPath($image)); |
| 187 | - $imageFileName = basename($image->path); | 196 | + $imageFileName = basename($this->getPath($image)); |
| 188 | $allImages = collect($storage->allFiles($imageFolder)); | 197 | $allImages = collect($storage->allFiles($imageFolder)); |
| 189 | 198 | ||
| 190 | $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { | 199 | $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) { |
| ... | @@ -223,34 +232,8 @@ class ImageService | ... | @@ -223,34 +232,8 @@ class ImageService |
| 223 | } | 232 | } |
| 224 | 233 | ||
| 225 | /** | 234 | /** |
| 226 | - * Get the storage that will be used for storing images. | ||
| 227 | - * @return FileSystemInstance | ||
| 228 | - */ | ||
| 229 | - private function getStorage() | ||
| 230 | - { | ||
| 231 | - if ($this->storageInstance !== null) return $this->storageInstance; | ||
| 232 | - | ||
| 233 | - $storageType = config('filesystems.default'); | ||
| 234 | - $this->storageInstance = $this->fileSystem->disk($storageType); | ||
| 235 | - | ||
| 236 | - return $this->storageInstance; | ||
| 237 | - } | ||
| 238 | - | ||
| 239 | - /** | ||
| 240 | - * Check whether or not a folder is empty. | ||
| 241 | - * @param $path | ||
| 242 | - * @return int | ||
| 243 | - */ | ||
| 244 | - private function isFolderEmpty($path) | ||
| 245 | - { | ||
| 246 | - $files = $this->getStorage()->files($path); | ||
| 247 | - $folders = $this->getStorage()->directories($path); | ||
| 248 | - return count($files) === 0 && count($folders) === 0; | ||
| 249 | - } | ||
| 250 | - | ||
| 251 | - /** | ||
| 252 | * Gets a public facing url for an image by checking relevant environment variables. | 235 | * Gets a public facing url for an image by checking relevant environment variables. |
| 253 | - * @param $filePath | 236 | + * @param string $filePath |
| 254 | * @return string | 237 | * @return string |
| 255 | */ | 238 | */ |
| 256 | private function getPublicUrl($filePath) | 239 | private function getPublicUrl($filePath) |
| ... | @@ -273,6 +256,8 @@ class ImageService | ... | @@ -273,6 +256,8 @@ class ImageService |
| 273 | $this->storageUrl = $storageUrl; | 256 | $this->storageUrl = $storageUrl; |
| 274 | } | 257 | } |
| 275 | 258 | ||
| 259 | + if ($this->isLocal()) $filePath = str_replace_first('public/', '', $filePath); | ||
| 260 | + | ||
| 276 | return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; | 261 | return ($this->storageUrl == false ? rtrim(baseUrl(''), '/') : rtrim($this->storageUrl, '/')) . $filePath; |
| 277 | } | 262 | } |
| 278 | 263 | ... | ... |
app/Services/UploadService.php
0 → 100644
| 1 | +<?php namespace BookStack\Services; | ||
| 2 | + | ||
| 3 | +use Illuminate\Contracts\Filesystem\Factory as FileSystem; | ||
| 4 | +use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance; | ||
| 5 | + | ||
| 6 | +class UploadService | ||
| 7 | +{ | ||
| 8 | + | ||
| 9 | + /** | ||
| 10 | + * @var FileSystem | ||
| 11 | + */ | ||
| 12 | + protected $fileSystem; | ||
| 13 | + | ||
| 14 | + /** | ||
| 15 | + * @var FileSystemInstance | ||
| 16 | + */ | ||
| 17 | + protected $storageInstance; | ||
| 18 | + | ||
| 19 | + | ||
| 20 | + /** | ||
| 21 | + * FileService constructor. | ||
| 22 | + * @param $fileSystem | ||
| 23 | + */ | ||
| 24 | + public function __construct(FileSystem $fileSystem) | ||
| 25 | + { | ||
| 26 | + $this->fileSystem = $fileSystem; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * Get the storage that will be used for storing images. | ||
| 31 | + * @return FileSystemInstance | ||
| 32 | + */ | ||
| 33 | + protected function getStorage() | ||
| 34 | + { | ||
| 35 | + if ($this->storageInstance !== null) return $this->storageInstance; | ||
| 36 | + | ||
| 37 | + $storageType = config('filesystems.default'); | ||
| 38 | + $this->storageInstance = $this->fileSystem->disk($storageType); | ||
| 39 | + | ||
| 40 | + return $this->storageInstance; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + | ||
| 44 | + /** | ||
| 45 | + * Check whether or not a folder is empty. | ||
| 46 | + * @param $path | ||
| 47 | + * @return bool | ||
| 48 | + */ | ||
| 49 | + protected function isFolderEmpty($path) | ||
| 50 | + { | ||
| 51 | + $files = $this->getStorage()->files($path); | ||
| 52 | + $folders = $this->getStorage()->directories($path); | ||
| 53 | + return (count($files) === 0 && count($folders) === 0); | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + /** | ||
| 57 | + * Check if using a local filesystem. | ||
| 58 | + * @return bool | ||
| 59 | + */ | ||
| 60 | + protected function isLocal() | ||
| 61 | + { | ||
| 62 | + return strtolower(config('filesystems.default')) === 'local'; | ||
| 63 | + } | ||
| 64 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Support\Facades\Schema; | ||
| 4 | +use Illuminate\Database\Schema\Blueprint; | ||
| 5 | +use Illuminate\Database\Migrations\Migration; | ||
| 6 | + | ||
| 7 | +class CreateFilesTable extends Migration | ||
| 8 | +{ | ||
| 9 | + /** | ||
| 10 | + * Run the migrations. | ||
| 11 | + * | ||
| 12 | + * @return void | ||
| 13 | + */ | ||
| 14 | + public function up() | ||
| 15 | + { | ||
| 16 | + Schema::create('files', function (Blueprint $table) { | ||
| 17 | + $table->increments('id'); | ||
| 18 | + $table->string('name'); | ||
| 19 | + $table->string('path'); | ||
| 20 | + $table->string('extension', 20); | ||
| 21 | + $table->integer('uploaded_to'); | ||
| 22 | + | ||
| 23 | + $table->boolean('external'); | ||
| 24 | + $table->integer('order'); | ||
| 25 | + | ||
| 26 | + $table->integer('created_by'); | ||
| 27 | + $table->integer('updated_by'); | ||
| 28 | + | ||
| 29 | + $table->index('uploaded_to'); | ||
| 30 | + $table->timestamps(); | ||
| 31 | + }); | ||
| 32 | + | ||
| 33 | + // Get roles with permissions we need to change | ||
| 34 | + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; | ||
| 35 | + | ||
| 36 | + // Create & attach new entity permissions | ||
| 37 | + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own']; | ||
| 38 | + $entity = 'File'; | ||
| 39 | + foreach ($ops as $op) { | ||
| 40 | + $permissionId = DB::table('role_permissions')->insertGetId([ | ||
| 41 | + 'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)), | ||
| 42 | + 'display_name' => $op . ' ' . $entity . 's', | ||
| 43 | + 'created_at' => \Carbon\Carbon::now()->toDateTimeString(), | ||
| 44 | + 'updated_at' => \Carbon\Carbon::now()->toDateTimeString() | ||
| 45 | + ]); | ||
| 46 | + DB::table('permission_role')->insert([ | ||
| 47 | + 'role_id' => $adminRoleId, | ||
| 48 | + 'permission_id' => $permissionId | ||
| 49 | + ]); | ||
| 50 | + } | ||
| 51 | + | ||
| 52 | + } | ||
| 53 | + | ||
| 54 | + /** | ||
| 55 | + * Reverse the migrations. | ||
| 56 | + * | ||
| 57 | + * @return void | ||
| 58 | + */ | ||
| 59 | + public function down() | ||
| 60 | + { | ||
| 61 | + Schema::dropIfExists('files'); | ||
| 62 | + | ||
| 63 | + // Create & attach new entity permissions | ||
| 64 | + $ops = ['Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own']; | ||
| 65 | + $entity = 'File'; | ||
| 66 | + foreach ($ops as $op) { | ||
| 67 | + $permName = strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)); | ||
| 68 | + DB::table('role_permissions')->where('name', '=', $permName)->delete(); | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | +} |
| ... | @@ -460,7 +460,7 @@ module.exports = function (ngApp, events) { | ... | @@ -460,7 +460,7 @@ module.exports = function (ngApp, events) { |
| 460 | * Get all tags for the current book and add into scope. | 460 | * Get all tags for the current book and add into scope. |
| 461 | */ | 461 | */ |
| 462 | function getTags() { | 462 | function getTags() { |
| 463 | - let url = window.baseUrl('/ajax/tags/get/page/' + pageId); | 463 | + let url = window.baseUrl(`/ajax/tags/get/page/${pageId}`); |
| 464 | $http.get(url).then((responseData) => { | 464 | $http.get(url).then((responseData) => { |
| 465 | $scope.tags = responseData.data; | 465 | $scope.tags = responseData.data; |
| 466 | addEmptyTag(); | 466 | addEmptyTag(); |
| ... | @@ -529,6 +529,205 @@ module.exports = function (ngApp, events) { | ... | @@ -529,6 +529,205 @@ module.exports = function (ngApp, events) { |
| 529 | 529 | ||
| 530 | }]); | 530 | }]); |
| 531 | 531 | ||
| 532 | + | ||
| 533 | + ngApp.controller('PageAttachmentController', ['$scope', '$http', '$attrs', | ||
| 534 | + function ($scope, $http, $attrs) { | ||
| 535 | + | ||
| 536 | + const pageId = $scope.uploadedTo = $attrs.pageId; | ||
| 537 | + let currentOrder = ''; | ||
| 538 | + $scope.files = []; | ||
| 539 | + $scope.editFile = false; | ||
| 540 | + $scope.file = getCleanFile(); | ||
| 541 | + $scope.errors = { | ||
| 542 | + link: {}, | ||
| 543 | + edit: {} | ||
| 544 | + }; | ||
| 545 | + | ||
| 546 | + function getCleanFile() { | ||
| 547 | + return { | ||
| 548 | + page_id: pageId | ||
| 549 | + }; | ||
| 550 | + } | ||
| 551 | + | ||
| 552 | + // Angular-UI-Sort options | ||
| 553 | + $scope.sortOptions = { | ||
| 554 | + handle: '.handle', | ||
| 555 | + items: '> tr', | ||
| 556 | + containment: "parent", | ||
| 557 | + axis: "y", | ||
| 558 | + stop: sortUpdate, | ||
| 559 | + }; | ||
| 560 | + | ||
| 561 | + /** | ||
| 562 | + * Event listener for sort changes. | ||
| 563 | + * Updates the file ordering on the server. | ||
| 564 | + * @param event | ||
| 565 | + * @param ui | ||
| 566 | + */ | ||
| 567 | + function sortUpdate(event, ui) { | ||
| 568 | + let newOrder = $scope.files.map(file => {return file.id}).join(':'); | ||
| 569 | + if (newOrder === currentOrder) return; | ||
| 570 | + | ||
| 571 | + currentOrder = newOrder; | ||
| 572 | + $http.put(`/files/sort/page/${pageId}`, {files: $scope.files}).then(resp => { | ||
| 573 | + events.emit('success', resp.data.message); | ||
| 574 | + }, checkError('sort')); | ||
| 575 | + } | ||
| 576 | + | ||
| 577 | + /** | ||
| 578 | + * Used by dropzone to get the endpoint to upload to. | ||
| 579 | + * @returns {string} | ||
| 580 | + */ | ||
| 581 | + $scope.getUploadUrl = function (file) { | ||
| 582 | + let suffix = (typeof file !== 'undefined') ? `/${file.id}` : ''; | ||
| 583 | + return window.baseUrl(`/files/upload${suffix}`); | ||
| 584 | + }; | ||
| 585 | + | ||
| 586 | + /** | ||
| 587 | + * Get files for the current page from the server. | ||
| 588 | + */ | ||
| 589 | + function getFiles() { | ||
| 590 | + let url = window.baseUrl(`/files/get/page/${pageId}`) | ||
| 591 | + $http.get(url).then(resp => { | ||
| 592 | + $scope.files = resp.data; | ||
| 593 | + currentOrder = resp.data.map(file => {return file.id}).join(':'); | ||
| 594 | + }, checkError('get')); | ||
| 595 | + } | ||
| 596 | + getFiles(); | ||
| 597 | + | ||
| 598 | + /** | ||
| 599 | + * Runs on file upload, Adds an file to local file list | ||
| 600 | + * and shows a success message to the user. | ||
| 601 | + * @param file | ||
| 602 | + * @param data | ||
| 603 | + */ | ||
| 604 | + $scope.uploadSuccess = function (file, data) { | ||
| 605 | + $scope.$apply(() => { | ||
| 606 | + $scope.files.push(data); | ||
| 607 | + }); | ||
| 608 | + events.emit('success', 'File uploaded'); | ||
| 609 | + }; | ||
| 610 | + | ||
| 611 | + /** | ||
| 612 | + * Upload and overwrite an existing file. | ||
| 613 | + * @param file | ||
| 614 | + * @param data | ||
| 615 | + */ | ||
| 616 | + $scope.uploadSuccessUpdate = function (file, data) { | ||
| 617 | + $scope.$apply(() => { | ||
| 618 | + let search = filesIndexOf(data); | ||
| 619 | + if (search !== -1) $scope.files[search] = data; | ||
| 620 | + | ||
| 621 | + if ($scope.editFile) { | ||
| 622 | + $scope.editFile = angular.copy(data); | ||
| 623 | + data.link = ''; | ||
| 624 | + } | ||
| 625 | + }); | ||
| 626 | + events.emit('success', 'File updated'); | ||
| 627 | + }; | ||
| 628 | + | ||
| 629 | + /** | ||
| 630 | + * Delete a file from the server and, on success, the local listing. | ||
| 631 | + * @param file | ||
| 632 | + */ | ||
| 633 | + $scope.deleteFile = function(file) { | ||
| 634 | + if (!file.deleting) { | ||
| 635 | + file.deleting = true; | ||
| 636 | + return; | ||
| 637 | + } | ||
| 638 | + $http.delete(`/files/${file.id}`).then(resp => { | ||
| 639 | + events.emit('success', resp.data.message); | ||
| 640 | + $scope.files.splice($scope.files.indexOf(file), 1); | ||
| 641 | + }, checkError('delete')); | ||
| 642 | + }; | ||
| 643 | + | ||
| 644 | + /** | ||
| 645 | + * Attach a link to a page. | ||
| 646 | + * @param fileName | ||
| 647 | + * @param fileLink | ||
| 648 | + */ | ||
| 649 | + $scope.attachLinkSubmit = function(file) { | ||
| 650 | + file.uploaded_to = pageId; | ||
| 651 | + $http.post('/files/link', file).then(resp => { | ||
| 652 | + $scope.files.push(resp.data); | ||
| 653 | + events.emit('success', 'Link attached'); | ||
| 654 | + $scope.file = getCleanFile(); | ||
| 655 | + }, checkError('link')); | ||
| 656 | + }; | ||
| 657 | + | ||
| 658 | + /** | ||
| 659 | + * Start the edit mode for a file. | ||
| 660 | + * @param fileId | ||
| 661 | + */ | ||
| 662 | + $scope.startEdit = function(file) { | ||
| 663 | + console.log(file); | ||
| 664 | + $scope.editFile = angular.copy(file); | ||
| 665 | + $scope.editFile.link = (file.external) ? file.path : ''; | ||
| 666 | + }; | ||
| 667 | + | ||
| 668 | + /** | ||
| 669 | + * Cancel edit mode | ||
| 670 | + */ | ||
| 671 | + $scope.cancelEdit = function() { | ||
| 672 | + $scope.editFile = false; | ||
| 673 | + }; | ||
| 674 | + | ||
| 675 | + /** | ||
| 676 | + * Update the name and link of a file. | ||
| 677 | + * @param file | ||
| 678 | + */ | ||
| 679 | + $scope.updateFile = function(file) { | ||
| 680 | + $http.put(`/files/${file.id}`, file).then(resp => { | ||
| 681 | + let search = filesIndexOf(resp.data); | ||
| 682 | + if (search !== -1) $scope.files[search] = resp.data; | ||
| 683 | + | ||
| 684 | + if ($scope.editFile && !file.external) { | ||
| 685 | + $scope.editFile.link = ''; | ||
| 686 | + } | ||
| 687 | + $scope.editFile = false; | ||
| 688 | + events.emit('success', 'Attachment details updated'); | ||
| 689 | + }, checkError('edit')); | ||
| 690 | + }; | ||
| 691 | + | ||
| 692 | + /** | ||
| 693 | + * Get the url of a file. | ||
| 694 | + */ | ||
| 695 | + $scope.getFileUrl = function(file) { | ||
| 696 | + return window.baseUrl('/files/' + file.id); | ||
| 697 | + } | ||
| 698 | + | ||
| 699 | + /** | ||
| 700 | + * Search the local files via another file object. | ||
| 701 | + * Used to search via object copies. | ||
| 702 | + * @param file | ||
| 703 | + * @returns int | ||
| 704 | + */ | ||
| 705 | + function filesIndexOf(file) { | ||
| 706 | + for (let i = 0; i < $scope.files.length; i++) { | ||
| 707 | + if ($scope.files[i].id == file.id) return i; | ||
| 708 | + } | ||
| 709 | + return -1; | ||
| 710 | + } | ||
| 711 | + | ||
| 712 | + /** | ||
| 713 | + * Check for an error response in a ajax request. | ||
| 714 | + * @param response | ||
| 715 | + */ | ||
| 716 | + function checkError(errorGroupName) { | ||
| 717 | + $scope.errors[errorGroupName] = {}; | ||
| 718 | + return function(response) { | ||
| 719 | + if (typeof response.data !== 'undefined' && typeof response.data.error !== 'undefined') { | ||
| 720 | + events.emit('error', response.data.error); | ||
| 721 | + } | ||
| 722 | + if (typeof response.data !== 'undefined' && typeof response.data.validation !== 'undefined') { | ||
| 723 | + $scope.errors[errorGroupName] = response.data.validation; | ||
| 724 | + console.log($scope.errors[errorGroupName]) | ||
| 725 | + } | ||
| 726 | + } | ||
| 727 | + } | ||
| 728 | + | ||
| 729 | + }]); | ||
| 730 | + | ||
| 532 | }; | 731 | }; |
| 533 | 732 | ||
| 534 | 733 | ... | ... |
| ... | @@ -33,6 +33,59 @@ module.exports = function (ngApp, events) { | ... | @@ -33,6 +33,59 @@ module.exports = function (ngApp, events) { |
| 33 | }; | 33 | }; |
| 34 | }); | 34 | }); |
| 35 | 35 | ||
| 36 | + /** | ||
| 37 | + * Common tab controls using simple jQuery functions. | ||
| 38 | + */ | ||
| 39 | + ngApp.directive('tabContainer', function() { | ||
| 40 | + return { | ||
| 41 | + restrict: 'A', | ||
| 42 | + link: function (scope, element, attrs) { | ||
| 43 | + const $content = element.find('[tab-content]'); | ||
| 44 | + const $buttons = element.find('[tab-button]'); | ||
| 45 | + | ||
| 46 | + if (attrs.tabContainer) { | ||
| 47 | + let initial = attrs.tabContainer; | ||
| 48 | + $buttons.filter(`[tab-button="${initial}"]`).addClass('selected'); | ||
| 49 | + $content.hide().filter(`[tab-content="${initial}"]`).show(); | ||
| 50 | + } else { | ||
| 51 | + $content.hide().first().show(); | ||
| 52 | + $buttons.first().addClass('selected'); | ||
| 53 | + } | ||
| 54 | + | ||
| 55 | + $buttons.click(function() { | ||
| 56 | + let clickedTab = $(this); | ||
| 57 | + $buttons.removeClass('selected'); | ||
| 58 | + $content.hide(); | ||
| 59 | + let name = clickedTab.addClass('selected').attr('tab-button'); | ||
| 60 | + $content.filter(`[tab-content="${name}"]`).show(); | ||
| 61 | + }); | ||
| 62 | + } | ||
| 63 | + }; | ||
| 64 | + }); | ||
| 65 | + | ||
| 66 | + /** | ||
| 67 | + * Sub form component to allow inner-form sections to act like thier own forms. | ||
| 68 | + */ | ||
| 69 | + ngApp.directive('subForm', function() { | ||
| 70 | + return { | ||
| 71 | + restrict: 'A', | ||
| 72 | + link: function (scope, element, attrs) { | ||
| 73 | + element.on('keypress', e => { | ||
| 74 | + if (e.keyCode === 13) { | ||
| 75 | + submitEvent(e); | ||
| 76 | + } | ||
| 77 | + }); | ||
| 78 | + | ||
| 79 | + element.find('button[type="submit"]').click(submitEvent); | ||
| 80 | + | ||
| 81 | + function submitEvent(e) { | ||
| 82 | + e.preventDefault() | ||
| 83 | + if (attrs.subForm) scope.$eval(attrs.subForm); | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + }; | ||
| 87 | + }); | ||
| 88 | + | ||
| 36 | 89 | ||
| 37 | /** | 90 | /** |
| 38 | * Image Picker | 91 | * Image Picker |
| ... | @@ -116,6 +169,7 @@ module.exports = function (ngApp, events) { | ... | @@ -116,6 +169,7 @@ module.exports = function (ngApp, events) { |
| 116 | uploadedTo: '@' | 169 | uploadedTo: '@' |
| 117 | }, | 170 | }, |
| 118 | link: function (scope, element, attrs) { | 171 | link: function (scope, element, attrs) { |
| 172 | + if (attrs.placeholder) element[0].querySelector('.dz-message').textContent = attrs.placeholder; | ||
| 119 | var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), { | 173 | var dropZone = new DropZone(element[0].querySelector('.dropzone-container'), { |
| 120 | url: scope.uploadUrl, | 174 | url: scope.uploadUrl, |
| 121 | init: function () { | 175 | init: function () { |
| ... | @@ -488,8 +542,8 @@ module.exports = function (ngApp, events) { | ... | @@ -488,8 +542,8 @@ module.exports = function (ngApp, events) { |
| 488 | link: function (scope, elem, attrs) { | 542 | link: function (scope, elem, attrs) { |
| 489 | 543 | ||
| 490 | // Get common elements | 544 | // Get common elements |
| 491 | - const $buttons = elem.find('[tab-button]'); | 545 | + const $buttons = elem.find('[toolbox-tab-button]'); |
| 492 | - const $content = elem.find('[tab-content]'); | 546 | + const $content = elem.find('[toolbox-tab-content]'); |
| 493 | const $toggle = elem.find('[toolbox-toggle]'); | 547 | const $toggle = elem.find('[toolbox-toggle]'); |
| 494 | 548 | ||
| 495 | // Handle toolbox toggle click | 549 | // Handle toolbox toggle click |
| ... | @@ -501,17 +555,17 @@ module.exports = function (ngApp, events) { | ... | @@ -501,17 +555,17 @@ module.exports = function (ngApp, events) { |
| 501 | function setActive(tabName, openToolbox) { | 555 | function setActive(tabName, openToolbox) { |
| 502 | $buttons.removeClass('active'); | 556 | $buttons.removeClass('active'); |
| 503 | $content.hide(); | 557 | $content.hide(); |
| 504 | - $buttons.filter(`[tab-button="${tabName}"]`).addClass('active'); | 558 | + $buttons.filter(`[toolbox-tab-button="${tabName}"]`).addClass('active'); |
| 505 | - $content.filter(`[tab-content="${tabName}"]`).show(); | 559 | + $content.filter(`[toolbox-tab-content="${tabName}"]`).show(); |
| 506 | if (openToolbox) elem.addClass('open'); | 560 | if (openToolbox) elem.addClass('open'); |
| 507 | } | 561 | } |
| 508 | 562 | ||
| 509 | // Set the first tab content active on load | 563 | // Set the first tab content active on load |
| 510 | - setActive($content.first().attr('tab-content'), false); | 564 | + setActive($content.first().attr('toolbox-tab-content'), false); |
| 511 | 565 | ||
| 512 | // Handle tab button click | 566 | // Handle tab button click |
| 513 | $buttons.click(function (e) { | 567 | $buttons.click(function (e) { |
| 514 | - let name = $(this).attr('tab-button'); | 568 | + let name = $(this).attr('toolbox-tab-button'); |
| 515 | setActive(name, true); | 569 | setActive(name, true); |
| 516 | }); | 570 | }); |
| 517 | } | 571 | } | ... | ... |
| ... | @@ -43,10 +43,6 @@ | ... | @@ -43,10 +43,6 @@ |
| 43 | } | 43 | } |
| 44 | } | 44 | } |
| 45 | 45 | ||
| 46 | -//body.ie .popup-body { | ||
| 47 | -// min-height: 100%; | ||
| 48 | -//} | ||
| 49 | - | ||
| 50 | .corner-button { | 46 | .corner-button { |
| 51 | position: absolute; | 47 | position: absolute; |
| 52 | top: 0; | 48 | top: 0; |
| ... | @@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { | ... | @@ -82,7 +78,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { |
| 82 | min-height: 70vh; | 78 | min-height: 70vh; |
| 83 | } | 79 | } |
| 84 | 80 | ||
| 85 | -#image-manager .dropzone-container { | 81 | +.dropzone-container { |
| 86 | position: relative; | 82 | position: relative; |
| 87 | border: 3px dashed #DDD; | 83 | border: 3px dashed #DDD; |
| 88 | } | 84 | } |
| ... | @@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { | ... | @@ -456,3 +452,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { |
| 456 | border-right: 6px solid transparent; | 452 | border-right: 6px solid transparent; |
| 457 | border-bottom: 6px solid $negative; | 453 | border-bottom: 6px solid $negative; |
| 458 | } | 454 | } |
| 455 | + | ||
| 456 | + | ||
| 457 | +[tab-container] .nav-tabs { | ||
| 458 | + text-align: left; | ||
| 459 | + border-bottom: 1px solid #DDD; | ||
| 460 | + margin-bottom: $-m; | ||
| 461 | + .tab-item { | ||
| 462 | + padding: $-s; | ||
| 463 | + color: #666; | ||
| 464 | + &.selected { | ||
| 465 | + border-bottom-width: 3px; | ||
| 466 | + } | ||
| 467 | + } | ||
| 468 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -150,7 +150,6 @@ | ... | @@ -150,7 +150,6 @@ |
| 150 | background-color: #FFF; | 150 | background-color: #FFF; |
| 151 | border: 1px solid #DDD; | 151 | border: 1px solid #DDD; |
| 152 | right: $-xl*2; | 152 | right: $-xl*2; |
| 153 | - z-index: 99; | ||
| 154 | width: 48px; | 153 | width: 48px; |
| 155 | overflow: hidden; | 154 | overflow: hidden; |
| 156 | align-items: stretch; | 155 | align-items: stretch; |
| ... | @@ -201,7 +200,7 @@ | ... | @@ -201,7 +200,7 @@ |
| 201 | color: #444; | 200 | color: #444; |
| 202 | background-color: rgba(0, 0, 0, 0.1); | 201 | background-color: rgba(0, 0, 0, 0.1); |
| 203 | } | 202 | } |
| 204 | - div[tab-content] { | 203 | + div[toolbox-tab-content] { |
| 205 | padding-bottom: 45px; | 204 | padding-bottom: 45px; |
| 206 | display: flex; | 205 | display: flex; |
| 207 | flex: 1; | 206 | flex: 1; |
| ... | @@ -209,7 +208,7 @@ | ... | @@ -209,7 +208,7 @@ |
| 209 | min-height: 0px; | 208 | min-height: 0px; |
| 210 | overflow-y: scroll; | 209 | overflow-y: scroll; |
| 211 | } | 210 | } |
| 212 | - div[tab-content] .padded { | 211 | + div[toolbox-tab-content] .padded { |
| 213 | flex: 1; | 212 | flex: 1; |
| 214 | padding-top: 0; | 213 | padding-top: 0; |
| 215 | } | 214 | } |
| ... | @@ -228,21 +227,6 @@ | ... | @@ -228,21 +227,6 @@ |
| 228 | padding-top: $-s; | 227 | padding-top: $-s; |
| 229 | position: relative; | 228 | position: relative; |
| 230 | } | 229 | } |
| 231 | - button.pos { | ||
| 232 | - position: absolute; | ||
| 233 | - bottom: 0; | ||
| 234 | - display: block; | ||
| 235 | - width: 100%; | ||
| 236 | - padding: $-s; | ||
| 237 | - height: 45px; | ||
| 238 | - border: 0; | ||
| 239 | - margin: 0; | ||
| 240 | - box-shadow: none; | ||
| 241 | - border-radius: 0; | ||
| 242 | - &:hover{ | ||
| 243 | - box-shadow: none; | ||
| 244 | - } | ||
| 245 | - } | ||
| 246 | .handle { | 230 | .handle { |
| 247 | user-select: none; | 231 | user-select: none; |
| 248 | cursor: move; | 232 | cursor: move; |
| ... | @@ -256,7 +240,7 @@ | ... | @@ -256,7 +240,7 @@ |
| 256 | } | 240 | } |
| 257 | } | 241 | } |
| 258 | 242 | ||
| 259 | -[tab-content] { | 243 | +[toolbox-tab-content] { |
| 260 | display: none; | 244 | display: none; |
| 261 | } | 245 | } |
| 262 | 246 | ... | ... |
| ... | @@ -52,3 +52,13 @@ table.list-table { | ... | @@ -52,3 +52,13 @@ table.list-table { |
| 52 | padding: $-xs; | 52 | padding: $-xs; |
| 53 | } | 53 | } |
| 54 | } | 54 | } |
| 55 | + | ||
| 56 | +table.file-table { | ||
| 57 | + @extend .no-style; | ||
| 58 | + td { | ||
| 59 | + padding: $-xs; | ||
| 60 | + } | ||
| 61 | + .ui-sortable-helper { | ||
| 62 | + display: table; | ||
| 63 | + } | ||
| 64 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -3,10 +3,13 @@ | ... | @@ -3,10 +3,13 @@ |
| 3 | 3 | ||
| 4 | <div class="tabs primary-background-light"> | 4 | <div class="tabs primary-background-light"> |
| 5 | <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span> | 5 | <span toolbox-toggle><i class="zmdi zmdi-caret-left-circle"></i></span> |
| 6 | - <span tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span> | 6 | + <span toolbox-tab-button="tags" title="Page Tags" class="active"><i class="zmdi zmdi-tag"></i></span> |
| 7 | + @if(userCan('file-create-all')) | ||
| 8 | + <span toolbox-tab-button="files" title="Attachments"><i class="zmdi zmdi-attachment"></i></span> | ||
| 9 | + @endif | ||
| 7 | </div> | 10 | </div> |
| 8 | 11 | ||
| 9 | - <div tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}"> | 12 | + <div toolbox-tab-content="tags" ng-controller="PageTagController" page-id="{{ $page->id or 0 }}"> |
| 10 | <h4>Page Tags</h4> | 13 | <h4>Page Tags</h4> |
| 11 | <div class="padded tags"> | 14 | <div class="padded tags"> |
| 12 | <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> | 15 | <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 @@ | ... | @@ -34,4 +37,98 @@ |
| 34 | </div> | 37 | </div> |
| 35 | </div> | 38 | </div> |
| 36 | 39 | ||
| 40 | + @if(userCan('file-create-all')) | ||
| 41 | + <div toolbox-tab-content="files" ng-controller="PageAttachmentController" page-id="{{ $page->id or 0 }}"> | ||
| 42 | + <h4>Attachments</h4> | ||
| 43 | + <div class="padded files"> | ||
| 44 | + | ||
| 45 | + <div id="file-list" ng-show="!editFile"> | ||
| 46 | + <p class="muted small">Upload some files or attach some link to display on your page. This are visible in the page sidebar.</p> | ||
| 47 | + | ||
| 48 | + <div tab-container> | ||
| 49 | + <div class="nav-tabs"> | ||
| 50 | + <div tab-button="list" class="tab-item">File List</div> | ||
| 51 | + <div tab-button="file" class="tab-item">Upload File</div> | ||
| 52 | + <div tab-button="link" class="tab-item">Attach Link</div> | ||
| 53 | + </div> | ||
| 54 | + <div tab-content="list"> | ||
| 55 | + <table class="file-table" style="width: 100%;"> | ||
| 56 | + <tbody ui-sortable="sortOptions" ng-model="files" > | ||
| 57 | + <tr ng-repeat="file in files track by $index"> | ||
| 58 | + <td width="20" ><i class="handle zmdi zmdi-menu"></i></td> | ||
| 59 | + <td> | ||
| 60 | + <a ng-href="@{{getFileUrl(file)}}" target="_blank" ng-bind="file.name"></a> | ||
| 61 | + <div ng-if="file.deleting"> | ||
| 62 | + <span class="neg small">Click delete again to confirm you want to delete this attachment.</span> | ||
| 63 | + <br> | ||
| 64 | + <span class="text-primary small" ng-click="file.deleting=false;">Cancel</span> | ||
| 65 | + </div> | ||
| 66 | + </td> | ||
| 67 | + <td width="10" ng-click="startEdit(file)" class="text-center text-primary" style="padding: 0;"><i class="zmdi zmdi-edit"></i></td> | ||
| 68 | + <td width="5"></td> | ||
| 69 | + <td width="10" ng-click="deleteFile(file)" class="text-center text-neg" style="padding: 0;"><i class="zmdi zmdi-close"></i></td> | ||
| 70 | + </tr> | ||
| 71 | + </tbody> | ||
| 72 | + </table> | ||
| 73 | + <p class="small muted" ng-if="files.length == 0"> | ||
| 74 | + No files have been uploaded. | ||
| 75 | + </p> | ||
| 76 | + </div> | ||
| 77 | + <div tab-content="file"> | ||
| 78 | + <drop-zone upload-url="@{{getUploadUrl()}}" uploaded-to="@{{uploadedTo}}" event-success="uploadSuccess"></drop-zone> | ||
| 79 | + </div> | ||
| 80 | + <div tab-content="link" sub-form="attachLinkSubmit(file)"> | ||
| 81 | + <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> | ||
| 82 | + <div class="form-group"> | ||
| 83 | + <label for="attachment-via-link">Link Name</label> | ||
| 84 | + <input type="text" placeholder="Link name" ng-model="file.name"> | ||
| 85 | + <p class="small neg" ng-repeat="error in errors.link.name" ng-bind="error"></p> | ||
| 86 | + </div> | ||
| 87 | + <div class="form-group"> | ||
| 88 | + <label for="attachment-via-link">Link to file</label> | ||
| 89 | + <input type="text" placeholder="Url of site or file" ng-model="file.link"> | ||
| 90 | + <p class="small neg" ng-repeat="error in errors.link.link" ng-bind="error"></p> | ||
| 91 | + </div> | ||
| 92 | + <button type="submit" class="button pos">Attach</button> | ||
| 93 | + | ||
| 94 | + </div> | ||
| 95 | + </div> | ||
| 96 | + | ||
| 97 | + </div> | ||
| 98 | + | ||
| 99 | + <div id="file-edit" ng-if="editFile" sub-form="updateFile(editFile)"> | ||
| 100 | + <h5>Edit File</h5> | ||
| 101 | + | ||
| 102 | + <div class="form-group"> | ||
| 103 | + <label for="attachment-name-edit">File Name</label> | ||
| 104 | + <input type="text" id="attachment-name-edit" placeholder="File name" ng-model="editFile.name"> | ||
| 105 | + <p class="small neg" ng-repeat="error in errors.edit.name" ng-bind="error"></p> | ||
| 106 | + </div> | ||
| 107 | + | ||
| 108 | + <div tab-container="@{{ editFile.external ? 'link' : 'file' }}"> | ||
| 109 | + <div class="nav-tabs"> | ||
| 110 | + <div tab-button="file" class="tab-item">Upload File</div> | ||
| 111 | + <div tab-button="link" class="tab-item">Set Link</div> | ||
| 112 | + </div> | ||
| 113 | + <div tab-content="file"> | ||
| 114 | + <drop-zone upload-url="@{{getUploadUrl(editFile)}}" uploaded-to="@{{uploadedTo}}" placeholder="Drop files or click here to upload and overwrite" event-success="uploadSuccessUpdate"></drop-zone> | ||
| 115 | + <br> | ||
| 116 | + </div> | ||
| 117 | + <div tab-content="link"> | ||
| 118 | + <div class="form-group"> | ||
| 119 | + <label for="attachment-link-edit">Link to file</label> | ||
| 120 | + <input type="text" id="attachment-link-edit" placeholder="Attachment link" ng-model="editFile.link"> | ||
| 121 | + <p class="small neg" ng-repeat="error in errors.edit.link" ng-bind="error"></p> | ||
| 122 | + </div> | ||
| 123 | + </div> | ||
| 124 | + </div> | ||
| 125 | + | ||
| 126 | + <button type="button" class="button" ng-click="cancelEdit()">Back</button> | ||
| 127 | + <button type="submit" class="button pos">Save</button> | ||
| 128 | + </div> | ||
| 129 | + | ||
| 130 | + </div> | ||
| 131 | + </div> | ||
| 132 | + @endif | ||
| 133 | + | ||
| 37 | </div> | 134 | </div> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | 1 | ||
| 2 | <div class="book-tree" ng-non-bindable> | 2 | <div class="book-tree" ng-non-bindable> |
| 3 | 3 | ||
| 4 | + @if (isset($page) && $page->files->count() > 0) | ||
| 5 | + <h6 class="text-muted">Attachments</h6> | ||
| 6 | + @foreach($page->files as $file) | ||
| 7 | + <div class="attachment"> | ||
| 8 | + <a href="{{ $file->getUrl() }}" @if($file->external) target="_blank" @endif><i class="zmdi zmdi-{{ $file->external ? 'open-in-new' : 'file' }}"></i> {{ $file->name }}</a> | ||
| 9 | + </div> | ||
| 10 | + @endforeach | ||
| 11 | + @endif | ||
| 12 | + | ||
| 4 | @if (isset($pageNav) && $pageNav) | 13 | @if (isset($pageNav) && $pageNav) |
| 5 | <h6 class="text-muted">Page Navigation</h6> | 14 | <h6 class="text-muted">Page Navigation</h6> |
| 6 | <div class="sidebar-page-nav menu"> | 15 | <div class="sidebar-page-nav menu"> |
| ... | @@ -10,8 +19,6 @@ | ... | @@ -10,8 +19,6 @@ |
| 10 | </li> | 19 | </li> |
| 11 | @endforeach | 20 | @endforeach |
| 12 | </div> | 21 | </div> |
| 13 | - | ||
| 14 | - | ||
| 15 | @endif | 22 | @endif |
| 16 | 23 | ||
| 17 | <h6 class="text-muted">Book Navigation</h6> | 24 | <h6 class="text-muted">Book Navigation</h6> | ... | ... |
| ... | @@ -14,7 +14,7 @@ | ... | @@ -14,7 +14,7 @@ |
| 14 | .nav-tabs a.selected, .nav-tabs .tab-item.selected { | 14 | .nav-tabs a.selected, .nav-tabs .tab-item.selected { |
| 15 | border-bottom-color: {{ setting('app-color') }}; | 15 | border-bottom-color: {{ setting('app-color') }}; |
| 16 | } | 16 | } |
| 17 | - 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 { | 17 | + .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 { |
| 18 | color: {{ setting('app-color') }}; | 18 | color: {{ setting('app-color') }}; |
| 19 | } | 19 | } |
| 20 | </style> | 20 | </style> |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -106,6 +106,19 @@ | ... | @@ -106,6 +106,19 @@ |
| 106 | <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label> | 106 | <label>@include('settings/roles/checkbox', ['permission' => 'image-delete-all']) All</label> |
| 107 | </td> | 107 | </td> |
| 108 | </tr> | 108 | </tr> |
| 109 | + <tr> | ||
| 110 | + <td>Attached <br>Files</td> | ||
| 111 | + <td>@include('settings/roles/checkbox', ['permission' => 'file-create-all'])</td> | ||
| 112 | + <td style="line-height:1.2;"><small class="faded">Controlled by the asset they are uploaded to</small></td> | ||
| 113 | + <td> | ||
| 114 | + <label>@include('settings/roles/checkbox', ['permission' => 'file-update-own']) Own</label> | ||
| 115 | + <label>@include('settings/roles/checkbox', ['permission' => 'file-update-all']) All</label> | ||
| 116 | + </td> | ||
| 117 | + <td> | ||
| 118 | + <label>@include('settings/roles/checkbox', ['permission' => 'file-delete-own']) Own</label> | ||
| 119 | + <label>@include('settings/roles/checkbox', ['permission' => 'file-delete-all']) All</label> | ||
| 120 | + </td> | ||
| 121 | + </tr> | ||
| 109 | </table> | 122 | </table> |
| 110 | </div> | 123 | </div> |
| 111 | </div> | 124 | </div> | ... | ... |
| ... | @@ -87,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () { | ... | @@ -87,6 +87,16 @@ Route::group(['middleware' => 'auth'], function () { |
| 87 | Route::delete('/{imageId}', 'ImageController@destroy'); | 87 | Route::delete('/{imageId}', 'ImageController@destroy'); |
| 88 | }); | 88 | }); |
| 89 | 89 | ||
| 90 | + // File routes | ||
| 91 | + Route::get('/files/{id}', 'FileController@get'); | ||
| 92 | + Route::post('/files/upload', 'FileController@upload'); | ||
| 93 | + Route::post('/files/upload/{id}', 'FileController@uploadUpdate'); | ||
| 94 | + Route::post('/files/link', 'FileController@attachLink'); | ||
| 95 | + Route::put('/files/{id}', 'FileController@update'); | ||
| 96 | + Route::get('/files/get/page/{pageId}', 'FileController@listForPage'); | ||
| 97 | + Route::put('/files/sort/page/{pageId}', 'FileController@sortForPage'); | ||
| 98 | + Route::delete('/files/{id}', 'FileController@delete'); | ||
| 99 | + | ||
| 90 | // AJAX routes | 100 | // AJAX routes |
| 91 | Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); | 101 | Route::put('/ajax/page/{id}/save-draft', 'PageController@saveDraft'); |
| 92 | Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); | 102 | Route::get('/ajax/page/{id}', 'PageController@getPageAjax'); | ... | ... |
storage/uploads/files/.gitignore
0 → 100755
tests/AttachmentTest.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +class AttachmentTest extends TestCase | ||
| 4 | +{ | ||
| 5 | + /** | ||
| 6 | + * Get a test file that can be uploaded | ||
| 7 | + * @param $fileName | ||
| 8 | + * @return \Illuminate\Http\UploadedFile | ||
| 9 | + */ | ||
| 10 | + protected function getTestFile($fileName) | ||
| 11 | + { | ||
| 12 | + return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true); | ||
| 13 | + } | ||
| 14 | + | ||
| 15 | + /** | ||
| 16 | + * Uploads a file with the given name. | ||
| 17 | + * @param $name | ||
| 18 | + * @param int $uploadedTo | ||
| 19 | + * @return string | ||
| 20 | + */ | ||
| 21 | + protected function uploadFile($name, $uploadedTo = 0) | ||
| 22 | + { | ||
| 23 | + $file = $this->getTestFile($name); | ||
| 24 | + return $this->call('POST', '/files/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []); | ||
| 25 | + } | ||
| 26 | + | ||
| 27 | + /** | ||
| 28 | + * Get the expected upload path for a file. | ||
| 29 | + * @param $fileName | ||
| 30 | + * @return string | ||
| 31 | + */ | ||
| 32 | + protected function getUploadPath($fileName) | ||
| 33 | + { | ||
| 34 | + return 'uploads/files/' . Date('Y-m-M') . '/' . $fileName; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * Delete all uploaded files. | ||
| 39 | + * To assist with cleanup. | ||
| 40 | + */ | ||
| 41 | + protected function deleteUploads() | ||
| 42 | + { | ||
| 43 | + $fileService = $this->app->make(\BookStack\Services\FileService::class); | ||
| 44 | + foreach (\BookStack\File::all() as $file) { | ||
| 45 | + $fileService->deleteFile($file); | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + public function test_file_upload() | ||
| 50 | + { | ||
| 51 | + $page = \BookStack\Page::first(); | ||
| 52 | + $this->asAdmin(); | ||
| 53 | + $admin = $this->getAdmin(); | ||
| 54 | + $fileName = 'upload_test_file.txt'; | ||
| 55 | + | ||
| 56 | + $expectedResp = [ | ||
| 57 | + 'name' => $fileName, | ||
| 58 | + 'uploaded_to'=> $page->id, | ||
| 59 | + 'extension' => 'txt', | ||
| 60 | + 'order' => 1, | ||
| 61 | + 'created_by' => $admin->id, | ||
| 62 | + 'updated_by' => $admin->id, | ||
| 63 | + 'path' => $this->getUploadPath($fileName) | ||
| 64 | + ]; | ||
| 65 | + | ||
| 66 | + $this->uploadFile($fileName, $page->id); | ||
| 67 | + $this->assertResponseOk(); | ||
| 68 | + $this->seeJsonContains($expectedResp); | ||
| 69 | + $this->seeInDatabase('files', $expectedResp); | ||
| 70 | + | ||
| 71 | + $this->deleteUploads(); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + public function test_file_display_and_access() | ||
| 75 | + { | ||
| 76 | + $page = \BookStack\Page::first(); | ||
| 77 | + $this->asAdmin(); | ||
| 78 | + $admin = $this->getAdmin(); | ||
| 79 | + $fileName = 'upload_test_file.txt'; | ||
| 80 | + | ||
| 81 | + $this->uploadFile($fileName, $page->id); | ||
| 82 | + $this->assertResponseOk(); | ||
| 83 | + $this->visit($page->getUrl()) | ||
| 84 | + ->seeLink($fileName) | ||
| 85 | + ->click($fileName) | ||
| 86 | + ->see('Hi, This is a test file for testing the upload process.'); | ||
| 87 | + | ||
| 88 | + $this->deleteUploads(); | ||
| 89 | + } | ||
| 90 | + | ||
| 91 | + public function test_attaching_link_to_page() | ||
| 92 | + { | ||
| 93 | + $page = \BookStack\Page::first(); | ||
| 94 | + $admin = $this->getAdmin(); | ||
| 95 | + $this->asAdmin(); | ||
| 96 | + | ||
| 97 | + $this->call('POST', 'files/link', [ | ||
| 98 | + 'link' => 'https://example.com', | ||
| 99 | + 'name' => 'Example Attachment Link', | ||
| 100 | + 'uploaded_to' => $page->id, | ||
| 101 | + ]); | ||
| 102 | + | ||
| 103 | + $expectedResp = [ | ||
| 104 | + 'path' => 'https://example.com', | ||
| 105 | + 'name' => 'Example Attachment Link', | ||
| 106 | + 'uploaded_to' => $page->id, | ||
| 107 | + 'created_by' => $admin->id, | ||
| 108 | + 'updated_by' => $admin->id, | ||
| 109 | + 'external' => true, | ||
| 110 | + 'order' => 1, | ||
| 111 | + 'extension' => '' | ||
| 112 | + ]; | ||
| 113 | + | ||
| 114 | + $this->assertResponseOk(); | ||
| 115 | + $this->seeJsonContains($expectedResp); | ||
| 116 | + $this->seeInDatabase('files', $expectedResp); | ||
| 117 | + | ||
| 118 | + $this->visit($page->getUrl())->seeLink('Example Attachment Link') | ||
| 119 | + ->click('Example Attachment Link')->seePageIs('https://example.com'); | ||
| 120 | + | ||
| 121 | + $this->deleteUploads(); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + public function test_attachment_updating() | ||
| 125 | + { | ||
| 126 | + $page = \BookStack\Page::first(); | ||
| 127 | + $this->asAdmin(); | ||
| 128 | + | ||
| 129 | + $this->call('POST', 'files/link', [ | ||
| 130 | + 'link' => 'https://example.com', | ||
| 131 | + 'name' => 'Example Attachment Link', | ||
| 132 | + 'uploaded_to' => $page->id, | ||
| 133 | + ]); | ||
| 134 | + | ||
| 135 | + $attachmentId = \BookStack\File::first()->id; | ||
| 136 | + | ||
| 137 | + $this->call('PUT', 'files/' . $attachmentId, [ | ||
| 138 | + 'uploaded_to' => $page->id, | ||
| 139 | + 'name' => 'My new attachment name', | ||
| 140 | + 'link' => 'https://test.example.com' | ||
| 141 | + ]); | ||
| 142 | + | ||
| 143 | + $expectedResp = [ | ||
| 144 | + 'path' => 'https://test.example.com', | ||
| 145 | + 'name' => 'My new attachment name', | ||
| 146 | + 'uploaded_to' => $page->id | ||
| 147 | + ]; | ||
| 148 | + | ||
| 149 | + $this->assertResponseOk(); | ||
| 150 | + $this->seeJsonContains($expectedResp); | ||
| 151 | + $this->seeInDatabase('files', $expectedResp); | ||
| 152 | + | ||
| 153 | + $this->deleteUploads(); | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + public function test_file_deletion() | ||
| 157 | + { | ||
| 158 | + $page = \BookStack\Page::first(); | ||
| 159 | + $this->asAdmin(); | ||
| 160 | + $fileName = 'deletion_test.txt'; | ||
| 161 | + $this->uploadFile($fileName, $page->id); | ||
| 162 | + | ||
| 163 | + $filePath = base_path('storage/' . $this->getUploadPath($fileName)); | ||
| 164 | + | ||
| 165 | + $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist'); | ||
| 166 | + | ||
| 167 | + $attachmentId = \BookStack\File::first()->id; | ||
| 168 | + $this->call('DELETE', 'files/' . $attachmentId); | ||
| 169 | + | ||
| 170 | + $this->dontSeeInDatabase('files', [ | ||
| 171 | + 'name' => $fileName | ||
| 172 | + ]); | ||
| 173 | + $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected'); | ||
| 174 | + | ||
| 175 | + $this->deleteUploads(); | ||
| 176 | + } | ||
| 177 | + | ||
| 178 | + public function test_attachment_deletion_on_page_deletion() | ||
| 179 | + { | ||
| 180 | + $page = \BookStack\Page::first(); | ||
| 181 | + $this->asAdmin(); | ||
| 182 | + $fileName = 'deletion_test.txt'; | ||
| 183 | + $this->uploadFile($fileName, $page->id); | ||
| 184 | + | ||
| 185 | + $filePath = base_path('storage/' . $this->getUploadPath($fileName)); | ||
| 186 | + | ||
| 187 | + $this->assertTrue(file_exists($filePath), 'File at path ' . $filePath . ' does not exist'); | ||
| 188 | + $this->seeInDatabase('files', [ | ||
| 189 | + 'name' => $fileName | ||
| 190 | + ]); | ||
| 191 | + | ||
| 192 | + $this->call('DELETE', $page->getUrl()); | ||
| 193 | + | ||
| 194 | + $this->dontSeeInDatabase('files', [ | ||
| 195 | + 'name' => $fileName | ||
| 196 | + ]); | ||
| 197 | + $this->assertFalse(file_exists($filePath), 'File at path ' . $filePath . ' was not deleted as expected'); | ||
| 198 | + | ||
| 199 | + $this->deleteUploads(); | ||
| 200 | + } | ||
| 201 | +} |
| ... | @@ -10,7 +10,7 @@ class ImageTest extends TestCase | ... | @@ -10,7 +10,7 @@ class ImageTest extends TestCase |
| 10 | */ | 10 | */ |
| 11 | protected function getTestImage($fileName) | 11 | protected function getTestImage($fileName) |
| 12 | { | 12 | { |
| 13 | - return new \Illuminate\Http\UploadedFile(base_path('tests/test-image.jpg'), $fileName, 'image/jpeg', 5238); | 13 | + return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-image.jpg'), $fileName, 'image/jpeg', 5238); |
| 14 | } | 14 | } |
| 15 | 15 | ||
| 16 | /** | 16 | /** | ... | ... |
tests/test-data/test-file.txt
0 → 100644
| 1 | +Hi, This is a test file for testing the upload process. | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
-
Please register or sign in to post a comment