]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'basic-pwa-support' into development
authorDan Brown <redacted>
Mon, 2 Oct 2023 14:58:07 +0000 (15:58 +0100)
committerDan Brown <redacted>
Mon, 2 Oct 2023 14:58:07 +0000 (15:58 +0100)
58 files changed:
app/Entities/Models/Book.php
app/Entities/Models/Bookshelf.php
app/Entities/Tools/ExportFormatter.php
app/Exceptions/Handler.php
app/Search/SearchOptions.php
app/Uploads/Controllers/DrawioImageController.php
app/Uploads/Controllers/GalleryImageController.php
app/Uploads/Controllers/ImageController.php
app/Uploads/Controllers/ImageGalleryApiController.php
app/Uploads/Image.php
app/Uploads/ImageRepo.php
app/Uploads/ImageResizer.php [new file with mode: 0644]
app/Uploads/ImageService.php
app/Uploads/ImageStorage.php [new file with mode: 0644]
app/Uploads/ImageStorageDisk.php [new file with mode: 0644]
app/Users/Models/User.php
app/Util/OutOfMemoryHandler.php [new file with mode: 0644]
lang/en/components.php
lang/en/errors.php
readme.md
resources/js/components/entity-selector-popup.js
resources/js/components/entity-selector.js
resources/js/components/image-manager.js
resources/js/markdown/actions.js
resources/js/wysiwyg/config.js
resources/js/wysiwyg/drop-paste-handling.js
resources/js/wysiwyg/plugins-imagemanager.js
resources/js/wysiwyg/shortcuts.js
resources/sass/_components.scss
resources/sass/_header.scss
resources/views/common/header.blade.php [deleted file]
resources/views/home/books.blade.php
resources/views/home/shelves.blade.php
resources/views/layouts/base.blade.php
resources/views/layouts/parts/custom-head.blade.php [moved from resources/views/common/custom-head.blade.php with 100% similarity]
resources/views/layouts/parts/custom-styles.blade.php [moved from resources/views/common/custom-styles.blade.php with 100% similarity]
resources/views/layouts/parts/footer.blade.php [moved from resources/views/common/footer.blade.php with 100% similarity]
resources/views/layouts/parts/header-links-start.blade.php [new file with mode: 0644]
resources/views/layouts/parts/header-links.blade.php [new file with mode: 0644]
resources/views/layouts/parts/header-logo.blade.php [new file with mode: 0644]
resources/views/layouts/parts/header-search.blade.php [new file with mode: 0644]
resources/views/layouts/parts/header-user-menu.blade.php [moved from resources/views/common/header-user-menu.blade.php with 100% similarity]
resources/views/layouts/parts/header.blade.php [new file with mode: 0644]
resources/views/layouts/parts/notifications.blade.php [moved from resources/views/common/notifications.blade.php with 100% similarity]
resources/views/layouts/parts/skip-to-content.blade.php [moved from resources/views/common/skip-to-content.blade.php with 100% similarity]
resources/views/layouts/plain.blade.php
resources/views/pages/parts/image-manager-form.blade.php
resources/views/pages/parts/image-manager-list.blade.php
routes/web.php
tests/Commands/ResetMfaCommandTest.php
tests/Entity/EntitySearchTest.php
tests/Entity/ExportTest.php
tests/Entity/SearchOptionsTest.php
tests/ErrorTest.php
tests/Helpers/FileProvider.php
tests/HomepageTest.php
tests/ThemeTest.php
tests/Uploads/ImageTest.php

index fc4556857c72a5d00ed9ec1bb68734489e973e36..f54a0bf2d6a464d859848ac1f97e674322562a5a 100644 (file)
@@ -40,26 +40,19 @@ class Book extends Entity implements HasCoverImage
 
     /**
      * Returns book cover image, if book cover not exists return default cover image.
-     *
-     * @param int $width  - Width of the image
-     * @param int $height - Height of the image
-     *
-     * @return string
      */
-    public function getBookCover($width = 440, $height = 250)
+    public function getBookCover(int $width = 440, int $height = 250): string
     {
         $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id) {
+        if (!$this->image_id || !$this->cover) {
             return $default;
         }
 
         try {
-            $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
+            return $this->cover->getThumb($width, $height, false) ?? $default;
         } catch (Exception $err) {
-            $cover = $default;
+            return $default;
         }
-
-        return $cover;
     }
 
     /**
index ad52d9d37d4f0ce7df280e7d0fd0052c1b79491b..4b44025a4c3e84eb3b00838cb4c32868e19d3b13 100644 (file)
@@ -3,6 +3,7 @@
 namespace BookStack\Entities\Models;
 
 use BookStack\Uploads\Image;
+use Exception;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -49,28 +50,21 @@ class Bookshelf extends Entity implements HasCoverImage
     }
 
     /**
-     * Returns BookShelf cover image, if cover does not exists return default cover image.
-     *
-     * @param int $width  - Width of the image
-     * @param int $height - Height of the image
-     *
-     * @return string
+     * Returns shelf cover image, if cover not exists return default cover image.
      */
-    public function getBookCover($width = 440, $height = 250)
+    public function getBookCover(int $width = 440, int $height = 250): string
     {
         // TODO - Make generic, focused on books right now, Perhaps set-up a better image
         $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
-        if (!$this->image_id) {
+        if (!$this->image_id || !$this->cover) {
             return $default;
         }
 
         try {
-            $cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
-        } catch (\Exception $err) {
-            $cover = $default;
+            return $this->cover->getThumb($width, $height, false) ?? $default;
+        } catch (Exception $err) {
+            return $default;
         }
-
-        return $cover;
     }
 
     /**
index 6779797d10b45fe336c43ec1d7521c181acb9ef6..9a8c687b08de8cf0c87166e6eec8b883bba0979c 100644 (file)
@@ -222,7 +222,7 @@ class ExportFormatter
             foreach ($imageTagsOutput[0] as $index => $imgMatch) {
                 $oldImgTagString = $imgMatch;
                 $srcString = $imageTagsOutput[2][$index];
-                $imageEncoded = $this->imageService->imageUriToBase64($srcString);
+                $imageEncoded = $this->imageService->imageUrlToBase64($srcString);
                 if ($imageEncoded === null) {
                     $imageEncoded = $srcString;
                 }
@@ -254,17 +254,20 @@ class ExportFormatter
      * Converts the page contents into simple plain text.
      * This method filters any bad looking content to provide a nice final output.
      */
-    public function pageToPlainText(Page $page): string
+    public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
     {
-        $html = (new PageContent($page))->render();
-        $text = strip_tags($html);
+        $html = $pageRendered ? $page->html : (new PageContent($page))->render();
+        // Add proceeding spaces before tags so spaces remain between
+        // text within elements after stripping tags.
+        $html = str_replace('<', " <", $html);
+        $text = trim(strip_tags($html));
         // Replace multiple spaces with single spaces
-        $text = preg_replace('/\ {2,}/', ' ', $text);
+        $text = preg_replace('/ {2,}/', ' ', $text);
         // Reduce multiple horrid whitespace characters.
         $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
         $text = html_entity_decode($text);
         // Add title
-        $text = $page->name . "\n\n" . $text;
+        $text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
 
         return $text;
     }
@@ -274,13 +277,15 @@ class ExportFormatter
      */
     public function chapterToPlainText(Chapter $chapter): string
     {
-        $text = $chapter->name . "\n\n";
-        $text .= $chapter->description . "\n\n";
+        $text = $chapter->name . "\n" . $chapter->description;
+        $text = trim($text) . "\n\n";
+
+        $parts = [];
         foreach ($chapter->getVisiblePages() as $page) {
-            $text .= $this->pageToPlainText($page);
+            $parts[] = $this->pageToPlainText($page, false, true);
         }
 
-        return $text;
+        return $text . implode("\n\n", $parts);
     }
 
     /**
@@ -288,17 +293,20 @@ class ExportFormatter
      */
     public function bookToPlainText(Book $book): string
     {
-        $bookTree = (new BookContents($book))->getTree(false, false);
-        $text = $book->name . "\n\n";
+        $bookTree = (new BookContents($book))->getTree(false, true);
+        $text = $book->name . "\n" . $book->description;
+        $text = rtrim($text) . "\n\n";
+
+        $parts = [];
         foreach ($bookTree as $bookChild) {
             if ($bookChild->isA('chapter')) {
-                $text .= $this->chapterToPlainText($bookChild);
+                $parts[] = $this->chapterToPlainText($bookChild);
             } else {
-                $text .= $this->pageToPlainText($bookChild);
+                $parts[] = $this->pageToPlainText($bookChild, true, true);
             }
         }
 
-        return $text;
+        return $text . implode("\n\n", $parts);
     }
 
     /**
index 36bdf845d64d8905e930ab04385ff184f195a267..6a44200568c96db4eeed5b71c3ffcf480a1eba39 100644 (file)
@@ -6,9 +6,11 @@ use Exception;
 use Illuminate\Auth\AuthenticationException;
 use Illuminate\Database\Eloquent\ModelNotFoundException;
 use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Illuminate\Http\Exceptions\PostTooLargeException;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
+use Symfony\Component\ErrorHandler\Error\FatalError;
 use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
 use Throwable;
 
@@ -35,6 +37,15 @@ class Handler extends ExceptionHandler
         'password_confirmation',
     ];
 
+    /**
+     * A function to run upon out of memory.
+     * If it returns a response, that will be provided back to the request
+     * upon an out of memory event.
+     *
+     * @var ?callable<?\Illuminate\Http\Response>
+     */
+    protected $onOutOfMemory = null;
+
     /**
      * Report or log an exception.
      *
@@ -59,6 +70,17 @@ class Handler extends ExceptionHandler
      */
     public function render($request, Throwable $e)
     {
+        if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
+            $response = call_user_func($this->onOutOfMemory);
+            if ($response) {
+                return $response;
+            }
+        }
+
+        if ($e instanceof PostTooLargeException) {
+            $e = new NotifyException(trans('errors.server_post_limit'), '/', 413);
+        }
+
         if ($this->isApiRequest($request)) {
             return $this->renderApiException($e);
         }
@@ -66,12 +88,30 @@ class Handler extends ExceptionHandler
         return parent::render($request, $e);
     }
 
