diff --git a/app/Http/Controllers/ImageController.php b/app/Http/Controllers/ImageController.php index d78350754..e675bff0c 100644 --- a/app/Http/Controllers/ImageController.php +++ b/app/Http/Controllers/ImageController.php @@ -112,6 +112,7 @@ class ImageController extends Controller * @param string $type * @param Request $request * @return \Illuminate\Http\JsonResponse + * @throws \Exception */ public function uploadByType($type, Request $request) { @@ -120,10 +121,14 @@ class ImageController extends Controller 'file' => 'is_image' ]); + if (!$this->imageRepo->isValidType($type)) { + return $this->jsonError(trans('errors.image_upload_type_error')); + } + $imageUpload = $request->file('file'); try { - $uploadedTo = $request->filled('uploaded_to') ? $request->get('uploaded_to') : 0; + $uploadedTo = $request->get('uploaded_to', 0); $image = $this->imageRepo->saveNew($imageUpload, $type, $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); @@ -132,6 +137,73 @@ class ImageController extends Controller return response()->json($image); } + /** + * Upload a drawing to the system. + * @param Request $request + * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response + */ + public function uploadDrawing(Request $request) + { + $this->validate($request, [ + 'image' => 'required|string', + 'uploaded_to' => 'required|integer' + ]); + $this->checkPermission('image-create-all'); + $imageBase64Data = $request->get('image'); + + try { + $uploadedTo = $request->get('uploaded_to', 0); + $image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo); + } catch (ImageUploadException $e) { + return response($e->getMessage(), 500); + } + + return response()->json($image); + } + + /** + * Replace the data content of a drawing. + * @param string $id + * @param Request $request + * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response + */ + public function replaceDrawing(string $id, Request $request) + { + $this->validate($request, [ + 'image' => 'required|string' + ]); + $this->checkPermission('image-create-all'); + + $imageBase64Data = $request->get('image'); + $image = $this->imageRepo->getById($id); + $this->checkOwnablePermission('image-update', $image); + + try { + $image = $this->imageRepo->replaceDrawingContent($image, $imageBase64Data); + } catch (ImageUploadException $e) { + return response($e->getMessage(), 500); + } + + return response()->json($image); + } + + /** + * Get the content of an image based64 encoded. + * @param $id + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function getBase64Image($id) + { + $image = $this->imageRepo->getById($id); + $imageData = $this->imageRepo->getImageData($image); + if ($imageData === null) { + return $this->jsonError("Image data could not be found"); + } + return response()->json([ + 'content' => base64_encode($imageData) + ]); + } + /** * Generate a sized thumbnail for an image. * @param $id @@ -139,6 +211,8 @@ class ImageController extends Controller * @param $height * @param $crop * @return \Illuminate\Http\JsonResponse + * @throws ImageUploadException + * @throws \Exception */ public function getThumbnail($id, $width, $height, $crop) { @@ -153,6 +227,8 @@ class ImageController extends Controller * @param integer $imageId * @param Request $request * @return \Illuminate\Http\JsonResponse + * @throws ImageUploadException + * @throws \Exception */ public function update($imageId, Request $request) { diff --git a/app/Repos/ImageRepo.php b/app/Repos/ImageRepo.php index 5f04a74b1..0c15a4310 100644 --- a/app/Repos/ImageRepo.php +++ b/app/Repos/ImageRepo.php @@ -1,12 +1,9 @@ getShortName(40) . '-' . strval(time()) . '.png'; + $image = $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo); + return $image; + } + + /** + * Replace the image content of a drawing. + * @param Image $image + * @param string $base64Uri + * @return Image + * @throws \BookStack\Exceptions\ImageUploadException + */ + public function replaceDrawingContent(Image $image, string $base64Uri) + { + return $this->imageService->replaceImageDataFromBase64Uri($image, $base64Uri); + } + /** * Update the details of an image via an array of properties. * @param Image $image * @param array $updateDetails * @return Image + * @throws \BookStack\Exceptions\ImageUploadException + * @throws \Exception */ public function updateImageDetails(Image $image, $updateDetails) { @@ -170,6 +197,8 @@ class ImageRepo /** * Load thumbnails onto an image object. * @param Image $image + * @throws \BookStack\Exceptions\ImageUploadException + * @throws \Exception */ private function loadThumbs(Image $image) { @@ -188,6 +217,8 @@ class ImageRepo * @param int $height * @param bool $keepRatio * @return string + * @throws \BookStack\Exceptions\ImageUploadException + * @throws \Exception */ public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false) { @@ -199,5 +230,29 @@ class ImageRepo } } + /** + * Get the raw image data from an Image. + * @param Image $image + * @return null|string + */ + public function getImageData(Image $image) + { + try { + return $this->imageService->getImageData($image); + } catch (\Exception $exception) { + return null; + } + } + + /** + * Check if the provided image type is valid. + * @param $type + * @return bool + */ + public function isValidType($type) + { + $validTypes = ['drawing', 'gallery', 'cover', 'system', 'user']; + return in_array($type, $validTypes); + } } \ No newline at end of file diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index 43375ee09..5eea285e5 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -46,6 +46,50 @@ class ImageService extends UploadService return $this->saveNew($imageName, $imageData, $type, $uploadedTo); } + /** + * Save a new image from a uri-encoded base64 string of data. + * @param string $base64Uri + * @param string $name + * @param string $type + * @param int $uploadedTo + * @return Image + * @throws ImageUploadException + */ + public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0) + { + $splitData = explode(';base64,', $base64Uri); + if (count($splitData) < 2) { + throw new ImageUploadException("Invalid base64 image data provided"); + } + $data = base64_decode($splitData[1]); + return $this->saveNew($name, $data, $type, $uploadedTo); + } + + /** + * Replace the data for an image via a Base64 encoded string. + * @param Image $image + * @param string $base64Uri + * @return Image + * @throws ImageUploadException + */ + public function replaceImageDataFromBase64Uri(Image $image, string $base64Uri) + { + $splitData = explode(';base64,', $base64Uri); + if (count($splitData) < 2) { + throw new ImageUploadException("Invalid base64 image data provided"); + } + $data = base64_decode($splitData[1]); + $storage = $this->getStorage(); + + try { + $storage->put($image->path, $data); + } catch (Exception $e) { + throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $image->path])); + } + + return $image; + } + /** * Gets an image from url and saves it to the database. * @param $url @@ -175,6 +219,19 @@ class ImageService extends UploadService return $this->getPublicUrl($thumbFilePath); } + /** + * Get the raw data content from an image. + * @param Image $image + * @return string + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + */ + public function getImageData(Image $image) + { + $imagePath = $this->getPath($image); + $storage = $this->getStorage(); + return $storage->get($imagePath); + } + /** * Destroys an Image object along with its files and thumbnails. * @param Image $image diff --git a/config/services.php b/config/services.php index ba9be69de..8695ea91c 100644 --- a/config/services.php +++ b/config/services.php @@ -13,7 +13,12 @@ return [ | to have a conventional place to find your various credentials. | */ + + // Single option to disable non-auth external services such as Gravatar and Draw.io 'disable_services' => env('DISABLE_EXTERNAL_SERVICES', false), + 'drawio' => env('DRAWIO', !env('DISABLE_EXTERNAL_SERVICES', false)), + + 'callback_url' => env('APP_URL', false), 'mailgun' => [ diff --git a/public/system_images/drawing.svg b/public/system_images/drawing.svg new file mode 100644 index 000000000..9a9231a18 --- /dev/null +++ b/public/system_images/drawing.svg @@ -0,0 +1,107 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + diff --git a/resources/assets/js/components/markdown-editor.js b/resources/assets/js/components/markdown-editor.js index 7b051dd12..3393829cc 100644 --- a/resources/assets/js/components/markdown-editor.js +++ b/resources/assets/js/components/markdown-editor.js @@ -1,6 +1,8 @@ const MarkdownIt = require("markdown-it"); const mdTasksLists = require('markdown-it-task-lists'); -const code = require('../code'); +const code = require('../libs/code'); + +const DrawIO = require('../libs/drawio'); class MarkdownEditor { @@ -20,13 +22,26 @@ class MarkdownEditor { init() { + let lastClick = 0; + // Prevent markdown display link click redirect this.display.addEventListener('click', event => { - let link = event.target.closest('a'); - if (link === null) return; + let isDblClick = Date.now() - lastClick < 300; - event.preventDefault(); - window.open(link.getAttribute('href')); + let link = event.target.closest('a'); + if (link !== null) { + event.preventDefault(); + window.open(link.getAttribute('href')); + return; + } + + let drawing = event.target.closest('[drawio-diagram]'); + if (drawing !== null && isDblClick) { + this.actionEditDrawing(drawing); + return; + } + + lastClick = Date.now(); }); // Button actions @@ -37,6 +52,7 @@ class MarkdownEditor { let action = button.getAttribute('data-action'); if (action === 'insertImage') this.actionInsertImage(); if (action === 'insertLink') this.actionShowLinkSelector(); + if (action === 'insertDrawing') this.actionStartDrawing(); }); window.$events.listen('editor-markdown-update', value => { @@ -290,6 +306,70 @@ class MarkdownEditor { }); } + // Show draw.io if enabled and handle save. + actionStartDrawing() { + if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return; + let cursorPos = this.cm.getCursor('from'); + + DrawIO.show(() => { + return Promise.resolve(''); + }, (pngData) => { + // let id = "image-" + Math.random().toString(16).slice(2); + // let loadingImage = window.baseUrl('/loading.gif'); + let data = { + image: pngData, + uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id')) + }; + + window.$http.post(window.baseUrl('/images/drawing/upload'), data).then(resp => { + let newText = `
`; + this.cm.focus(); + this.cm.replaceSelection(newText); + this.cm.setCursor(cursorPos.line, cursorPos.ch + newText.length); + DrawIO.close(); + }).catch(err => { + window.$events.emit('error', trans('errors.image_upload_error')); + console.log(err); + }); + }); + } + + // Show draw.io if enabled and handle save. + actionEditDrawing(imgContainer) { + if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return; + let cursorPos = this.cm.getCursor('from'); + let drawingId = imgContainer.getAttribute('drawio-diagram'); + + DrawIO.show(() => { + return window.$http.get(window.baseUrl(`/images/base64/${drawingId}`)).then(resp => { + return `data:image/png;base64,${resp.data.content}`; + }); + }, (pngData) => { + + let data = { + image: pngData, + uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id')) + }; + + window.$http.put(window.baseUrl(`/images/drawing/upload/${drawingId}`), data).then(resp => { + let newText = `
`; + let newContent = this.cm.getValue().split('\n').map(line => { + if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) { + return newText; + } + return line; + }).join('\n'); + this.cm.setValue(newContent); + this.cm.setCursor(cursorPos); + this.cm.focus(); + DrawIO.close(); + }).catch(err => { + window.$events.emit('error', trans('errors.image_upload_error')); + console.log(err); + }); + }); + } + } module.exports = MarkdownEditor ; \ No newline at end of file diff --git a/resources/assets/js/code.js b/resources/assets/js/libs/code.js similarity index 100% rename from resources/assets/js/code.js rename to resources/assets/js/libs/code.js diff --git a/resources/assets/js/libs/drawio.js b/resources/assets/js/libs/drawio.js new file mode 100644 index 000000000..beb6f0d59 --- /dev/null +++ b/resources/assets/js/libs/drawio.js @@ -0,0 +1,69 @@ + +const drawIoUrl = 'https://www.draw.io/?embed=1&ui=atlas&spin=1&proto=json'; +let iFrame = null; + +let onInit, onSave; + +/** + * Show the draw.io editor. + * @param onInitCallback - Must return a promise with the xml to load for the editor. + * @param onSaveCallback - Is called with the drawing data on save. + */ +function show(onInitCallback, onSaveCallback) { + onInit = onInitCallback; + onSave = onSaveCallback; + + iFrame = document.createElement('iframe'); + iFrame.setAttribute('frameborder', '0'); + window.addEventListener('message', drawReceive); + iFrame.setAttribute('src', drawIoUrl); + iFrame.setAttribute('class', 'fullscreen'); + iFrame.style.backgroundColor = '#FFFFFF'; + document.body.appendChild(iFrame); +} + +function close() { + drawEventClose(); +} + +function drawReceive(event) { + if (!event.data || event.data.length < 1) return; + let message = JSON.parse(event.data); + if (message.event === 'init') { + drawEventInit(); + } else if (message.event === 'exit') { + drawEventClose(); + } else if (message.event === 'save') { + drawEventSave(message); + } else if (message.event === 'export') { + drawEventExport(message); + } +} + +function drawEventExport(message) { + if (onSave) { + onSave(message.data); + } +} + +function drawEventSave(message) { + drawPostMessage({action: 'export', format: 'xmlpng', xml: message.xml, spin: 'Updating drawing'}); +} + +function drawEventInit() { + if (!onInit) return; + onInit().then(xml => { + drawPostMessage({action: 'load', autosave: 1, xml: xml}); + }); +} + +function drawEventClose() { + window.removeEventListener('message', drawReceive); + if (iFrame) document.body.removeChild(iFrame); +} + +function drawPostMessage(data) { + iFrame.contentWindow.postMessage(JSON.stringify(data), '*'); +} + +module.exports = {show, close}; \ No newline at end of file diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index 904403fc1..f7bfe22cf 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -1,5 +1,6 @@ "use strict"; -const Code = require('../code'); +const Code = require('../libs/code'); +const DrawIO = require('../libs/drawio'); /** * Handle pasting images from clipboard. @@ -47,7 +48,7 @@ function uploadImageFile(file) { let formData = new FormData(); formData.append('file', file, remoteFilename); - return window.$http.post('/images/gallery/upload', formData).then(resp => (resp.data)); + return window.$http.post(window.baseUrl('/images/gallery/upload'), formData).then(resp => (resp.data)); } function registerEditorShortcuts(editor) { @@ -218,7 +219,103 @@ function codePlugin() { }); } -codePlugin(); + +function drawIoPlugin() { + + const drawIoUrl = 'https://www.draw.io/?embed=1&ui=atlas&spin=1&proto=json'; + let iframe = null; + let pageEditor = null; + let currentNode = null; + + function isDrawing(node) { + return node.hasAttribute('drawio-diagram'); + } + + function showDrawingEditor(mceEditor, selectedNode = null) { + pageEditor = mceEditor; + currentNode = selectedNode; + DrawIO.show(drawingInit, updateContent); + } + + function updateContent(pngData) { + let id = "image-" + Math.random().toString(16).slice(2); + let loadingImage = window.baseUrl('/loading.gif'); + let data = { + image: pngData, + uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id')) + }; + + // Handle updating an existing image + if (currentNode) { + DrawIO.close(); + let imgElem = currentNode.querySelector('img'); + let drawingId = currentNode.getAttribute('drawio-diagram'); + window.$http.put(window.baseUrl(`/images/drawing/upload/${drawingId}`), data).then(resp => { + pageEditor.dom.setAttrib(imgElem, 'src', `${resp.data.url}?updated=${Date.now()}`); + }).catch(err => { + window.$events.emit('error', trans('errors.image_upload_error')); + console.log(err); + }); + return; + } + + setTimeout(() => { + pageEditor.insertContent(`
`); + DrawIO.close(); + window.$http.post(window.baseUrl('/images/drawing/upload'), data).then(resp => { + pageEditor.dom.setAttrib(id, 'src', resp.data.url); + pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', resp.data.id); + }).catch(err => { + pageEditor.dom.remove(id); + window.$events.emit('error', trans('errors.image_upload_error')); + console.log(err); + }); + }, 5); + } + + + function drawingInit() { + if (!currentNode) { + return Promise.resolve(''); + } + + let drawingId = currentNode.getAttribute('drawio-diagram'); + return window.$http.get(window.baseUrl(`/images/base64/${drawingId}`)).then(resp => { + return `data:image/png;base64,${resp.data.content}`; + }); + } + + window.tinymce.PluginManager.add('drawio', function(editor, url) { + + editor.addCommand('drawio', () => { + showDrawingEditor(editor); + }); + + editor.addButton('drawio', { + tooltip: 'Drawing', + image: window.baseUrl('/system_images/drawing.svg'), + cmd: 'drawio' + }); + + editor.on('dblclick', event => { + let selectedNode = editor.selection.getNode(); + if (!isDrawing(selectedNode)) return; + showDrawingEditor(editor, selectedNode); + }); + + editor.on('SetContent', function () { + let drawings = editor.$('body > div[drawio-diagram]'); + if (!drawings.length) return; + + editor.undoManager.transact(function () { + drawings.each((index, elem) => { + elem.setAttribute('contenteditable', 'false'); + }); + }); + }); + + }); +} window.tinymce.PluginManager.add('customhr', function (editor) { editor.addCommand('InsertHorizontalRule', function () { @@ -242,7 +339,13 @@ window.tinymce.PluginManager.add('customhr', function (editor) { }); }); - +// Load plugins +let plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor"; +codePlugin(); +if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') === 'true') { + drawIoPlugin(); + plugins += ' drawio'; +} module.exports = { selector: '#html-editor', @@ -259,12 +362,12 @@ module.exports = { statusbar: false, menubar: false, paste_data_images: false, - extended_valid_elements: 'pre[*]', + extended_valid_elements: 'pre[*],svg[*],div[drawio-diagram]', automatic_uploads: false, - valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre]", - plugins: "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor", + valid_children: "-div[p|h1|h2|h3|h4|h5|h6|blockquote],+div[pre],+div[img]", + plugins: plugins, imagetools_toolbar: 'imageoptions', - toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr | removeformat code fullscreen", + toolbar: "undo redo | styleselect | bold italic underline strikethrough superscript subscript | forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table image-insert link hr drawio | removeformat code fullscreen", content_style: "body {padding-left: 15px !important; padding-right: 15px !important; margin:0!important; margin-left:auto!important;margin-right:auto!important;}", style_formats: [ {title: "Header Large", format: "h2"}, diff --git a/resources/assets/js/pages/page-show.js b/resources/assets/js/pages/page-show.js index 6af5af57d..2efaf66c6 100644 --- a/resources/assets/js/pages/page-show.js +++ b/resources/assets/js/pages/page-show.js @@ -1,5 +1,5 @@ const Clipboard = require("clipboard"); -const Code = require('../code'); +const Code = require('../libs/code'); let setupPageShow = window.setupPageShow = function (pageId) { diff --git a/resources/assets/js/vues/code-editor.js b/resources/assets/js/vues/code-editor.js index 35a98cc77..c7926cf28 100644 --- a/resources/assets/js/vues/code-editor.js +++ b/resources/assets/js/vues/code-editor.js @@ -1,4 +1,4 @@ -const codeLib = require('../code'); +const codeLib = require('../libs/code'); const methods = { show() { diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 457d30e54..97620ff3f 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -59,16 +59,21 @@ border: 1px solid #DDD; width: 50%; } - .markdown-display { - padding: 0 $-m 0; - margin-left: -1px; - overflow-y: scroll; - } - .markdown-display.page-content { +} + +.markdown-display { + padding: 0 $-m 0; + margin-left: -1px; + overflow-y: scroll; + &.page-content { margin: 0 auto; max-width: 100%; } + [drawio-diagram]:hover { + outline: 2px solid $primary; + } } + .editor-toolbar { width: 100%; padding: $-xs $-m; diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 6a80237c5..2cb72bd75 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -231,4 +231,16 @@ $btt-size: 40px; input { width: 100%; } +} + +.fullscreen { + border:0; + position:fixed; + top:0; + left:0; + right:0; + bottom:0; + width:100%; + height:100%; + z-index: 150; } \ No newline at end of file diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 4dc5ccc38..6c5dd9f77 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -162,6 +162,7 @@ return [ 'pages_md_preview' => 'Preview', 'pages_md_insert_image' => 'Insert Image', 'pages_md_insert_link' => 'Insert Entity Link', + 'pages_md_insert_drawing' => 'Insert Drawing', 'pages_not_in_chapter' => 'Page is not in a chapter', 'pages_move' => 'Move Page', 'pages_move_success' => 'Page moved to ":parentName"', diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php index 18ed63c60..bbcbdaec2 100644 --- a/resources/lang/en/errors.php +++ b/resources/lang/en/errors.php @@ -36,6 +36,7 @@ return [ 'cannot_create_thumbs' => 'The server cannot create thumbnails. Please check you have the GD PHP extension installed.', 'server_upload_limit' => 'The server does not allow uploads of this size. Please try a smaller file size.', 'image_upload_error' => 'An error occurred uploading the image', + 'image_upload_type_error' => 'The image type being uploaded is invalid', // Attachments 'attachment_page_mismatch' => 'Page mismatch during attachment update', diff --git a/resources/views/pages/form.blade.php b/resources/views/pages/form.blade.php index f450452ce..53861527b 100644 --- a/resources/views/pages/form.blade.php +++ b/resources/views/pages/form.blade.php @@ -1,5 +1,11 @@ -
+
{{ csrf_field() }} @@ -80,6 +86,10 @@
{{ trans('entities.pages_md_editor') }}
+ @if(config('services.drawio')) + +  |  + @endif  |  diff --git a/routes/web.php b/routes/web.php index 06805714d..a69e672e4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -89,13 +89,16 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/user/all/{page}', 'ImageController@getAllForUserType'); // Standard get, update and deletion for all types Route::get('/thumb/{id}/{width}/{height}/{crop}', 'ImageController@getThumbnail'); + Route::get('/base64/{id}', 'ImageController@getBase64Image'); Route::put('/update/{imageId}', 'ImageController@update'); + Route::post('/drawing/upload', 'ImageController@uploadDrawing'); + Route::put('/drawing/upload/{id}', 'ImageController@replaceDrawing'); Route::post('/{type}/upload', 'ImageController@uploadByType'); Route::get('/{type}/all', 'ImageController@getAllByType'); Route::get('/{type}/all/{page}', 'ImageController@getAllByType'); Route::get('/{type}/search/{page}', 'ImageController@searchByType'); Route::get('/gallery/{filter}/{page}', 'ImageController@getGalleryFiltered'); - Route::delete('/{imageId}', 'ImageController@destroy'); + Route::delete('/{id}', 'ImageController@destroy'); }); // Attachments routes diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php index d5c9911f8..a8ff03044 100644 --- a/tests/BrowserKitTest.php +++ b/tests/BrowserKitTest.php @@ -13,17 +13,16 @@ abstract class BrowserKitTest extends TestCase use DatabaseTransactions; - /** - * The base URL to use while testing the application. - * - * @var string - */ - protected $baseUrl = 'http://localhost'; - // Local user instances private $admin; private $editor; + /** + * The base URL to use while testing the application. + * @var string + */ + protected $baseUrl = 'http://localhost'; + public function tearDown() { \DB::disconnect(); diff --git a/tests/ImageTest.php b/tests/ImageTest.php index 3bb41138b..c75617c0e 100644 --- a/tests/ImageTest.php +++ b/tests/ImageTest.php @@ -1,7 +1,18 @@ getTestImageFilePath(), $fileName, 'image/jpeg', 5238); } /** @@ -28,13 +39,12 @@ class ImageTest extends BrowserKitTest * Uploads an image with the given name. * @param $name * @param int $uploadedTo - * @return string + * @return \Illuminate\Foundation\Testing\TestResponse */ protected function uploadImage($name, $uploadedTo = 0) { $file = $this->getTestImage($name); - $this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []); - return $this->getTestImagePath('gallery', $name); + return $this->call('POST', '/images/gallery/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []); } /** @@ -43,25 +53,31 @@ class ImageTest extends BrowserKitTest */ protected function deleteImage($relPath) { - unlink(public_path($relPath)); + $path = public_path($relPath); + if (file_exists($path)) { + unlink($path); + } } public function test_image_upload() { - $page = \BookStack\Page::first(); - $this->asAdmin(); + $page = Page::first(); $admin = $this->getAdmin(); - $imageName = 'first-image.jpg'; + $this->actingAs($admin); - $relPath = $this->uploadImage($imageName, $page->id); - $this->assertResponseOk(); + $imageName = 'first-image.png'; + $relPath = $this->getTestImagePath('gallery', $imageName); + $this->deleteImage($relPath); + + $upload = $this->uploadImage($imageName, $page->id); + $upload->assertStatus(200); $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image not found at path: '. public_path($relPath)); $this->deleteImage($relPath); - $this->seeInDatabase('images', [ + $this->assertDatabaseHas('images', [ 'url' => $this->baseUrl . $relPath, 'type' => 'gallery', 'uploaded_to' => $page->id, @@ -75,17 +91,18 @@ class ImageTest extends BrowserKitTest public function test_image_delete() { - $page = \BookStack\Page::first(); + $page = Page::first(); $this->asAdmin(); - $imageName = 'first-image.jpg'; + $imageName = 'first-image.png'; - $relPath = $this->uploadImage($imageName, $page->id); - $image = \BookStack\Image::first(); + $this->uploadImage($imageName, $page->id); + $image = Image::first(); + $relPath = $this->getTestImagePath('gallery', $imageName); - $this->call('DELETE', '/images/' . $image->id); - $this->assertResponseOk(); + $delete = $this->delete( '/images/' . $image->id); + $delete->assertStatus(200); - $this->dontSeeInDatabase('images', [ + $this->assertDatabaseMissing('images', [ 'url' => $this->baseUrl . $relPath, 'type' => 'gallery' ]); @@ -93,4 +110,78 @@ class ImageTest extends BrowserKitTest $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected'); } + public function testBase64Get() + { + $page = Page::first(); + $this->asAdmin(); + $imageName = 'first-image.png'; + + $this->uploadImage($imageName, $page->id); + $image = Image::first(); + + $imageGet = $this->getJson("/images/base64/{$image->id}"); + $imageGet->assertJson([ + 'content' => 'iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' + ]); + } + + public function test_drawing_base64_upload() + { + $page = Page::first(); + $editor = $this->getEditor(); + $this->actingAs($editor); + + $upload = $this->postJson('images/drawing/upload', [ + 'uploaded_to' => $page->id, + 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' + ]); + + $upload->assertStatus(200); + $upload->assertJson([ + 'type' => 'drawio', + 'uploaded_to' => $page->id, + 'created_by' => $editor->id, + 'updated_by' => $editor->id, + ]); + + $image = Image::where('type', '=', 'drawio')->first(); + $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path)); + + $testImageData = file_get_contents($this->getTestImageFilePath()); + $uploadedImageData = file_get_contents(public_path($image->path)); + $this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected"); + } + + public function test_drawing_replacing() + { + $page = Page::first(); + $editor = $this->getEditor(); + $this->actingAs($editor); + + $this->postJson('images/drawing/upload', [ + 'uploaded_to' => $page->id, + 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDQ4S1RUeKwAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12NctNWSAQkwMaACUvkAfCkBmjyhGl4AAAAASUVORK5CYII=' + ]); + + $image = Image::where('type', '=', 'drawio')->first(); + + $replace = $this->putJson("images/drawing/upload/{$image->id}", [ + 'image' => 'image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUHAAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=' + ]); + + $replace->assertStatus(200); + $replace->assertJson([ + 'type' => 'drawio', + 'uploaded_to' => $page->id, + 'created_by' => $editor->id, + 'updated_by' => $editor->id, + ]); + + $this->assertTrue(file_exists(public_path($image->path)), 'Uploaded image not found at path: '. public_path($image->path)); + + $testImageData = file_get_contents($this->getTestImageFilePath()); + $uploadedImageData = file_get_contents(public_path($image->path)); + $this->assertTrue($testImageData === $uploadedImageData, "Uploaded image file data does not match our test image as expected"); + } + } \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 81bd93ec4..94751b004 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -16,6 +16,12 @@ abstract class TestCase extends BaseTestCase protected $admin; protected $editor; + /** + * The base URL to use while testing the application. + * @var string + */ + protected $baseUrl = 'http://localhost'; + /** * Set the current user context to be an admin. * @return $this diff --git a/tests/test-data/test-image.jpg b/tests/test-data/test-image.jpg deleted file mode 100644 index fb8da9101..000000000 Binary files a/tests/test-data/test-image.jpg and /dev/null differ diff --git a/tests/test-data/test-image.png b/tests/test-data/test-image.png new file mode 100644 index 000000000..dd15f6e83 Binary files /dev/null and b/tests/test-data/test-image.png differ