mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-08-07 23:03:00 +03:00
Merge branch 'drawing_updates'
This commit is contained in:
83
app/Console/Commands/CleanupImages.php
Normal file
83
app/Console/Commands/CleanupImages.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Services\ImageService;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class CleanupImages extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:cleanup-images
|
||||
{--a|all : Include images that are used in page revisions}
|
||||
{--f|force : Actually run the deletions}
|
||||
';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Cleanup images and drawings';
|
||||
|
||||
|
||||
protected $imageService;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
* @param ImageService $imageService
|
||||
*/
|
||||
public function __construct(ImageService $imageService)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$checkRevisions = $this->option('all') ? false : true;
|
||||
$dryRun = $this->option('force') ? false : true;
|
||||
|
||||
if (!$dryRun) {
|
||||
$proceed = $this->confirm("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\nAre you sure you want to proceed?");
|
||||
if (!$proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$deleted = $this->imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($deleted);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment('Dry run, No images have been deleted');
|
||||
$this->comment($deleteCount . ' images found that would have been deleted');
|
||||
$this->showDeletedImages($deleted);
|
||||
$this->comment('Run with -f or --force to perform deletions');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->showDeletedImages($deleted);
|
||||
$this->comment($deleteCount . ' images deleted');
|
||||
}
|
||||
|
||||
protected function showDeletedImages($paths)
|
||||
{
|
||||
if ($this->getOutput()->getVerbosity() <= OutputInterface::VERBOSITY_NORMAL) return;
|
||||
if (count($paths) > 0) {
|
||||
$this->line('Images to delete:');
|
||||
}
|
||||
foreach ($paths as $path) {
|
||||
$this->line($path);
|
||||
}
|
||||
}
|
||||
}
|
@@ -164,32 +164,6 @@ class ImageController extends Controller
|
||||
return response()->json($image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the data content of a drawing.
|
||||
* @param string $id
|
||||
* @param Request $request
|
||||
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
public function replaceDrawing(string $id, Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'image' => 'required|string'
|
||||
]);
|
||||
$this->checkPermission('image-create-all');
|
||||
|
||||
$imageBase64Data = $request->get('image');
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('image-update', $image);
|
||||
|
||||
try {
|
||||
$image = $this->imageRepo->replaceDrawingContent($image, $imageBase64Data);
|
||||
} catch (ImageUploadException $e) {
|
||||
return response($e->getMessage(), 500);
|
||||
}
|
||||
|
||||
return response()->json($image);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of an image based64 encoded.
|
||||
* @param $id
|
||||
@@ -245,26 +219,29 @@ class ImageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image and all thumbnail/image files
|
||||
* Show the usage of an image on pages.
|
||||
* @param EntityRepo $entityRepo
|
||||
* @param Request $request
|
||||
* @param int $id
|
||||
* @param $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function destroy(EntityRepo $entityRepo, Request $request, $id)
|
||||
public function usage(EntityRepo $entityRepo, $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$pageSearch = $entityRepo->searchForImage($image->url);
|
||||
return response()->json($pageSearch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an image and all thumbnail/image files
|
||||
* @param int $id
|
||||
* @return \Illuminate\Http\JsonResponse
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function destroy($id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('image-delete', $image);
|
||||
|
||||
// Check if this image is used on any pages
|
||||
$isForced = in_array($request->get('force', ''), [true, 'true']);
|
||||
if (!$isForced) {
|
||||
$pageSearch = $entityRepo->searchForImage($image->url);
|
||||
if ($pageSearch !== false) {
|
||||
return response()->json($pageSearch, 400);
|
||||
}
|
||||
}
|
||||
|
||||
$this->imageRepo->destroyImage($image);
|
||||
return response()->json(trans('components.images_deleted'));
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
<?php namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Services\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Setting;
|
||||
@@ -13,7 +14,7 @@ class SettingController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle('Settings');
|
||||
$this->setPageTitle(trans('settings.settings'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
@@ -43,4 +44,48 @@ class SettingController extends Controller
|
||||
session()->flash('success', trans('settings.settings_save_success'));
|
||||
return redirect('/settings');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the page for application maintenance.
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function showMaintenance()
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->setPageTitle(trans('settings.maint'));
|
||||
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings/maintenance', ['version' => $version]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to clean-up images in the system.
|
||||
* @param Request $request
|
||||
* @param ImageService $imageService
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
|
||||
*/
|
||||
public function cleanupImages(Request $request, ImageService $imageService)
|
||||
{
|
||||
$this->checkPermission('settings-manage');
|
||||
|
||||
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
|
||||
$dryRun = !($request->has('confirm'));
|
||||
|
||||
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
|
||||
$deleteCount = count($imagesToDelete);
|
||||
if ($deleteCount === 0) {
|
||||
session()->flash('warning', trans('settings.maint_image_cleanup_nothing_found'));
|
||||
return redirect('/settings/maintenance')->withInput();
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
|
||||
} else {
|
||||
session()->flash('success', trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
|
||||
}
|
||||
|
||||
return redirect('/settings/maintenance#image-cleanup')->withInput();
|
||||
}
|
||||
}
|
||||
|
@@ -9,13 +9,15 @@ class Image extends Ownable
|
||||
|
||||
/**
|
||||
* Get a thumbnail for this image.
|
||||
* @param int $width
|
||||
* @param int $height
|
||||
* @param int $width
|
||||
* @param int $height
|
||||
* @param bool|false $keepRatio
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getThumb($width, $height, $keepRatio = false)
|
||||
{
|
||||
return Images::getThumbnail($this, $width, $height, $keepRatio);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Activity;
|
||||
use BookStack\Image;
|
||||
use BookStack\Services\ImageService;
|
||||
use BookStack\Services\PermissionService;
|
||||
use BookStack\Services\ViewService;
|
||||
@@ -57,6 +58,7 @@ class CustomFacadeProvider extends ServiceProvider
|
||||
|
||||
$this->app->bind('images', function () {
|
||||
return new ImageService(
|
||||
$this->app->make(Image::class),
|
||||
$this->app->make(ImageManager::class),
|
||||
$this->app->make(Factory::class),
|
||||
$this->app->make(Repository::class)
|
||||
|
@@ -153,17 +153,6 @@ class ImageRepo
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the image content of a drawing.
|
||||
* @param Image $image
|
||||
* @param string $base64Uri
|
||||
* @return Image
|
||||
* @throws \BookStack\Exceptions\ImageUploadException
|
||||
*/
|
||||
public function replaceDrawingContent(Image $image, string $base64Uri)
|
||||
{
|
||||
return $this->imageService->replaceImageDataFromBase64Uri($image, $base64Uri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an image via an array of properties.
|
||||
@@ -183,13 +172,14 @@ class ImageRepo
|
||||
|
||||
|
||||
/**
|
||||
* Destroys an Image object along with its files and thumbnails.
|
||||
* Destroys an Image object along with its revisions, files and thumbnails.
|
||||
* @param Image $image
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function destroyImage(Image $image)
|
||||
{
|
||||
$this->imageService->destroyImage($image);
|
||||
$this->imageService->destroy($image);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -200,7 +190,7 @@ class ImageRepo
|
||||
* @throws \BookStack\Exceptions\ImageUploadException
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function loadThumbs(Image $image)
|
||||
protected function loadThumbs(Image $image)
|
||||
{
|
||||
$image->thumbs = [
|
||||
'gallery' => $this->getThumbnail($image, 150, 150),
|
||||
@@ -250,7 +240,7 @@ class ImageRepo
|
||||
*/
|
||||
public function isValidType($type)
|
||||
{
|
||||
$validTypes = ['drawing', 'gallery', 'cover', 'system', 'user'];
|
||||
$validTypes = ['gallery', 'cover', 'system', 'user'];
|
||||
return in_array($type, $validTypes);
|
||||
}
|
||||
}
|
||||
|
@@ -166,7 +166,7 @@ class UserRepo
|
||||
// Delete user profile images
|
||||
$profileImages = $images = Image::where('type', '=', 'user')->where('created_by', '=', $user->id)->get();
|
||||
foreach ($profileImages as $image) {
|
||||
Images::destroyImage($image);
|
||||
Images::destroy($image);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,11 +3,11 @@
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Image;
|
||||
use BookStack\User;
|
||||
use DB;
|
||||
use Exception;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
|
||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
@@ -17,15 +17,18 @@ class ImageService extends UploadService
|
||||
protected $imageTool;
|
||||
protected $cache;
|
||||
protected $storageUrl;
|
||||
protected $image;
|
||||
|
||||
/**
|
||||
* ImageService constructor.
|
||||
* @param $imageTool
|
||||
* @param $fileSystem
|
||||
* @param $cache
|
||||
* @param Image $image
|
||||
* @param ImageManager $imageTool
|
||||
* @param FileSystem $fileSystem
|
||||
* @param Cache $cache
|
||||
*/
|
||||
public function __construct(ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
|
||||
public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
|
||||
{
|
||||
$this->image = $image;
|
||||
$this->imageTool = $imageTool;
|
||||
$this->cache = $cache;
|
||||
parent::__construct($fileSystem);
|
||||
@@ -82,31 +85,6 @@ class ImageService extends UploadService
|
||||
return $this->saveNew($name, $data, $type, $uploadedTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the data for an image via a Base64 encoded string.
|
||||
* @param Image $image
|
||||
* @param string $base64Uri
|
||||
* @return Image
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function replaceImageDataFromBase64Uri(Image $image, string $base64Uri)
|
||||
{
|
||||
$splitData = explode(';base64,', $base64Uri);
|
||||
if (count($splitData) < 2) {
|
||||
throw new ImageUploadException("Invalid base64 image data provided");
|
||||
}
|
||||
$data = base64_decode($splitData[1]);
|
||||
$storage = $this->getStorage();
|
||||
|
||||
try {
|
||||
$storage->put($image->path, $data);
|
||||
} catch (Exception $e) {
|
||||
throw new ImageUploadException(trans('errors.path_not_writable', ['filePath' => $image->path]));
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an image from url and saves it to the database.
|
||||
* @param $url
|
||||
@@ -140,16 +118,16 @@ class ImageService extends UploadService
|
||||
$secureUploads = setting('app-secure-images');
|
||||
$imageName = str_replace(' ', '-', $imageName);
|
||||
|
||||
if ($secureUploads) {
|
||||
$imageName = str_random(16) . '-' . $imageName;
|
||||
}
|
||||
|
||||
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
|
||||
|
||||
while ($storage->exists($imagePath . $imageName)) {
|
||||
$imageName = str_random(3) . $imageName;
|
||||
}
|
||||
|
||||
$fullPath = $imagePath . $imageName;
|
||||
if ($secureUploads) {
|
||||
$fullPath = $imagePath . str_random(16) . '-' . $imageName;
|
||||
}
|
||||
|
||||
try {
|
||||
$storage->put($fullPath, $imageData);
|
||||
@@ -172,20 +150,11 @@ class ImageService extends UploadService
|
||||
$imageDetails['updated_by'] = $userId;
|
||||
}
|
||||
|
||||
$image = (new Image());
|
||||
$image = $this->image->newInstance();
|
||||
$image->forceFill($imageDetails)->save();
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage path, Dependant of storage type.
|
||||
* @param Image $image
|
||||
* @return mixed|string
|
||||
*/
|
||||
protected function getPath(Image $image)
|
||||
{
|
||||
return $image->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image is a gif. Returns true if it is, else false.
|
||||
@@ -194,7 +163,7 @@ class ImageService extends UploadService
|
||||
*/
|
||||
protected function isGif(Image $image)
|
||||
{
|
||||
return strtolower(pathinfo($this->getPath($image), PATHINFO_EXTENSION)) === 'gif';
|
||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,11 +181,11 @@ class ImageService extends UploadService
|
||||
public function getThumbnail(Image $image, $width = 220, $height = 220, $keepRatio = false)
|
||||
{
|
||||
if ($keepRatio && $this->isGif($image)) {
|
||||
return $this->getPublicUrl($this->getPath($image));
|
||||
return $this->getPublicUrl($image->path);
|
||||
}
|
||||
|
||||
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
|
||||
$imagePath = $this->getPath($image);
|
||||
$imagePath = $image->path;
|
||||
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
|
||||
|
||||
if ($this->cache->has('images-' . $image->id . '-' . $thumbFilePath) && $this->cache->get('images-' . $thumbFilePath)) {
|
||||
@@ -262,43 +231,51 @@ class ImageService extends UploadService
|
||||
*/
|
||||
public function getImageData(Image $image)
|
||||
{
|
||||
$imagePath = $this->getPath($image);
|
||||
$imagePath = $image->path;
|
||||
$storage = $this->getStorage();
|
||||
return $storage->get($imagePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys an Image object along with its files and thumbnails.
|
||||
* Destroy an image along with its revisions, thumbnails and remaining folders.
|
||||
* @param Image $image
|
||||
* @return bool
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroyImage(Image $image)
|
||||
public function destroy(Image $image)
|
||||
{
|
||||
$this->destroyImagesFromPath($image->path);
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys an image at the given path.
|
||||
* Searches for image thumbnails in addition to main provided path..
|
||||
* @param string $path
|
||||
* @return bool
|
||||
*/
|
||||
protected function destroyImagesFromPath(string $path)
|
||||
{
|
||||
$storage = $this->getStorage();
|
||||
|
||||
$imageFolder = dirname($this->getPath($image));
|
||||
$imageFileName = basename($this->getPath($image));
|
||||
$imageFolder = dirname($path);
|
||||
$imageFileName = basename($path);
|
||||
$allImages = collect($storage->allFiles($imageFolder));
|
||||
|
||||
// Delete image files
|
||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
|
||||
return strpos($imagePath, $imageFileName) === $expectedIndex;
|
||||
});
|
||||
|
||||
$storage->delete($imagesToDelete->all());
|
||||
|
||||
// Cleanup of empty folders
|
||||
foreach ($storage->directories($imageFolder) as $directory) {
|
||||
$foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
|
||||
foreach ($foldersInvolved as $directory) {
|
||||
if ($this->isFolderEmpty($directory)) {
|
||||
$storage->deleteDirectory($directory);
|
||||
}
|
||||
}
|
||||
if ($this->isFolderEmpty($imageFolder)) {
|
||||
$storage->deleteDirectory($imageFolder);
|
||||
}
|
||||
|
||||
$image->delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -321,6 +298,46 @@ class ImageService extends UploadService
|
||||
return $image;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
|
||||
* Checks based off of only the image name.
|
||||
* Could be much improved to be more specific but kept it generic for now to be safe.
|
||||
*
|
||||
* Returns the path of the images that would be/have been deleted.
|
||||
* @param bool $checkRevisions
|
||||
* @param bool $dryRun
|
||||
* @param array $types
|
||||
* @return array
|
||||
*/
|
||||
public function deleteUnusedImages($checkRevisions = true, $dryRun = true, $types = ['gallery', 'drawio'])
|
||||
{
|
||||
$types = array_intersect($types, ['gallery', 'drawio']);
|
||||
$deletedPaths = [];
|
||||
|
||||
$this->image->newQuery()->whereIn('type', $types)
|
||||
->chunk(1000, function($images) use ($types, $checkRevisions, &$deletedPaths, $dryRun) {
|
||||
foreach ($images as $image) {
|
||||
$searchQuery = '%' . basename($image->path) . '%';
|
||||
$inPage = DB::table('pages')
|
||||
->where('html', 'like', $searchQuery)->count() > 0;
|
||||
$inRevision = false;
|
||||
if ($checkRevisions) {
|
||||
$inRevision = DB::table('page_revisions')
|
||||
->where('html', 'like', $searchQuery)->count() > 0;
|
||||
}
|
||||
|
||||
if (!$inPage && !$inRevision) {
|
||||
$deletedPaths[] = $image->path;
|
||||
if (!$dryRun) {
|
||||
$this->destroy($image);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return $deletedPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a image URI to a Base64 encoded string.
|
||||
* Attempts to find locally via set storage method first.
|
||||
|
Reference in New Issue
Block a user