+    /**
+     * Provide a function to be called when an out of memory event occurs.
+     * If the callable returns a response, this response will be returned
+     * to the request upon error.
+     */
+    public function prepareForOutOfMemory(callable $onOutOfMemory)
+    {
+        $this->onOutOfMemory = $onOutOfMemory;
+    }
+
+    /**
+     * Forget the current out of memory handler, if existing.
+     */
+    public function forgetOutOfMemoryHandler()
+    {
+        $this->onOutOfMemory = null;
+    }
+
     /**
      * Check if the given request is an API request.
      */
     protected function isApiRequest(Request $request): bool
     {
-        return strpos($request->path(), 'api/') === 0;
+        return str_starts_with($request->path(), 'api/');
     }
 
     /**
index af146d5fd6e5c19db5802e336611ec74e7cfa8ac..d38fc8d5751f3017fe39f1eced968b580e754d18 100644 (file)
@@ -78,7 +78,7 @@ class SearchOptions
         ];
 
         $patterns = [
-            'exacts'  => '/"(.*?)(?<!\\\)"/',
+            'exacts'  => '/"((?:\\\\.|[^"\\\\])*)"/',
             'tags'    => '/\[(.*?)\]/',
             'filters' => '/\{(.*?)\}/',
         ];
@@ -93,9 +93,9 @@ class SearchOptions
             }
         }
 
-        // Unescape exacts
+        // Unescape exacts and backslash escapes
         foreach ($terms['exacts'] as $index => $exact) {
-            $terms['exacts'][$index] = str_replace('\"', '"', $exact);
+            $terms['exacts'][$index] = static::decodeEscapes($exact);
         }
 
         // Parse standard terms
@@ -118,6 +118,28 @@ class SearchOptions
         return $terms;
     }
 
+    /**
+     * Decode backslash escaping within the input string.
+     */
+    protected static function decodeEscapes(string $input): string
+    {
+        $decoded = "";
+        $escaping = false;
+
+        foreach (str_split($input) as $char) {
+            if ($escaping) {
+                $decoded .= $char;
+                $escaping = false;
+            } else if ($char === '\\') {
+                $escaping = true;
+            } else {
+                $decoded .= $char;
+            }
+        }
+
+        return $decoded;
+    }
+
     /**
      * Parse a standard search term string into individual search terms and
      * convert any required terms to exact matches. This is done since some
@@ -156,7 +178,8 @@ class SearchOptions
         $parts = $this->searches;
 
         foreach ($this->exacts as $term) {
-            $escaped = str_replace('"', '\"', $term);
+            $escaped = str_replace('\\', '\\\\', $term);
+            $escaped = str_replace('"', '\"', $escaped);
             $parts[] = '"' . $escaped . '"';
         }
 
index 35deada883bc211576541fbdd96ec5e0d781711e..6293da4f718b24189ae5c70a92529caf4c77a94b 100644 (file)
@@ -5,23 +5,23 @@ namespace BookStack\Uploads\Controllers;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
+use BookStack\Util\OutOfMemoryHandler;
 use Exception;
 use Illuminate\Http\Request;
 
 class DrawioImageController extends Controller
 {
-    protected $imageRepo;
-
-    public function __construct(ImageRepo $imageRepo)
-    {
-        $this->imageRepo = $imageRepo;
+    public function __construct(
+        protected ImageRepo $imageRepo
+    ) {
     }
 
     /**
      * Get a list of gallery images, in a list.
      * Can be paged and filtered by entity.
      */
-    public function list(Request $request)
+    public function list(Request $request, ImageResizer $resizer)
     {
         $page = $request->get('page', 1);
         $searchTerm = $request->get('search', null);
@@ -29,11 +29,20 @@ class DrawioImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
-
-        return view('pages.parts.image-manager-list', [
+        $viewData = [
+            'warning' => '',
             'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
-        ]);
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-list', $viewData, 200);
+        });
+
+        $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+        return view('pages.parts.image-manager-list', $viewData);
     }
 
     /**
index 33d3dd74c671b323bc2c503d14d82d28998a137a..258f2bef6bda19c48e58a12b9d46280084d9997e 100644 (file)
@@ -5,7 +5,11 @@ namespace BookStack\Uploads\Controllers;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
+use BookStack\Util\OutOfMemoryHandler;
 use Illuminate\Http\Request;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Validation\ValidationException;
 
 class GalleryImageController extends Controller
@@ -19,7 +23,7 @@ class GalleryImageController extends Controller
      * Get a list of gallery images, in a list.
      * Can be paged and filtered by entity.
      */
-    public function list(Request $request)
+    public function list(Request $request, ImageResizer $resizer)
     {
         $page = $request->get('page', 1);
         $searchTerm = $request->get('search', null);
@@ -27,11 +31,20 @@ class GalleryImageController extends Controller
         $parentTypeFilter = $request->get('filter_type', null);
 
         $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
-
-        return view('pages.parts.image-manager-list', [
+        $viewData = [
+            'warning' => '',
             'images'  => $imgData['images'],
             'hasMore' => $imgData['has_more'],
-        ]);
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-list', $viewData, 200);
+        });
+
+        $resizer->loadGalleryThumbnailsForMany($imgData['images']);
+
+        return view('pages.parts.image-manager-list', $viewData);
     }
 
     /**
@@ -51,6 +64,10 @@ class GalleryImageController extends Controller
             return $this->jsonError(implode("\n", $exception->errors()['file']));
         }
 
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_upload_memory_limit'));
+        });
+
         try {
             $imageUpload = $request->file('file');
             $uploadedTo = $request->get('uploaded_to', 0);
index 2c611c515bff8746f87c868c0d5ec5f3dce1c0e4..c68ffdf6bd0361fae8f904b38499dbe0ea0a5bff 100644 (file)
@@ -4,19 +4,22 @@ namespace BookStack\Uploads\Controllers;
 
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
+use BookStack\Exceptions\NotifyException;
 use BookStack\Http\Controller;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
 use BookStack\Uploads\ImageService;
+use BookStack\Util\OutOfMemoryHandler;
 use Exception;
 use Illuminate\Http\Request;
-use Illuminate\Validation\ValidationException;
 
 class ImageController extends Controller
 {
     public function __construct(
         protected ImageRepo $imageRepo,
-        protected ImageService $imageService
+        protected ImageService $imageService,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -38,13 +41,10 @@ class ImageController extends Controller
 
     /**
      * Update image details.
-     *
-     * @throws ImageUploadException
-     * @throws ValidationException
      */
     public function update(Request $request, string $id)
     {
-        $this->validate($request, [
+        $data = $this->validate($request, [
             'name' => ['required', 'min:2', 'string'],
         ]);
 
@@ -52,9 +52,7 @@ class ImageController extends Controller
         $this->checkImagePermission($image);
         $this->checkOwnablePermission('image-update', $image);
 
-        $image = $this->imageRepo->updateImageDetails($image, $request->all());
-
-        $this->imageRepo->loadThumbs($image);
+        $image = $this->imageRepo->updateImageDetails($image, $data);
 
         return view('pages.parts.image-manager-form', [
             'image'          => $image,
@@ -76,6 +74,10 @@ class ImageController extends Controller
         $this->checkOwnablePermission('image-update', $image);
         $file = $request->file('file');
 
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_upload_memory_limit'));
+        });
+
         try {
             $this->imageRepo->updateImageFile($image, $file);
         } catch (ImageUploadException $exception) {
@@ -99,12 +101,20 @@ class ImageController extends Controller
             $dependantPages = $this->imageRepo->getPagesUsingImage($image);
         }
 
-        $this->imageRepo->loadThumbs($image);
-
-        return view('pages.parts.image-manager-form', [
+        $viewData = [
             'image'          => $image,
             'dependantPages' => $dependantPages ?? null,
-        ]);
+            'warning'        => '',
+        ];
+
+        new OutOfMemoryHandler(function () use ($viewData) {
+            $viewData['warning'] = trans('errors.image_thumbnail_memory_limit');
+            return response()->view('pages.parts.image-manager-form', $viewData);
+        });
+
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
+
+        return view('pages.parts.image-manager-form', $viewData);
     }
 
     /**
@@ -123,10 +133,29 @@ class ImageController extends Controller
         return response('');
     }
 
+    /**
+     * Rebuild the thumbnails for the given image.
+     */
+    public function rebuildThumbnails(string $id)
+    {
+        $image = $this->imageRepo->getById($id);
+        $this->checkImagePermission($image);
+        $this->checkOwnablePermission('image-update', $image);
+
+        new OutOfMemoryHandler(function () {
+            return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
+        });
+
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
+
+        return response(trans('components.image_rebuild_thumbs_success'));
+    }
+
     /**
      * Check related page permission and ensure type is drawio or gallery.
+     * @throws NotifyException
      */
-    protected function checkImagePermission(Image $image)
+    protected function checkImagePermission(Image $image): void
     {
         if ($image->type !== 'drawio' && $image->type !== 'gallery') {
             $this->showPermissionError();
index 0d35d2905f71dbeda2302539760baff2119a36c2..ec96e4593bc2fa48f71e0e221a5c27be69278156 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
 use BookStack\Http\ApiController;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Uploads\ImageResizer;
 use Illuminate\Http\Request;
 
 class ImageGalleryApiController extends ApiController
@@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
     ];
 
     public function __construct(
-        protected ImageRepo $imageRepo
+        protected ImageRepo $imageRepo,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -130,7 +132,7 @@ class ImageGalleryApiController extends ApiController
      */
     protected function formatForSingleResponse(Image $image): array
     {
-        $this->imageRepo->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
         $data = $image->toArray();
         $data['created_by'] = $image->createdBy;
         $data['updated_by'] = $image->updatedBy;
@@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
 
         $escapedUrl = htmlentities($image->url);
         $escapedName = htmlentities($image->name);
+
         if ($image->type === 'drawio') {
             $data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
             $data['content']['markdown'] = $data['content']['html'];
index 9f571693a0ab68830faedc90c212d8e47e5d6b47..0a267a64465ff623ae92c001cd68caf4666f38ef 100644 (file)
@@ -45,13 +45,14 @@ class Image extends Model
     }
 
     /**
-     * Get a thumbnail for this image.
+     * Get a thumbnail URL for this image.
+     * Attempts to generate the thumbnail if not already existing.
      *
      * @throws \Exception
      */
-    public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string
+    public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string
     {
-        return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio);
+        return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false);
     }
 
     /**
index 5507933f3fafbbb8e9025a52eb0674ce12b4c8ca..0e312d8832730b678e2c35502604966247037761 100644 (file)
@@ -13,7 +13,8 @@ class ImageRepo
 {
     public function __construct(
         protected ImageService $imageService,
-        protected PermissionApplicator $permissions
+        protected PermissionApplicator $permissions,
+        protected ImageResizer $imageResizer,
     ) {
     }
 
@@ -29,19 +30,13 @@ class ImageRepo
      * Execute a paginated query, returning in a standard format.
      * Also runs the query through the restriction system.
      */
-    private function returnPaginated($query, $page = 1, $pageSize = 24): array
+    protected function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
     {
         $images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get();
-        $hasMore = count($images) > $pageSize;
-
-        $returnImages = $images->take($pageSize);
-        $returnImages->each(function (Image $image) {
-            $this->loadThumbs($image);
-        });
 
         return [
-            'images'   => $returnImages,
-            'has_more' => $hasMore,
+            'images'   => $images->take($pageSize),
+            'has_more' => count($images) > $pageSize,
         ];
     }
 
@@ -119,7 +114,7 @@ class ImageRepo
         $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
 
         if ($type !== 'system') {
-            $this->loadThumbs($image);
+            $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
         }
 
         return $image;
@@ -133,7 +128,7 @@ class ImageRepo
     public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
         $image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
-        $this->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
 
         return $image;
     }
@@ -160,7 +155,7 @@ class ImageRepo
         $image->fill($updateDetails);
         $image->updated_by = user()->id;
         $image->save();
-        $this->loadThumbs($image);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
 
         return $image;
     }
@@ -179,8 +174,9 @@ class ImageRepo
         $image->updated_by = user()->id;
         $image->touch();
         $image->save();
+
         $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
-        $this->loadThumbs($image, true);
+        $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
     }
 
     /**
@@ -212,31 +208,6 @@ class ImageRepo
         }
     }
 
-    /**
-     * Load thumbnails onto an image object.
-     */
-    public function loadThumbs(Image $image, bool $forceCreate = false): void
-    {
-        $image->setAttribute('thumbs', [
-            'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
-            'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
-        ]);
-    }
-
-    /**
-     * Get the thumbnail for an image.
-     * If $keepRatio is true only the width will be used.
-     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
-     */
-    protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
-    {
-        try {
-            return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
-        } catch (Exception $exception) {
-            return null;
-        }
-    }
-
     /**
      * Get the raw image data from an Image.
      */
diff --git a/app/Uploads/ImageResizer.php b/app/Uploads/ImageResizer.php
new file mode 100644 (file)
index 0000000..0d090a9
--- /dev/null
@@ -0,0 +1,206 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use BookStack\Exceptions\ImageUploadException;
+use Exception;
+use GuzzleHttp\Psr7\Utils;
+use Illuminate\Support\Facades\Cache;
+use Intervention\Image\Image as InterventionImage;
+use Intervention\Image\ImageManager;
+
+class ImageResizer
+{
+    protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
+
+    public function __construct(
+        protected ImageManager $intervention,
+        protected ImageStorage $storage,
+    ) {
+    }
+
+    /**
+     * Load gallery thumbnails for a set of images.
+     * @param iterable<Image> $images
+     */
+    public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
+    {
+        foreach ($images as $image) {
+            $this->loadGalleryThumbnailsForImage($image, $shouldCreate);
+        }
+    }
+
+    /**
+     * Load gallery thumbnails into the given image instance.
+     */
+    public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
+    {
+        $thumbs = ['gallery' => null, 'display' => null];
+
+        try {
+            $thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
+            $thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
+        } catch (Exception $exception) {
+            // Prevent thumbnail errors from stopping execution
+        }
+
+        $image->setAttribute('thumbs', $thumbs);
+    }
+
+    /**
+     * Get the thumbnail for an image.
+     * If $keepRatio is true only the width will be used.
+     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
+     *
+     * @throws Exception
+     */
+    public function resizeToThumbnailUrl(
+        Image $image,
+        ?int $width,
+        ?int $height,
+        bool $keepRatio = false,
+        bool $shouldCreate = false
+    ): ?string {
+        // Do not resize GIF images where we're not cropping
+        if ($keepRatio && $this->isGif($image)) {
+            return $this->storage->getPublicUrl($image->path);
+        }
+
+        $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
+        $imagePath = $image->path;
+        $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
+
+        $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
+
+        // Return path if in cache
+        $cachedThumbPath = Cache::get($thumbCacheKey);
+        if ($cachedThumbPath && !$shouldCreate) {
+            return $this->storage->getPublicUrl($cachedThumbPath);
+        }
+
+        // If thumbnail has already been generated, serve that and cache path
+        $disk = $this->storage->getDisk($image->type);
+        if (!$shouldCreate && $disk->exists($thumbFilePath)) {
+            Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
+
+            return $this->storage->getPublicUrl($thumbFilePath);
+        }
+
+        $imageData = $disk->get($imagePath);
+
+        // Do not resize apng images where we're not cropping
+        if ($keepRatio && $this->isApngData($image, $imageData)) {
+            Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
+
+            return $this->storage->getPublicUrl($image->path);
+        }
+
+        // If not in cache and thumbnail does not exist, generate thumb and cache path
+        $thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
+        $disk->put($thumbFilePath, $thumbData, true);
+        Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
+
+        return $this->storage->getPublicUrl($thumbFilePath);
+    }
+
+    /**
+     * Resize the image of given data to the specified size, and return the new image data.
+     *
+     * @throws ImageUploadException
+     */
+    public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
+    {
+        try {
+            $thumb = $this->intervention->make($imageData);
+        } catch (Exception $e) {
+            throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
+        }
+
+        $this->orientImageToOriginalExif($thumb, $imageData);
+
+        if ($keepRatio) {
+            $thumb->resize($width, $height, function ($constraint) {
+                $constraint->aspectRatio();
+                $constraint->upsize();
+            });
+        } else {
+            $thumb->fit($width, $height);
+        }
+
+        $thumbData = (string) $thumb->encode();
+
+        // Use original image data if we're keeping the ratio
+        // and the resizing does not save any space.
+        if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
+            return $imageData;
+        }
+
+        return $thumbData;
+    }
+
+    /**
+     * Orientate the given intervention image based upon the given original image data.
+     * Intervention does have an `orientate` method but the exif data it needs is lost before it
+     * can be used (At least when created using binary string data) so we need to do some
+     * implementation on our side to use the original image data.
+     * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
+     * Copyright (c) Oliver Vogel, MIT License.
+     */
+    protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
+    {
+        if (!extension_loaded('exif')) {
+            return;
+        }
+
+        $stream = Utils::streamFor($originalData)->detach();
+        $exif = @exif_read_data($stream);
+        $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
+
+        switch ($orientation) {
+            case 2:
+                $image->flip();
+                break;
+            case 3:
+                $image->rotate(180);
+                break;
+            case 4:
+                $image->rotate(180)->flip();
+                break;
+            case 5:
+                $image->rotate(270)->flip();
+                break;
+            case 6:
+                $image->rotate(270);
+                break;
+            case 7:
+                $image->rotate(90)->flip();
+                break;
+            case 8:
+                $image->rotate(90);
+                break;
+        }
+    }
+
+    /**
+     * Checks if the image is a gif. Returns true if it is, else false.
+     */
+    protected function isGif(Image $image): bool
+    {
+        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
+    }
+
+    /**
+     * Check if the given image and image data is apng.
+     */
+    protected function isApngData(Image $image, string &$imageData): bool
+    {
+        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
+        if (!$isPng) {
+            return false;
+        }
+
+        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
+
+        return str_contains($initialHeader, 'acTL');
+    }
+}
index 66596a57f0980fc19da26a7eeaf0d5efb1458b6a..1655a4cc3aa0d9c74a12f355a8cbb6880f011711 100644 (file)
@@ -6,109 +6,27 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Page;
 use BookStack\Exceptions\ImageUploadException;
-use ErrorException;
 use Exception;
-use GuzzleHttp\Psr7\Utils;
-use Illuminate\Contracts\Cache\Repository as Cache;
-use Illuminate\Contracts\Filesystem\FileNotFoundException;
-use Illuminate\Contracts\Filesystem\Filesystem as Storage;
-use Illuminate\Filesystem\FilesystemAdapter;
-use Illuminate\Filesystem\FilesystemManager;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
-use Intervention\Image\Exception\NotSupportedException;
-use Intervention\Image\Image as InterventionImage;
-use Intervention\Image\ImageManager;
-use League\Flysystem\WhitespacePathNormalizer;
-use Psr\SimpleCache\InvalidArgumentException;
 use Symfony\Component\HttpFoundation\File\UploadedFile;
 use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ImageService
 {
-    protected ImageManager $imageTool;
-    protected Cache $cache;
-    protected FilesystemManager $fileSystem;
-
     protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
 
-    public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
-    {
-        $this->imageTool = $imageTool;
-        $this->fileSystem = $fileSystem;
-        $this->cache = $cache;
-    }
-
-    /**
-     * Get the storage that will be used for storing images.
-     */
-    protected function getStorageDisk(string $imageType = ''): Storage
-    {
-        return $this->fileSystem->disk($this->getStorageDiskName($imageType));
-    }
-
-    /**
-     * Check if local secure image storage (Fetched behind authentication)
-     * is currently active in the instance.
-     */
-    protected function usingSecureImages(string $imageType = 'gallery'): bool
-    {
-        return $this->getStorageDiskName($imageType) === 'local_secure_images';
-    }
-
-    /**
-     * Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
-     * is currently active in the instance.
-     */
-    protected function usingSecureRestrictedImages()
-    {
-        return config('filesystems.images') === 'local_secure_restricted';
-    }
-
-    /**
-     * Change the originally provided path to fit any disk-specific requirements.
-     * This also ensures the path is kept to the expected root folders.
-     */
-    protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
-    {
-        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
-
-        if ($this->usingSecureImages($imageType)) {
-            return $path;
-        }
-
-        return 'uploads/images/' . $path;
-    }
-
-    /**
-     * Get the name of the storage disk to use.
-     */
-    protected function getStorageDiskName(string $imageType): string
-    {
-        $storageType = config('filesystems.images');
-        $localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
-
-        // Ensure system images (App logo) are uploaded to a public space
-        if ($imageType === 'system' && $localSecureInUse) {
-            return 'local';
-        }
-
-        // Rename local_secure options to get our image specific storage driver which
-        // is scoped to the relevant image directories.
-        if ($localSecureInUse) {
-            return 'local_secure_images';
-        }
-
-        return $storageType;
+    public function __construct(
+        protected ImageStorage $storage,
+        protected ImageResizer $resizer,
+    ) {
     }
 
     /**
      * Saves a new image from an upload.
      *
      * @throws ImageUploadException
-     *
-     * @return mixed
      */
     public function saveNewFromUpload(
         UploadedFile $uploadedFile,
@@ -117,12 +35,12 @@ class ImageService
         int $resizeWidth = null,
         int $resizeHeight = null,
         bool $keepRatio = true
-    ) {
+    ): Image {
         $imageName = $uploadedFile->getClientOriginalName();
         $imageData = file_get_contents($uploadedFile->getRealPath());
 
         if ($resizeWidth !== null || $resizeHeight !== null) {
-            $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
+            $imageData = $this->resizer->resizeImageData($imageData, $resizeWidth, $resizeHeight, $keepRatio);
         }
 
         return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
@@ -151,13 +69,13 @@ class ImageService
      */
     public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
     {
-        $storage = $this->getStorageDisk($type);
+        $disk = $this->storage->getDisk($type);
         $secureUploads = setting('app-secure-images');
-        $fileName = $this->cleanImageFileName($imageName);
+        $fileName = $this->storage->cleanImageFileName($imageName);
 
         $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
 
-        while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
+        while ($disk->exists($imagePath . $fileName)) {
             $fileName = Str::random(3) . $fileName;
         }
 
@@ -167,7 +85,7 @@ class ImageService
         }
 
         try {
-            $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
+            $disk->put($fullPath, $imageData, true);
         } catch (Exception $e) {
             Log::error('Error when attempting image upload:' . $e->getMessage());
 
@@ -177,7 +95,7 @@ class ImageService
         $imageDetails = [
             'name'        => $imageName,
             'path'        => $fullPath,
-            'url'         => $this->getPublicUrl($fullPath),
+            'url'         => $this->storage->getPublicUrl($fullPath),
             'type'        => $type,
             'uploaded_to' => $uploadedTo,
         ];
@@ -194,214 +112,26 @@ class ImageService
         return $image;
     }
 
-    public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
-    {
-        $imageData = file_get_contents($file->getRealPath());
-        $storage = $this->getStorageDisk($type);
-        $adjustedPath = $this->adjustPathForStorageDisk($path, $type);
-        $storage->put($adjustedPath, $imageData);
-    }
-
-    /**
-     * Save image data for the given path in the public space, if possible,
-     * for the provided storage mechanism.
-     */
-    protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
-    {
-        $storage->put($path, $data);
-
-        // Set visibility when a non-AWS-s3, s3-like storage option is in use.
-        // Done since this call can break s3-like services but desired for other image stores.
-        // Attempting to set ACL during above put request requires different permissions
-        // hence would technically be a breaking change for actual s3 usage.
-        $usingS3 = strtolower(config('filesystems.images')) === 's3';
-        $usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
-        if (!$usingS3Like) {
-            $storage->setVisibility($path, 'public');
-        }
-    }
-
-    /**
-     * Clean up an image file name to be both URL and storage safe.
-     */
-    protected function cleanImageFileName(string $name): string
-    {
-        $name = str_replace(' ', '-', $name);
-        $nameParts = explode('.', $name);
-        $extension = array_pop($nameParts);
-        $name = implode('-', $nameParts);
-        $name = Str::slug($name);
-
-        if (strlen($name) === 0) {
-            $name = Str::random(10);
-        }
-
-        return $name . '.' . $extension;
-    }
-
-    /**
-     * Checks if the image is a gif. Returns true if it is, else false.
-     */
-    protected function isGif(Image $image): bool
-    {
-        return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
-    }
-
-    /**
-     * Check if the given image and image data is apng.
-     */
-    protected function isApngData(Image $image, string &$imageData): bool
-    {
-        $isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
-        if (!$isPng) {
-            return false;
-        }
-
-        $initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
-
-        return strpos($initialHeader, 'acTL') !== false;
-    }
-
     /**
-     * Get the thumbnail for an image.
-     * If $keepRatio is true only the width will be used.
-     * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
-     *
-     * @throws Exception
-     * @throws InvalidArgumentException
+     * Replace an existing image file in the system using the given file.
      */
-    public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
-    {
-        // Do not resize GIF images where we're not cropping
-        if ($keepRatio && $this->isGif($image)) {
-            return $this->getPublicUrl($image->path);
-        }
-
-        $thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
-        $imagePath = $image->path;
-        $thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
-
-        $thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
-
-        // Return path if in cache
-        $cachedThumbPath = $this->cache->get($thumbCacheKey);
-        if ($cachedThumbPath && !$forceCreate) {
-            return $this->getPublicUrl($cachedThumbPath);
-        }
-
-        // If thumbnail has already been generated, serve that and cache path
-        $storage = $this->getStorageDisk($image->type);
-        if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
-            $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
-
-            return $this->getPublicUrl($thumbFilePath);
-        }
-
-        $imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
-
-        // Do not resize apng images where we're not cropping
-        if ($keepRatio && $this->isApngData($image, $imageData)) {
-            $this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
-
-            return $this->getPublicUrl($image->path);
-        }
-
-        // If not in cache and thumbnail does not exist, generate thumb and cache path
-        $thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
-        $this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
-        $this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
-
-        return $this->getPublicUrl($thumbFilePath);
-    }
-
-    /**
-     * Resize the image of given data to the specified size, and return the new image data.
-     *
-     * @throws ImageUploadException
-     */
-    protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
-    {
-        try {
-            $thumb = $this->imageTool->make($imageData);
-        } catch (ErrorException | NotSupportedException $e) {
-            throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
-        }
-
-        $this->orientImageToOriginalExif($thumb, $imageData);
-
-        if ($keepRatio) {
-            $thumb->resize($width, $height, function ($constraint) {
-                $constraint->aspectRatio();
-                $constraint->upsize();
-            });
-        } else {
-            $thumb->fit($width, $height);
-        }
-
-        $thumbData = (string) $thumb->encode();
-
-        // Use original image data if we're keeping the ratio
-        // and the resizing does not save any space.
-        if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
-            return $imageData;
-        }
-
-        return $thumbData;
-    }
-
-    /**
-     * Orientate the given intervention image based upon the given original image data.
-     * Intervention does have an `orientate` method but the exif data it needs is lost before it
-     * can be used (At least when created using binary string data) so we need to do some
-     * implementation on our side to use the original image data.
-     * Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
-     * Copyright (c) Oliver Vogel, MIT License.
-     */
-    protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
+    public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
     {
-        if (!extension_loaded('exif')) {
-            return;
-        }
-
-        $stream = Utils::streamFor($originalData)->detach();
-        $exif = @exif_read_data($stream);
-        $orientation = $exif ? ($exif['Orientation'] ?? null) : null;
-
-        switch ($orientation) {
-            case 2:
-                $image->flip();
-                break;
-            case 3:
-                $image->rotate(180);
-                break;
-            case 4:
-                $image->rotate(180)->flip();
-                break;
-            case 5:
-                $image->rotate(270)->flip();
-                break;
-            case 6:
-                $image->rotate(270);
-                break;
-            case 7:
-                $image->rotate(90)->flip();
-                break;
-            case 8:
-                $image->rotate(90);
-                break;
-        }
+        $imageData = file_get_contents($file->getRealPath());
+        $disk = $this->storage->getDisk($type);
+        $disk->put($path, $imageData);
     }
 
     /**
      * Get the raw data content from an image.
      *
-     * @throws FileNotFoundException
+     * @throws Exception
      */
     public function getImageData(Image $image): string
     {
-        $storage = $this->getStorageDisk();
+        $disk = $this->storage->getDisk();
 
-        return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
+        return $disk->get($image->path);
     }
 
     /**
@@ -409,53 +139,13 @@ class ImageService
      *
      * @throws Exception
      */
-    public function destroy(Image $image)
+    public function destroy(Image $image): void
     {
-        $this->destroyImagesFromPath($image->path, $image->type);
+        $disk = $this->storage->getDisk($image->type);
+        $disk->destroyAllMatchingNameFromPath($image->path);
         $image->delete();
     }
 
-    /**
-     * Destroys an image at the given path.
-     * Searches for image thumbnails in addition to main provided path.
-     */
-    protected function destroyImagesFromPath(string $path, string $imageType): bool
-    {
-        $path = $this->adjustPathForStorageDisk($path, $imageType);
-        $storage = $this->getStorageDisk($imageType);
-
-        $imageFolder = dirname($path);
-        $imageFileName = basename($path);
-        $allImages = collect($storage->allFiles($imageFolder));
-
-        // Delete image files
-        $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
-            return basename($imagePath) === $imageFileName;
-        });
-        $storage->delete($imagesToDelete->all());
-
-        // Cleanup of empty folders
-        $foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
-        foreach ($foldersInvolved as $directory) {
-            if ($this->isFolderEmpty($storage, $directory)) {
-                $storage->deleteDirectory($directory);
-            }
-        }
-
-        return true;
-    }
-
-    /**
-     * Check whether a folder is empty.
-     */
-    protected function isFolderEmpty(Storage $storage, string $path): bool
-    {
-        $files = $storage->files($path);
-        $folders = $storage->directories($path);
-
-        return count($files) === 0 && count($folders) === 0;
-    }
-
     /**
      * Delete gallery and drawings that are not within HTML content of pages or page revisions.
      * Checks based off of only the image name.
@@ -463,7 +153,7 @@ class ImageService
      *
      * Returns the path of the images that would be/have been deleted.
      */
-    public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
+    public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
     {
         $types = ['gallery', 'drawio'];
         $deletedPaths = [];
@@ -499,36 +189,32 @@ class ImageService
      * Attempts to convert the URL to a system storage url then
      * fetch the data from the disk or storage location.
      * Returns null if the image data cannot be fetched from storage.
-     *
-     * @throws FileNotFoundException
      */
-    public function imageUriToBase64(string $uri): ?string
+    public function imageUrlToBase64(string $url): ?string
     {
-        $storagePath = $this->imageUrlToStoragePath($uri);
-        if (empty($uri) || is_null($storagePath)) {
+        $storagePath = $this->storage->urlToPath($url);
+        if (empty($url) || is_null($storagePath)) {
             return null;
         }
 
-        $storagePath = $this->adjustPathForStorageDisk($storagePath);
-
         // Apply access control when local_secure_restricted images are active
-        if ($this->usingSecureRestrictedImages()) {
+        if ($this->storage->usingSecureRestrictedImages()) {
             if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
                 return null;
             }
         }
 
-        $storage = $this->getStorageDisk();
+        $disk = $this->storage->getDisk();
         $imageData = null;
-        if ($storage->exists($storagePath)) {
-            $imageData = $storage->get($storagePath);
+        if ($disk->exists($storagePath)) {
+            $imageData = $disk->get($storagePath);
         }
 
         if (is_null($imageData)) {
             return null;
         }
 
-        $extension = pathinfo($uri, PATHINFO_EXTENSION);
+        $extension = pathinfo($url, PATHINFO_EXTENSION);
         if ($extension === 'svg') {
             $extension = 'svg+xml';
         }
@@ -543,20 +229,18 @@ class ImageService
      */
     public function pathAccessibleInLocalSecure(string $imagePath): bool
     {
-        /** @var FilesystemAdapter $disk */
-        $disk = $this->getStorageDisk('gallery');
+        $disk = $this->storage->getDisk('gallery');
 
-        if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
+        if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
             return false;
         }
 
         // Check local_secure is active
-        return $this->usingSecureImages()
-            && $disk instanceof FilesystemAdapter
+        return $disk->usingSecureImages()
             // Check the image file exists
             && $disk->exists($imagePath)
             // Check the file is likely an image file
-            && strpos($disk->mimeType($imagePath), 'image/') === 0;
+            && str_starts_with($disk->mimeType($imagePath), 'image/');
     }
 
     /**
@@ -565,14 +249,14 @@ class ImageService
      */
     protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
     {
-        if (strpos($path, '/uploads/images/') === 0) {
+        if (str_starts_with($path, 'uploads/images/')) {
             $path = substr($path, 15);
         }
 
         // Strip thumbnail element from path if existing
         $originalPathSplit = array_filter(explode('/', $path), function (string $part) {
-            $resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0);
-            $missingExtension = strpos($part, '.') === false;
+            $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
+            $missingExtension = !str_contains($part, '.');
 
             return !($resizedDir && $missingExtension);
         });
@@ -613,7 +297,7 @@ class ImageService
      */
     public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
     {
-        $disk = $this->getStorageDisk($imageType);
+        $disk = $this->storage->getDisk($imageType);
 
         return $disk->response($path);
     }
@@ -627,64 +311,4 @@ class ImageService
     {
         return in_array($extension, static::$supportedExtensions);
     }
-
-    /**
-     * Get a storage path for the given image URL.
-     * Ensures the path will start with "uploads/images".
-     * Returns null if the url cannot be resolved to a local URL.
-     */
-    private function imageUrlToStoragePath(string $url): ?string
-    {
-        $url = ltrim(trim($url), '/');
-
-        // Handle potential relative paths
-        $isRelative = strpos($url, 'http') !== 0;
-        if ($isRelative) {
-            if (strpos(strtolower($url), 'uploads/images') === 0) {
-                return trim($url, '/');
-            }
-
-            return null;
-        }
-
-        // Handle local images based on paths on the same domain
-        $potentialHostPaths = [
-            url('uploads/images/'),
-            $this->getPublicUrl('/uploads/images/'),
-        ];
-
-        foreach ($potentialHostPaths as $potentialBasePath) {
-            $potentialBasePath = strtolower($potentialBasePath);
-            if (strpos(strtolower($url), $potentialBasePath) === 0) {
-                return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
-            }
-        }
-
-        return null;
-    }
-
-    /**
-     * Gets a public facing url for an image by checking relevant environment variables.
-     * If s3-style store is in use it will default to guessing a public bucket URL.
-     */
-    private function getPublicUrl(string $filePath): string
-    {
-        $storageUrl = config('filesystems.url');
-
-        // Get the standard public s3 url if s3 is set as storage type
-        // Uses the nice, short URL if bucket name has no periods in otherwise the longer
-        // region-based url will be used to prevent http issues.
-        if (!$storageUrl && config('filesystems.images') === 's3') {
-            $storageDetails = config('filesystems.disks.s3');
-            if (strpos($storageDetails['bucket'], '.') === false) {
-                $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
-            } else {
-                $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
-            }
-        }
-
-        $basePath = $storageUrl ?: url('/');
-
-        return rtrim($basePath, '/') . $filePath;
-    }
 }
diff --git a/app/Uploads/ImageStorage.php b/app/Uploads/ImageStorage.php
new file mode 100644 (file)
index 0000000..dc4abc0
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use Illuminate\Filesystem\FilesystemManager;
+use Illuminate\Support\Str;
+
+class ImageStorage
+{
+    public function __construct(
+        protected FilesystemManager $fileSystem,
+    ) {
+    }
+
+    /**
+     * Get the storage disk for the given image type.
+     */
+    public function getDisk(string $imageType = ''): ImageStorageDisk
+    {
+        $diskName = $this->getDiskName($imageType);
+
+        return new ImageStorageDisk(
+            $diskName,
+            $this->fileSystem->disk($diskName),
+        );
+    }
+
+    /**
+     * Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
+     * is currently active in the instance.
+     */
+    public function usingSecureRestrictedImages(): bool
+    {
+        return config('filesystems.images') === 'local_secure_restricted';
+    }
+
+    /**
+     * Clean up an image file name to be both URL and storage safe.
+     */
+    public function cleanImageFileName(string $name): string
+    {
+        $name = str_replace(' ', '-', $name);
+        $nameParts = explode('.', $name);
+        $extension = array_pop($nameParts);
+        $name = implode('-', $nameParts);
+        $name = Str::slug($name);
+
+        if (strlen($name) === 0) {
+            $name = Str::random(10);
+        }
+
+        return $name . '.' . $extension;
+    }
+
+    /**
+     * Get the name of the storage disk to use.
+     */
+    protected function getDiskName(string $imageType): string
+    {
+        $storageType = strtolower(config('filesystems.images'));
+        $localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
+
+        // Ensure system images (App logo) are uploaded to a public space
+        if ($imageType === 'system' && $localSecureInUse) {
+            return 'local';
+        }
+
+        // Rename local_secure options to get our image specific storage driver which
+        // is scoped to the relevant image directories.
+        if ($localSecureInUse) {
+            return 'local_secure_images';
+        }
+
+        return $storageType;
+    }
+
+    /**
+     * Get a storage path for the given image URL.
+     * Ensures the path will start with "uploads/images".
+     * Returns null if the url cannot be resolved to a local URL.
+     */
+    public function urlToPath(string $url): ?string
+    {
+        $url = ltrim(trim($url), '/');
+
+        // Handle potential relative paths
+        $isRelative = !str_starts_with($url, 'http');
+        if ($isRelative) {
+            if (str_starts_with(strtolower($url), 'uploads/images')) {
+                return trim($url, '/');
+            }
+
+            return null;
+        }
+
+        // Handle local images based on paths on the same domain
+        $potentialHostPaths = [
+            url('uploads/images/'),
+            $this->getPublicUrl('/uploads/images/'),
+        ];
+
+        foreach ($potentialHostPaths as $potentialBasePath) {
+            $potentialBasePath = strtolower($potentialBasePath);
+            if (str_starts_with(strtolower($url), $potentialBasePath)) {
+                return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Gets a public facing url for an image by checking relevant environment variables.
+     * If s3-style store is in use it will default to guessing a public bucket URL.
+     */
+    public function getPublicUrl(string $filePath): string
+    {
+        $storageUrl = config('filesystems.url');
+
+        // Get the standard public s3 url if s3 is set as storage type
+        // Uses the nice, short URL if bucket name has no periods in otherwise the longer
+        // region-based url will be used to prevent http issues.
+        if (!$storageUrl && config('filesystems.images') === 's3') {
+            $storageDetails = config('filesystems.disks.s3');
+            if (!str_contains($storageDetails['bucket'], '.')) {
+                $storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
+            } else {
+                $storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
+            }
+        }
+
+        $basePath = $storageUrl ?: url('/');
+
+        return rtrim($basePath, '/') . $filePath;
+    }
+}
diff --git a/app/Uploads/ImageStorageDisk.php b/app/Uploads/ImageStorageDisk.php
new file mode 100644 (file)
index 0000000..798b72a
--- /dev/null
@@ -0,0 +1,140 @@
+<?php
+
+namespace BookStack\Uploads;
+
+use Illuminate\Contracts\Filesystem\Filesystem;
+use Illuminate\Filesystem\FilesystemAdapter;
+use League\Flysystem\WhitespacePathNormalizer;
+use Symfony\Component\HttpFoundation\StreamedResponse;
+
+class ImageStorageDisk
+{
+    public function __construct(
+        protected string $diskName,
+        protected Filesystem $filesystem,
+    ) {
+    }
+
+    /**
+     * Check if local secure image storage (Fetched behind authentication)
+     * is currently active in the instance.
+     */
+    public function usingSecureImages(): bool
+    {
+        return $this->diskName === 'local_secure_images';
+    }
+
+    /**
+     * Change the originally provided path to fit any disk-specific requirements.
+     * This also ensures the path is kept to the expected root folders.
+     */
+    protected function adjustPathForDisk(string $path): string
+    {
+        $path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
+
+        if ($this->usingSecureImages()) {
+            return $path;
+        }
+
+        return 'uploads/images/' . $path;
+    }
+
+    /**
+     * Check if a file at the given path exists.
+     */
+    public function exists(string $path): bool
+    {
+        return $this->filesystem->exists($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Get the file at the given path.
+     */
+    public function get(string $path): ?string
+    {
+        return $this->filesystem->get($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Save the given image data at the given path. Can choose to set
+     * the image as public which will update its visibility after saving.
+     */
+    public function put(string $path, string $data, bool $makePublic = false): void
+    {
+        $path = $this->adjustPathForDisk($path);
+        $this->filesystem->put($path, $data);
+
+        // Set visibility when a non-AWS-s3, s3-like storage option is in use.
+        // Done since this call can break s3-like services but desired for other image stores.
+        // Attempting to set ACL during above put request requires different permissions
+        // hence would technically be a breaking change for actual s3 usage.
+        if ($makePublic && !$this->isS3Like()) {
+            $this->filesystem->setVisibility($path, 'public');
+        }
+    }
+
+    /**
+     * Destroys an image at the given path.
+     * Searches for image thumbnails in addition to main provided path.
+     */
+    public function destroyAllMatchingNameFromPath(string $path): void
+    {
+        $path = $this->adjustPathForDisk($path);
+
+        $imageFolder = dirname($path);
+        $imageFileName = basename($path);
+        $allImages = collect($this->filesystem->allFiles($imageFolder));
+
+        // Delete image files
+        $imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
+            return basename($imagePath) === $imageFileName;
+        });
+        $this->filesystem->delete($imagesToDelete->all());
+
+        // Cleanup of empty folders
+        $foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
+        foreach ($foldersInvolved as $directory) {
+            if ($this->isFolderEmpty($directory)) {
+                $this->filesystem->deleteDirectory($directory);
+            }
+        }
+    }
+
+    /**
+     * Get the mime type of the file at the given path.
+     * Only works for local filesystem adapters.
+     */
+    public function mimeType(string $path): string
+    {
+        $path = $this->adjustPathForDisk($path);
+        return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
+    }
+
+    /**
+     * Get a stream response for the image at the given path.
+     */
+    public function response(string $path): StreamedResponse
+    {
+        return $this->filesystem->response($this->adjustPathForDisk($path));
+    }
+
+    /**
+     * Check if the image storage in use is an S3-like (but not likely S3) external system.
+     */
+    protected function isS3Like(): bool
+    {
+        $usingS3 = $this->diskName === 's3';
+        return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
+    }
+
+    /**
+     * Check whether a folder is empty.
+     */
+    protected function isFolderEmpty(string $path): bool
+    {
+        $files = $this->filesystem->files($path);
+        $folders = $this->filesystem->directories($path);
+
+        return count($files) === 0 && count($folders) === 0;
+    }
+}
index 39236c7e41b173c28bc170875716d0cb62d51816..5bd308ae87707cd425f6ba21b42748cc5a4392b7 100644 (file)
@@ -244,7 +244,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
         }
 
         try {
-            $avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
+            $avatar = $this->avatar?->getThumb($size, $size, false) ?? $default;
         } catch (Exception $err) {
             $avatar = $default;
         }
diff --git a/app/Util/OutOfMemoryHandler.php b/app/Util/OutOfMemoryHandler.php
new file mode 100644 (file)
index 0000000..88e9581
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+namespace BookStack\Util;
+
+use BookStack\Exceptions\Handler;
+use Illuminate\Contracts\Debug\ExceptionHandler;
+
+/**
+ * Create a handler which runs the provided actions upon an
+ * out-of-memory event. This allows reserving of memory to allow
+ * the desired action to run as needed.
+ *
+ * Essentially provides a wrapper and memory reserving around the
+ * memory handling added to the default app error handler.
+ */
+class OutOfMemoryHandler
+{
+    protected $onOutOfMemory;
+    protected string $memoryReserve = '';
+
+    public function __construct(callable $onOutOfMemory, int $memoryReserveMB = 4)
+    {
+        $this->onOutOfMemory = $onOutOfMemory;
+
+        $this->memoryReserve = str_repeat('x', $memoryReserveMB * 1_000_000);
+        $this->getHandler()->prepareForOutOfMemory(function () {
+            return $this->handle();
+        });
+    }
+
+    protected function handle(): mixed
+    {
+        $result = null;
+        $this->memoryReserve = '';
+
+        if ($this->onOutOfMemory) {
+            $result = call_user_func($this->onOutOfMemory);
+            $this->forget();
+        }
+
+        return $result;
+    }
+
+    /**
+     * Forget the handler so no action is taken place on out of memory.
+     */
+    public function forget(): void
+    {
+        $this->memoryReserve = '';
+        $this->onOutOfMemory = null;
+        $this->getHandler()->forgetOutOfMemoryHandler();
+    }
+
+    protected function getHandler(): Handler
+    {
+        return app()->make(ExceptionHandler::class);
+    }
+}
index 8a105096b4f6133295c637acaab526e44f4811a4..c33b1d0b7915c164799f195c385ff6c4704fe275 100644 (file)
@@ -34,6 +34,8 @@ return [
     'image_delete_success' => 'Image successfully deleted',
     'image_replace' => 'Replace Image',
     'image_replace_success' => 'Image file successfully updated',
+    'image_rebuild_thumbs' => 'Regenerate Size Variations',
+    'image_rebuild_thumbs_success' => 'Image size variations successfully rebuilt!',
 
     // Code Editor
     'code_editor' => 'Edit Code',
index 4cde4cea36fb27f8bfd1207b6732c45f317a3886..8813cf90a2da584fb01923b27c58c9e45750b825 100644 (file)
@@ -44,12 +44,16 @@ return [
     'cannot_get_image_from_url' => 'Cannot get image from :url',
     '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.',
+    'server_post_limit' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.',
     'uploaded'  => 'The server does not allow uploads of this size. Please try a smaller file size.',
 
     // Drawing & Images
     'image_upload_error' => 'An error occurred uploading the image',
     'image_upload_type_error' => 'The image type being uploaded is invalid',
     'image_upload_replace_type' => 'Image file replacements must be of the same type',
+    'image_upload_memory_limit' => 'Failed to handle image upload and/or create thumbnails due to system resource limits.',
+    'image_thumbnail_memory_limit' => 'Failed to create image size variations due to system resource limits.',
+    'image_gallery_thumbnail_memory_limit' => 'Failed to create gallery thumbnails due to system resource limits.',
     'drawing_data_not_found' => 'Drawing data could not be loaded. The drawing file might no longer exist or you may not have permission to access it.',
 
     // Attachments
index aa335f9daa35a7d66ab155f8039cfa9ee002aa63..9ce5aabcd498c35805654a81a4e1f4a90aa9cd9d 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -10,7 +10,8 @@
 [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
 [![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
 [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://fosstodon.org/@bookstack)
-[![Twitter](https://img.shields.io/static/v1?label=Twitter&message=@bookstack_app&color=1d9bf0&logo=twitter)](https://twitter.com/bookstack_app)
+[![X - Formerly Twitter](https://img.shields.io/static/v1?label=Follow&message=@bookstack_app&color=1d9bf0&logo=x)](https://x.com/bookstack_app)
+
 [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&[email protected]&color=f2690d&logo=peertube)](https://foss.video/c/bookstack)
 [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp)
 
@@ -24,6 +25,7 @@ A platform for storing and organising information and documentation. Details for
 * [BookStack Blog](https://www.bookstackapp.com/blog)
 * [Issue List](https://github.com/BookStackApp/BookStack/issues)
 * [Discord Chat](https://discord.gg/ztkBqR2)
+* [Support Options](https://www.bookstackapp.com/support/)
 
 ## ðŸ“š Project Definition
 
@@ -57,6 +59,10 @@ Note: Listed services are not tested, vetted nor supported by the official BookS
 <td><a href="https://www.practicali.be" target="_blank">
     <img width="240" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/practicali.png" alt="Practicali">
 </a></td>
+</tr><tr>
+<td><a href="https://www.stellarhosted.com/bookstack/" target="_blank">
+    <img width="240" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/stellarhosted.png" alt="Stellar Hosted">
+</a></td>
 <td><a href="https://torutec.com/" target="_blank">
     <img width="240" src="https://media.githubusercontent.com/media/BookStackApp/website/main/static/images/sponsors/torutec.png" alt="Torutec">
 </a></td>
index e21e67fb33ebb2b6e1ec914d30d425114857bce4..9ff67d53efbe49637ddabd87f9fc50f333e9f2ac 100644 (file)
@@ -15,9 +15,14 @@ export class EntitySelectorPopup extends Component {
         window.$events.listen('entity-select-confirm', this.handleConfirmedSelection.bind(this));
     }
 
-    show(callback) {
+    show(callback, searchText = '') {
         this.callback = callback;
         this.getPopup().show();
+
+        if (searchText) {
+            this.getSelector().searchText(searchText);
+        }
+
         this.getSelector().focusSearch();
     }
 
index f12108fbb497877d9d96cd345e66cdf6a24f51bf..9cda35874019e4291951712de8ab96e719c454ec 100644 (file)
@@ -87,6 +87,11 @@ export class EntitySelector extends Component {
         this.searchInput.focus();
     }
 
+    searchText(queryText) {
+        this.searchInput.value = queryText;
+        this.searchEntities(queryText);
+    }
+
     showLoading() {
         this.loading.style.display = 'block';
         this.resultsContainer.style.display = 'none';
index 78abcf30d8b8c5465f360094f9a9d193be02dd5e..47231477b684fc00e1d453bbc5a83d88a3188500 100644 (file)
@@ -90,6 +90,15 @@ export class ImageManager extends Component {
             }
         });
 
+        // Rebuild thumbs click
+        onChildEvent(this.formContainer, '#image-manager-rebuild-thumbs', 'click', async (_, button) => {
+            button.disabled = true;
+            if (this.lastSelected) {
+                await this.rebuildThumbnails(this.lastSelected.id);
+            }
+            button.disabled = false;
+        });
+
         // Edit form submit
         this.formContainer.addEventListener('ajax-form-success', () => {
             this.refreshGallery();
@@ -220,8 +229,8 @@ export class ImageManager extends Component {
         this.loadGallery();
     }
 
-    onImageSelectEvent(event) {
-        const image = JSON.parse(event.detail.data);
+    async onImageSelectEvent(event) {
+        let image = JSON.parse(event.detail.data);
         const isDblClick = ((image && image.id === this.lastSelected.id)
             && Date.now() - this.lastSelectedTime < 400);
         const alreadySelected = event.target.classList.contains('selected');
@@ -229,12 +238,15 @@ export class ImageManager extends Component {
             el.classList.remove('selected');
         });
 
-        if (!alreadySelected) {
+        if (!alreadySelected && !isDblClick) {
             event.target.classList.add('selected');
-            this.loadImageEditForm(image.id);
-        } else {
+            image = await this.loadImageEditForm(image.id);
+        } else if (!isDblClick) {
             this.resetEditForm();
+        } else if (isDblClick) {
+            image = this.lastSelected;
         }
+
         this.selectButton.classList.toggle('hidden', alreadySelected);
 
         if (isDblClick && this.callback) {
@@ -256,6 +268,9 @@ export class ImageManager extends Component {
         this.formContainer.innerHTML = formHtml;
         this.formContainerPlaceholder.setAttribute('hidden', '');
         window.$components.init(this.formContainer);
+
+        const imageDataEl = this.formContainer.querySelector('#image-manager-form-image-data');
+        return JSON.parse(imageDataEl.text);
     }
 
     runLoadMore() {
@@ -268,4 +283,14 @@ export class ImageManager extends Component {
         return this.loadMore.querySelector('button') && !this.loadMore.hasAttribute('hidden');
     }
 
+    async rebuildThumbnails(imageId) {
+        try {
+            const response = await window.$http.put(`/images/${imageId}/rebuild-thumbnails`);
+            window.$events.success(response.data);
+            this.refreshGallery();
+        } catch (err) {
+            window.$events.showResponseError(err);
+        }
+    }
+
 }
index 3f9df47788f0c7b6cb70092310e740e4d4791cfa..4909a59d066f9627b3756e951be2079940369083 100644 (file)
@@ -34,7 +34,7 @@ export class Actions {
         const imageManager = window.$components.first('image-manager');
 
         imageManager.show(image => {
-            const imageUrl = image.thumbs.display || image.url;
+            const imageUrl = image.thumbs?.display || image.url;
             const selectedText = this.#getSelectionText();
             const newText = `[![${selectedText || image.name}](${imageUrl})](${image.url})`;
             this.#replaceSelection(newText, newText.length);
@@ -68,11 +68,12 @@ export class Actions {
 
         /** @type {EntitySelectorPopup} * */
         const selector = window.$components.first('entity-selector-popup');
+        const selectionText = this.#getSelectionText(selectionRange);
         selector.show(entity => {
-            const selectedText = this.#getSelectionText(selectionRange) || entity.name;
+            const selectedText = selectionText || entity.name;
             const newText = `[${selectedText}](${entity.link})`;
             this.#replaceSelection(newText, newText.length, selectionRange);
-        });
+        }, selectionText);
     }
 
     // Show draw.io if enabled and handle save.
@@ -416,7 +417,7 @@ export class Actions {
             const newContent = `[![](${data.thumbs.display})](${data.url})`;
             this.#findAndReplaceContent(placeHolderText, newContent);
         } catch (err) {
-            window.$events.emit('error', this.editor.config.text.imageUploadError);
+            window.$events.error(err?.data?.message || this.editor.config.text.imageUploadError);
             this.#findAndReplaceContent(placeHolderText, '');
             console.error(err);
         }
index d93c9644e5db99b7e77af8bdb0194b14adc516d1..984081bd602f2c7efc1ae3c175008a9bec3b63da 100644 (file)
@@ -78,12 +78,13 @@ function filePickerCallback(callback, value, meta) {
     if (meta.filetype === 'file') {
         /** @type {EntitySelectorPopup} * */
         const selector = window.$components.first('entity-selector-popup');
+        const selectionText = this.selection.getContent({format: 'text'}).trim();
         selector.show(entity => {
             callback(entity.link, {
                 text: entity.name,
                 title: entity.name,
             });
-        });
+        }, selectionText);
     }
 
     if (meta.filetype === 'image') {
index 33078cd1d5687b74fb877ef8dd2719dd4e0f59ff..9668692c81d1e162cf6f73d954c6cd307bd29735 100644 (file)
@@ -61,7 +61,7 @@ function paste(editor, options, event) {
                 editor.dom.replace(newEl, id);
             }).catch(err => {
                 editor.dom.remove(id);
-                window.$events.emit('error', options.translations.imageUploadErrorText);
+                window.$events.error(err?.data?.message || options.translations.imageUploadErrorText);
                 console.error(err);
             });
         }, 10);
index 37b5bfafd653b7f45fc523fd7b2775cbdd3e0c7c..f1ea120502a4ee99286e82fe94fc56ce5247b8d9 100644 (file)
@@ -11,7 +11,7 @@ function register(editor) {
             /** @type {ImageManager} * */
             const imageManager = window.$components.first('image-manager');
             imageManager.show(image => {
-                const imageUrl = image.thumbs.display || image.url;
+                const imageUrl = image.thumbs?.display || image.url;
                 let html = `<a href="${image.url}" target="_blank">`;
                 html += `<img src="${imageUrl}" alt="${image.name}">`;
                 html += '</a>';
index 1c20df9c5516c8df6a640151b0b5be4c1b99fa11..147e3c2d5c9aceb2d4c573a6aef5d8e4f74335c7 100644 (file)
@@ -48,6 +48,7 @@ export function register(editor) {
     editor.shortcuts.add('meta+shift+K', '', () => {
         /** @var {EntitySelectorPopup} * */
         const selectorPopup = window.$components.first('entity-selector-popup');
+        const selectionText = editor.selection.getContent({format: 'text'}).trim();
         selectorPopup.show(entity => {
             if (editor.selection.isCollapsed()) {
                 editor.insertContent(editor.dom.createHTML('a', {href: entity.link}, editor.dom.encode(entity.name)));
@@ -57,6 +58,6 @@ export function register(editor) {
 
             editor.selection.collapse(false);
             editor.focus();
-        });
+        }, selectionText);
     });
 }
index c66a432bf263ec9fde0fb084e4d685076e4364f5..150f78e12026e08e26d748e60b6854958ddf2ab0 100644 (file)
@@ -382,7 +382,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 .image-manager-list {
   padding: 3px;
   display: grid;
-  grid-template-columns: repeat( auto-fit, minmax(140px, 1fr) );
+  grid-template-columns: repeat( auto-fill, minmax(max(140px, 17%), 1fr) );
   gap: 3px;
   z-index: 3;
   > div {
@@ -457,6 +457,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   text-align: center;
 }
 
+.image-manager-list .image-manager-list-warning {
+  grid-column: 1 / -1;
+  aspect-ratio: auto;
+}
+
+.image-manager-warning {
+  @include lightDark(background, #FFF, #333);
+  color: var(--color-warning);
+  font-weight: bold;
+  border-inline: 3px solid var(--color-warning);
+}
+
 .image-manager-sidebar {
   width: 300px;
   margin: 0 auto;
index c1b6af4c655652b609f0ffaa45505300e5a6f183..4a4c70401b1f7ca2db6d1ed4c7cde3f6d90037b7 100644 (file)
@@ -2,12 +2,12 @@
  * Includes the main navigation header and the faded toolbar.
  */
 
-header .grid {
+header.grid {
   grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr);
 }
 
 @include smaller-than($l) {
-  header .grid {
+  header.grid {
     grid-template-columns: 1fr;
     grid-row-gap: 0;
   }
diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php
deleted file mode 100644 (file)
index 86ad356..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-<header id="header" component="header-mobile-toggle" class="primary-background">
-    <div class="grid mx-l">
-
-        <div>
-            <a href="{{ url('/') }}" data-shortcut="home_view" class="logo">
-                @if(setting('app-logo', '') !== 'none')
-                    <img class="logo-image" src="{{ setting('app-logo', '') === '' ? url('/logo.png') : url(setting('app-logo', '')) }}" alt="Logo">
-                @endif
-                @if (setting('app-name-header'))
-                    <span class="logo-text">{{ setting('app-name') }}</span>
-                @endif
-            </a>
-            <button type="button"
-                    refs="header-mobile-toggle@toggle"
-                    title="{{ trans('common.header_menu_expand') }}"
-                    aria-expanded="false"
-                    class="mobile-menu-toggle hide-over-l">@icon('more')</button>
-        </div>
-
-        <div class="flex-container-column items-center justify-center hide-under-l">
-            @if (user()->hasAppAccess())
-            <form component="global-search" action="{{ url('/search') }}" method="GET" class="search-box" role="search" tabindex="0">
-                <button id="header-search-box-button"
-                        refs="global-search@button"
-                        type="submit"
-                        aria-label="{{ trans('common.search') }}"
-                        tabindex="-1">@icon('search')</button>
-                <input id="header-search-box-input"
-                       refs="global-search@input"
-                       type="text"
-                       name="term"
-                       data-shortcut="global_search"
-                       autocomplete="off"
-                       aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
-                       value="{{ $searchTerm ?? '' }}">
-                <div refs="global-search@suggestions" class="global-search-suggestions card">
-                    <div refs="global-search@loading" class="text-center px-m global-search-loading">@include('common.loading-icon')</div>
-                    <div refs="global-search@suggestion-results" class="px-m"></div>
-                    <button class="text-button card-footer-link" type="submit">{{ trans('common.view_all') }}</button>
-                </div>
-            </form>
-            @endif
-        </div>
-
-        <nav refs="header-mobile-toggle@menu" class="header-links">
-            <div class="links text-center">
-                @if (user()->hasAppAccess())
-                    <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
-                    @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
-                        <a href="{{ url('/shelves') }}" data-shortcut="shelves_view">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
-                    @endif
-                    <a href="{{ url('/books') }}" data-shortcut="books_view">@icon('books'){{ trans('entities.books') }}</a>
-                    @if(!user()->isGuest() && userCan('settings-manage'))
-                        <a href="{{ url('/settings') }}" data-shortcut="settings_view">@icon('settings'){{ trans('settings.settings') }}</a>
-                    @endif
-                    @if(!user()->isGuest() && userCan('users-manage') && !userCan('settings-manage'))
-                        <a href="{{ url('/settings/users') }}" data-shortcut="settings_view">@icon('users'){{ trans('settings.users') }}</a>
-                    @endif
-                @endif
-
-                @if(user()->isGuest())
-                    @if(setting('registration-enabled') && config('auth.method') === 'standard')
-                        <a href="{{ url('/register') }}">@icon('new-user'){{ trans('auth.sign_up') }}</a>
-                    @endif
-                    <a href="{{ url('/login')  }}">@icon('login'){{ trans('auth.log_in') }}</a>
-                @endif
-            </div>
-            @if(!user()->isGuest())
-                @include('common.header-user-menu', ['user' => user()])
-            @endif
-        </nav>
-
-    </div>
-</header>
index 95c0c9df239e0fd86f6a1c31b34c1113ec380d8d..a2f2bf79605422b6fbc4d7a7b784faf813456030 100644 (file)
                 </a>
             @endif
             @include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
+            <a href="{{ url('/tags') }}" class="icon-list-item">
+                <span>@icon('tag')</span>
+                <span>{{ trans('entities.tags_view_tags') }}</span>
+            </a>
             @include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
         </div>
index 9699d6b96b513dfe41281f8582f07f66fa086354..1265db29e53457cd9e823dc277752aac49280109 100644 (file)
                 </a>
             @endif
             @include('entities.view-toggle', ['view' => $view, 'type' => 'bookshelves'])
+            <a href="{{ url('/tags') }}" class="icon-list-item">
+                <span>@icon('tag')</span>
+                <span>{{ trans('entities.tags_view_tags') }}</span>
+            </a>
             @include('home.parts.expand-toggle', ['classes' => 'text-link', 'target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('common.dark-mode-toggle', ['classes' => 'icon-list-item text-link'])
         </div>
index 8693f021d88e48299f93905e7035f11dbf0178aa..67b074905126037b118b730633503f586392cacb 100644 (file)
@@ -36,8 +36,8 @@
     @yield('head')
 
     <!-- Custom Styles & Head Content -->
-    @include('common.custom-styles')
-    @include('common.custom-head')
+    @include('layouts.parts.custom-styles')
+    @include('layouts.parts.custom-head')
 
     @stack('head')
 
       class="@stack('body-class')">
 
     @include('layouts.parts.base-body-start')
-    @include('common.skip-to-content')
-    @include('common.notifications')
-    @include('common.header')
+    @include('layouts.parts.skip-to-content')
+    @include('layouts.parts.notifications')
+    @include('layouts.parts.header')
 
     <div id="content" components="@yield('content-components')" class="block">
         @yield('content')
     </div>
 
-    @include('common.footer')
+    @include('layouts.parts.footer')
 
     <div component="back-to-top" class="back-to-top print-hidden">
         <div class="inner">
diff --git a/resources/views/layouts/parts/header-links-start.blade.php b/resources/views/layouts/parts/header-links-start.blade.php
new file mode 100644 (file)
index 0000000..4711989
--- /dev/null
@@ -0,0 +1,2 @@
+{{-- This is a placeholder template file provided as a --}}
+{{-- convenience to users of the visual theme system. --}}
\ No newline at end of file
diff --git a/resources/views/layouts/parts/header-links.blade.php b/resources/views/layouts/parts/header-links.blade.php
new file mode 100644 (file)
index 0000000..697f406
--- /dev/null
@@ -0,0 +1,25 @@
+@include('layouts.parts.header-links-start')
+
+@if (user()->hasAppAccess())
+    <a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
+    @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+        <a href="{{ url('/shelves') }}"
+           data-shortcut="shelves_view">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+    @endif
+    <a href="{{ url('/books') }}" data-shortcut="books_view">@icon('books'){{ trans('entities.books') }}</a>
+    @if(!user()->isGuest() && userCan('settings-manage'))
+        <a href="{{ url('/settings') }}"
+           data-shortcut="settings_view">@icon('settings'){{ trans('settings.settings') }}</a>
+    @endif
+    @if(!user()->isGuest() && userCan('users-manage') && !userCan('settings-manage'))
+        <a href="{{ url('/settings/users') }}"
+           data-shortcut="settings_view">@icon('users'){{ trans('settings.users') }}</a>
+    @endif
+@endif
+
+@if(user()->isGuest())
+    @if(setting('registration-enabled') && config('auth.method') === 'standard')
+        <a href="{{ url('/register') }}">@icon('new-user'){{ trans('auth.sign_up') }}</a>
+    @endif
+    <a href="{{ url('/login')  }}">@icon('login'){{ trans('auth.log_in') }}</a>
+@endif
\ No newline at end of file
diff --git a/resources/views/layouts/parts/header-logo.blade.php b/resources/views/layouts/parts/header-logo.blade.php
new file mode 100644 (file)
index 0000000..c1bbd78
--- /dev/null
@@ -0,0 +1,8 @@
+<a href="{{ url('/') }}" data-shortcut="home_view" class="logo">
+    @if(setting('app-logo', '') !== 'none')
+        <img class="logo-image" src="{{ setting('app-logo', '') === '' ? url('/logo.png') : url(setting('app-logo', '')) }}" alt="Logo">
+    @endif
+    @if (setting('app-name-header'))
+        <span class="logo-text">{{ setting('app-name') }}</span>
+    @endif
+</a>
\ No newline at end of file
diff --git a/resources/views/layouts/parts/header-search.blade.php b/resources/views/layouts/parts/header-search.blade.php
new file mode 100644 (file)
index 0000000..d542611
--- /dev/null
@@ -0,0 +1,20 @@
+<form component="global-search" action="{{ url('/search') }}" method="GET" class="search-box" role="search" tabindex="0">
+    <button id="header-search-box-button"
+            refs="global-search@button"
+            type="submit"
+            aria-label="{{ trans('common.search') }}"
+            tabindex="-1">@icon('search')</button>
+    <input id="header-search-box-input"
+           refs="global-search@input"
+           type="text"
+           name="term"
+           data-shortcut="global_search"
+           autocomplete="off"
+           aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
+           value="{{ $searchTerm ?? '' }}">
+    <div refs="global-search@suggestions" class="global-search-suggestions card">
+        <div refs="global-search@loading" class="text-center px-m global-search-loading">@include('common.loading-icon')</div>
+        <div refs="global-search@suggestion-results" class="px-m"></div>
+        <button class="text-button card-footer-link" type="submit">{{ trans('common.view_all') }}</button>
+    </div>
+</form>
\ No newline at end of file
diff --git a/resources/views/layouts/parts/header.blade.php b/resources/views/layouts/parts/header.blade.php
new file mode 100644 (file)
index 0000000..25f8e8c
--- /dev/null
@@ -0,0 +1,25 @@
+<header id="header" component="header-mobile-toggle" class="primary-background px-xl grid">
+    <div>
+        @include('layouts.parts.header-logo')
+        <button type="button"
+                refs="header-mobile-toggle@toggle"
+                title="{{ trans('common.header_menu_expand') }}"
+                aria-expanded="false"
+                class="mobile-menu-toggle hide-over-l">@icon('more')</button>
+    </div>
+
+    <div class="flex-container-column items-center justify-center hide-under-l">
+    @if(user()->hasAppAccess())
+        @include('layouts.parts.header-search')
+    @endif
+    </div>
+
+    <nav refs="header-mobile-toggle@menu" class="header-links">
+        <div class="links text-center">
+            @include('layouts.parts.header-links')
+        </div>
+        @if(!user()->isGuest())
+            @include('layouts.parts.header-user-menu', ['user' => user()])
+        @endif
+    </nav>
+</header>
index a3ee74143b3749d658e3836d313677f148518dee..a644ef78997040499a447c9caa9c86deb60c5b3d 100644 (file)
@@ -14,8 +14,8 @@
     <link rel="stylesheet" media="print" href="{{ versioned_asset('dist/print-styles.css') }}">
 
     <!-- Custom Styles & Head Content -->
-    @include('common.custom-styles')
-    @include('common.custom-head')
+    @include('layouts.parts.custom-styles')
+    @include('layouts.parts.custom-head')
 </head>
 <body>
     @yield('content')
index 75750ef2f76373057f58d94041db194bfbda01fe..bd84e247d912d8b6a09d197f7e15082619fb98e4 100644 (file)
@@ -8,8 +8,17 @@
      option:dropzone:file-accept="image/*"
      class="image-manager-details">
 
+    @if($warning ?? '')
+        <div class="image-manager-warning px-m py-xs flex-container-row gap-xs items-center mb-l">
+            <div>@icon('warning')</div>
+            <div class="flex">{{ $warning }}</div>
+        </div>
+    @endif
+
     <div refs="dropzone@status-area dropzone@drop-target"></div>
 
+    <script id="image-manager-form-image-data" type="application/json">@json($image)</script>
+
     <form component="ajax-form"
           option:ajax-form:success-message="{{ trans('components.image_update_success') }}"
           option:ajax-form:method="put"
@@ -44,6 +53,9 @@
                                     id="image-manager-replace"
                                     refs="dropzone@select-button"
                                     class="text-item">{{ trans('components.image_replace') }}</button>
+                            <button type="button"
+                                    id="image-manager-rebuild-thumbs"
+                                    class="text-item">{{ trans('components.image_rebuild_thumbs') }}</button>
                         @endif
                     </div>
                 </div>
index 7e660c747dca135c9d947596dca6d76ace6ad1d8..ff6e23d6ac7e272a504c1b6bc074c000d2c80a48 100644 (file)
@@ -1,3 +1,9 @@
+@if($warning ?? '')
+    <div class="image-manager-list-warning image-manager-warning px-m py-xs flex-container-row gap-xs items-center">
+        <div>@icon('warning')</div>
+        <div class="flex">{{ $warning }}</div>
+    </div>
+@endif
 @foreach($images as $index => $image)
 <div>
     <button component="event-emit-select"
@@ -5,7 +11,7 @@
          option:event-emit-select:data="{{ json_encode($image) }}"
          class="image anim fadeIn text-link"
          style="animation-delay: {{ min($index * 10, 260) . 'ms' }};">
-        <img src="{{ $image->thumbs['gallery'] }}"
+        <img src="{{ $image->thumbs['gallery'] ?? '' }}"
              alt="{{ $image->name }}"
              role="none"
              width="150"
index 9c049ba362aed2d4427ef4c9d24052d2f556bfe0..06dffa636b778066f8083a5e02006d0b5da9ed4e 100644 (file)
@@ -143,6 +143,7 @@ Route::middleware('auth')->group(function () {
     Route::post('/images/drawio', [UploadControllers\DrawioImageController::class, 'create']);
     Route::get('/images/edit/{id}', [UploadControllers\ImageController::class, 'edit']);
     Route::put('/images/{id}/file', [UploadControllers\ImageController::class, 'updateFile']);
+    Route::put('/images/{id}/rebuild-thumbnails', [UploadControllers\ImageController::class, 'rebuildThumbnails']);
     Route::put('/images/{id}', [UploadControllers\ImageController::class, 'update']);
     Route::delete('/images/{id}', [UploadControllers\ImageController::class, 'destroy']);
 
index 85f8f6430a769e77ee9e18ef5ac009526e6ca3d0..39c8c689b0c7944ce45dd76847df7c1831d1987f 100644 (file)
@@ -11,7 +11,7 @@ class ResetMfaCommandTest extends TestCase
     public function test_command_requires_email_or_id_option()
     {
         $this->artisan('bookstack:reset-mfa')
-            ->expectsOutput('Either a --id=<number> or --email=<email> option must be provided.')
+            ->expectsOutputToContain('Either a --id=<number> or --email=<email> option must be provided.')
             ->assertExitCode(1);
     }
 
index a070ce3fa889994686b5fa8e83b57f4fc3f7499f..fbb47226e6a4864a8ee78c51915e5d1367b92b58 100644 (file)
@@ -466,10 +466,10 @@ class EntitySearchTest extends TestCase
         $search = $this->asEditor()->get('/search?term=' . urlencode('\\\\cat\\dog'));
         $search->assertSee($page->getUrl(), false);
 
-        $search = $this->asEditor()->get('/search?term=' . urlencode('"\\dog\\"'));
+        $search = $this->asEditor()->get('/search?term=' . urlencode('"\\dog\\\\"'));
         $search->assertSee($page->getUrl(), false);
 
-        $search = $this->asEditor()->get('/search?term=' . urlencode('"\\badger\\"'));
+        $search = $this->asEditor()->get('/search?term=' . urlencode('"\\badger\\\\"'));
         $search->assertDontSee($page->getUrl(), false);
 
         $search = $this->asEditor()->get('/search?term=' . urlencode('[\\Categorylike%\\fluffy]'));
index 2b5244bf010510dde94191ac8dc21c87465218cb..08bf17d0ada22d799a3ee271a91e5cf2acf626c3 100644 (file)
@@ -46,17 +46,43 @@ class ExportTest extends TestCase
 
     public function test_book_text_export()
     {
-        $page = $this->entities->page();
-        $book = $page->book;
+        $book = $this->entities->bookHasChaptersAndPages();
+        $directPage = $book->directPages()->first();
+        $chapter = $book->chapters()->first();
+        $chapterPage = $chapter->pages()->first();
+        $this->entities->updatePage($directPage, ['html' => '<p>My awesome page</p>']);
+        $this->entities->updatePage($chapterPage, ['html' => '<p>My little nested page</p>']);
         $this->asEditor();
 
         $resp = $this->get($book->getUrl('/export/plaintext'));
         $resp->assertStatus(200);
         $resp->assertSee($book->name);
-        $resp->assertSee($page->name);
+        $resp->assertSee($chapterPage->name);
+        $resp->assertSee($chapter->name);
+        $resp->assertSee($directPage->name);
+        $resp->assertSee('My awesome page');
+        $resp->assertSee('My little nested page');
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $book->slug . '.txt"');
     }
 
+    public function test_book_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['book']->name = 'Export Book';
+        $entities['book']->description = "This is a book with stuff to export";
+        $entities['chapter']->save();
+        $entities['book']->save();
+
+        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
+
+        $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
+        $expected .= "My wonderful page!\nMy great page Full of great stuff";
+        $resp->assertSee($expected);
+    }
+
     public function test_book_pdf_export()
     {
         $page = $this->entities->page();
@@ -99,15 +125,32 @@ class ExportTest extends TestCase
     {
         $chapter = $this->entities->chapter();
         $page = $chapter->pages[0];
+        $this->entities->updatePage($page, ['html' => '<p>This is content within the page!</p>']);
         $this->asEditor();
 
         $resp = $this->get($chapter->getUrl('/export/plaintext'));
         $resp->assertStatus(200);
         $resp->assertSee($chapter->name);
         $resp->assertSee($page->name);
+        $resp->assertSee('This is content within the page!');
         $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $chapter->slug . '.txt"');
     }
 
+    public function test_chapter_text_export_format()
+    {
+        $entities = $this->entities->createChainBelongingToUser($this->users->viewer());
+        $this->entities->updatePage($entities['page'], ['html' => '<p>My great page</p><p>Full of <strong>great</strong> stuff</p>', 'name' => 'My wonderful page!']);
+        $entities['chapter']->name = 'Export chapter';
+        $entities['chapter']->description = "A test chapter to be exported\nIt has loads of info within";
+        $entities['chapter']->save();
+
+        $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext'));
+
+        $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n";
+        $expected .= "My wonderful page!\nMy great page Full of great stuff";
+        $resp->assertSee($expected);
+    }
+
     public function test_chapter_pdf_export()
     {
         $chapter = $this->entities->chapter();
index 8bc9d02e4c792df0da3949391830e68523d3ab3e..ea4d727a42850002353b686823ce362a048b4395 100644 (file)
@@ -20,9 +20,9 @@ class SearchOptionsTest extends TestCase
 
     public function test_from_string_properly_parses_escaped_quotes()
     {
-        $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\""');
+        $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\"" "\\\\"');
 
-        $this->assertEquals(['"cat"', '""', '"donkey', '"'], $options->exacts);
+        $this->assertEquals(['"cat"', '""', '"donkey', '"', '\\'], $options->exacts);
     }
 
     public function test_to_string_includes_all_items_in_the_correct_format()
@@ -40,13 +40,13 @@ class SearchOptionsTest extends TestCase
         }
     }
 
-    public function test_to_string_escapes_quotes_as_expected()
+    public function test_to_string_escapes_as_expected()
     {
         $options = new SearchOptions();
-        $options->exacts = ['"cat"', '""', '"donkey', '"'];
+        $options->exacts = ['"cat"', '""', '"donkey', '"', '\\', '\\"'];
 
         $output = $options->toString();
-        $this->assertEquals('"\"cat\"" "\"\"" "\"donkey" "\""', $output);
+        $this->assertEquals('"\"cat\"" "\"\"" "\"donkey" "\"" "\\\\" "\\\\\""', $output);
     }
 
     public function test_correct_filter_values_are_set_from_string()
index 6ba01dd88e6ed8dfb094e637f3cbf6ed1ea6cfe1..0d2ef808c48af0cfa1ed608ea2c4c6d23e3eec08 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Tests;
 
+use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
 use Illuminate\Support\Facades\Log;
 
 class ErrorTest extends TestCase
@@ -45,4 +46,16 @@ class ErrorTest extends TestCase
         $resp->assertStatus(404);
         $resp->assertSeeText('Image Not Found');
     }
+
+    public function test_posts_above_php_limit_shows_friendly_error()
+    {
+        // Fake super large JSON request
+        $resp = $this->asEditor()->call('GET', '/books', [], [], [], [
+            'CONTENT_LENGTH' => '10000000000',
+            'HTTP_ACCEPT' => 'application/json',
+        ]);
+
+        $resp->assertStatus(413);
+        $resp->assertJson(['error' => 'The server cannot receive the provided amount of data. Try again with less data or a smaller file.']);
+    }
 }
index 9e44697c7a552738464286ed98379dbc11dfcd12..99ee11efbad8b355e136a794c9b2215b8b594e18 100644 (file)
@@ -133,12 +133,21 @@ class FileProvider
      */
     public function deleteAtRelativePath(string $path): void
     {
-        $fullPath = public_path($path);
+        $fullPath = $this->relativeToFullPath($path);
         if (file_exists($fullPath)) {
             unlink($fullPath);
         }
     }
 
+    /**
+     * Convert a relative path used by default in this provider to a full
+     * absolute local filesystem path.
+     */
+    public function relativeToFullPath(string $path): string
+    {
+        return public_path($path);
+    }
+
     /**
      * Delete all uploaded files.
      * To assist with cleanup.
index eb552b2e2095700e8054c9679746620f8cd935d3..977ae5256df0038875287eb0bfd8c47b1235838e 100644 (file)
@@ -126,9 +126,6 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('grid-card-content');
         $homeVisit->assertSee('grid-card-footer');
         $homeVisit->assertSee('featured-image-container');
-
-        $this->setSettings(['app-homepage-type' => false]);
-        $this->test_default_homepage_visible();
     }
 
     public function test_set_bookshelves_homepage()
@@ -145,9 +142,19 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('grid-card-content');
         $homeVisit->assertSee('featured-image-container');
         $this->withHtml($homeVisit)->assertElementContains('.grid-card', $shelf->name);
+    }
+
+    public function test_books_and_bookshelves_homepage_has_expected_actions()
+    {
+        $this->asEditor();
+
+        foreach (['bookshelves', 'books'] as $homepageType) {
+            $this->setSettings(['app-homepage-type' => $homepageType]);
 
-        $this->setSettings(['app-homepage-type' => false]);
-        $this->test_default_homepage_visible();
+            $html = $this->withHtml($this->get('/'));
+            $html->assertElementContains('.actions button', 'Dark Mode');
+            $html->assertElementContains('.actions a[href$="/tags"]', 'View Tags');
+        }
     }
 
     public function test_shelves_list_homepage_adheres_to_book_visibility_permissions()
index f0266cd0c1349420fdd4c07724b68d9b77b7ef56..53361e35194dad7b8fdec639373cc1a673bdfdd4 100644 (file)
@@ -366,6 +366,20 @@ class ThemeTest extends TestCase
         });
     }
 
+    public function test_header_links_start_template_file_can_be_used()
+    {
+        $content = 'This is added text in the header bar';
+
+        $this->usingThemeFolder(function (string $folder) use ($content) {
+            $viewDir = theme_path('layouts/parts');
+            mkdir($viewDir, 0777, true);
+            file_put_contents($viewDir . '/header-links-start.blade.php', $content);
+            $this->setSettings(['registration-enabled' => 'true']);
+
+            $this->get('/login')->assertSee($content);
+        });
+    }
+
     protected function usingThemeFolder(callable $callback)
     {
         // Create a folder and configure a theme
index a9684eef72a9e52a55897571227ddf680f11daae..4da964d4804db70e0cd75c877844ccfe46bd121b 100644 (file)
@@ -552,6 +552,30 @@ class ImageTest extends TestCase
         $this->files->deleteAtRelativePath($relPath);
     }
 
+    public function test_image_manager_regen_thumbnails()
+    {
+        $this->asEditor();
+        $imageName = 'first-image.png';
+        $relPath = $this->files->expectedImagePath('gallery', $imageName);
+        $this->files->deleteAtRelativePath($relPath);
+
+        $this->files->uploadGalleryImage($this, $imageName, $this->entities->page()->id);
+        $image = Image::first();
+
+        $resp = $this->get("/images/edit/{$image->id}");
+        $this->withHtml($resp)->assertElementExists('button#image-manager-rebuild-thumbs');
+
+        $expectedThumbPath = dirname($relPath) . '/scaled-1680-/' . basename($relPath);
+        $this->files->deleteAtRelativePath($expectedThumbPath);
+        $this->assertFileDoesNotExist($this->files->relativeToFullPath($expectedThumbPath));
+
+        $resp = $this->put("/images/{$image->id}/rebuild-thumbnails");
+        $resp->assertOk();
+
+        $this->assertFileExists($this->files->relativeToFullPath($expectedThumbPath));
+        $this->files->deleteAtRelativePath($relPath);
+    }
+
     protected function getTestProfileImage()
     {
         $imageName = 'profile.png';