<?php namespace BookStack\Uploads;
-use BookStack\Auth\User;
use BookStack\Exceptions\ImageUploadException;
use DB;
+use ErrorException;
use Exception;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
+use Illuminate\Contracts\Filesystem\FileNotFoundException;
+use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\ImageManager;
use Symfony\Component\HttpFoundation\File\UploadedFile;
-class ImageService extends UploadService
+class ImageService
{
-
protected $imageTool;
protected $cache;
protected $storageUrl;
protected $image;
+ protected $fileSystem;
/**
* ImageService constructor.
- * @param Image $image
- * @param ImageManager $imageTool
- * @param FileSystem $fileSystem
- * @param Cache $cache
*/
public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->image = $image;
$this->imageTool = $imageTool;
+ $this->fileSystem = $fileSystem;
$this->cache = $cache;
- parent::__construct($fileSystem);
}
/**
* Get the storage that will be used for storing images.
- * @param string $type
- * @return \Illuminate\Contracts\Filesystem\Filesystem
*/
- protected function getStorage($type = '')
+ protected function getStorage(string $type = ''): FileSystemInstance
{
- $storageType = config('filesystems.default');
+ $storageType = config('filesystems.images');
- // Override default location if set to local public to ensure not visible.
+ // Ensure system images (App logo) are uploaded to a public space
if ($type === 'system' && $storageType === 'local_secure') {
$storageType = 'local';
}
/**
* Saves a new image from an upload.
- * @param UploadedFile $uploadedFile
- * @param string $type
- * @param int $uploadedTo
* @return mixed
* @throws ImageUploadException
*/
- public function saveNewFromUpload(UploadedFile $uploadedFile, $type, $uploadedTo = 0)
- {
+ public function saveNewFromUpload(
+ UploadedFile $uploadedFile,
+ string $type,
+ int $uploadedTo = 0,
+ int $resizeWidth = null,
+ int $resizeHeight = null,
+ bool $keepRatio = true
+ ) {
$imageName = $uploadedFile->getClientOriginalName();
$imageData = file_get_contents($uploadedFile->getRealPath());
+
+ if ($resizeWidth !== null || $resizeHeight !== null) {
+ $imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
+ }
+
return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
}
/**
* Save a new image from a uri-encoded base64 string of data.
- * @param string $base64Uri
- * @param string $name
- * @param string $type
- * @param int $uploadedTo
- * @return Image
* @throws ImageUploadException
*/
- public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0)
+ public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
{
$splitData = explode(';base64,', $base64Uri);
if (count($splitData) < 2) {
}
/**
- * Gets an image from url and saves it to the database.
- * @param $url
- * @param string $type
- * @param bool|string $imageName
- * @return mixed
- * @throws \Exception
- */
- private function saveNewFromUrl($url, $type, $imageName = false)
- {
- $imageName = $imageName ? $imageName : basename($url);
- $imageData = file_get_contents($url);
- if ($imageData === false) {
- throw new \Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
- }
- return $this->saveNew($imageName, $imageData, $type);
- }
-
- /**
- * Saves a new image
- * @param string $imageName
- * @param string $imageData
- * @param string $type
- * @param int $uploadedTo
- * @return Image
+ * Save a new image into storage.
* @throws ImageUploadException
*/
- private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
+ public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$storage = $this->getStorage($type);
$secureUploads = setting('app-secure-images');
- $imageName = str_replace(' ', '-', $imageName);
+ $fileName = $this->cleanImageFileName($imageName);
- $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m-M') . '/';
+ $imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
- while ($storage->exists($imagePath . $imageName)) {
- $imageName = str_random(3) . $imageName;
+ while ($storage->exists($imagePath . $fileName)) {
+ $fileName = Str::random(3) . $fileName;
}
- $fullPath = $imagePath . $imageName;
+ $fullPath = $imagePath . $fileName;
if ($secureUploads) {
- $fullPath = $imagePath . str_random(16) . '-' . $imageName;
+ $fullPath = $imagePath . Str::random(16) . '-' . $fileName;
}
try {
}
$imageDetails = [
- 'name' => $imageName,
- 'path' => $fullPath,
- 'url' => $this->getPublicUrl($fullPath),
- 'type' => $type,
+ 'name' => $imageName,
+ 'path' => $fullPath,
+ 'url' => $this->getPublicUrl($fullPath),
+ 'type' => $type,
'uploaded_to' => $uploadedTo
];
return $image;
}
+ /**
+ * 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.
- * @param Image $image
- * @return boolean
*/
- protected function isGif(Image $image)
+ protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
return $this->getPublicUrl($thumbFilePath);
}
+ $thumbData = $this->resizeImage($storage->get($imagePath), $width, $height, $keepRatio);
+
+ $storage->put($thumbFilePath, $thumbData);
+ $storage->setVisibility($thumbFilePath, 'public');
+ $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
+
+
+ return $this->getPublicUrl($thumbFilePath);
+ }
+
+ /**
+ * Resize image data.
+ * @param string $imageData
+ * @param int $width
+ * @param int $height
+ * @param bool $keepRatio
+ * @return string
+ * @throws ImageUploadException
+ */
+ protected function resizeImage(string $imageData, $width = 220, $height = null, bool $keepRatio = true)
+ {
try {
- $thumb = $this->imageTool->make($storage->get($imagePath));
+ $thumb = $this->imageTool->make($imageData);
} catch (Exception $e) {
- if ($e instanceof \ErrorException || $e instanceof NotSupportedException) {
+ if ($e instanceof ErrorException || $e instanceof NotSupportedException) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
throw $e;
}
if ($keepRatio) {
- $thumb->resize($width, null, function ($constraint) {
+ $thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
}
$thumbData = (string)$thumb->encode();
- $storage->put($thumbFilePath, $thumbData);
- $storage->setVisibility($thumbFilePath, 'public');
- $this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 72);
- return $this->getPublicUrl($thumbFilePath);
+ // 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;
}
/**
* Get the raw data content from an image.
- * @param Image $image
- * @return string
- * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+ * @throws FileNotFoundException
*/
- public function getImageData(Image $image)
+ public function getImageData(Image $image): string
{
$imagePath = $image->path;
$storage = $this->getStorage();
/**
* Destroy an image along with its revisions, thumbnails and remaining folders.
- * @param Image $image
* @throws Exception
*/
public function destroy(Image $image)
/**
* Destroys an image at the given path.
- * Searches for image thumbnails in addition to main provided path..
- * @param string $path
- * @return bool
+ * Searches for image thumbnails in addition to main provided path.
*/
- protected function destroyImagesFromPath(string $path)
+ protected function destroyImagesFromPath(string $path): bool
{
$storage = $this->getStorage();
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
- $expectedIndex = strlen($imagePath) - strlen($imageFileName);
- return strpos($imagePath, $imageFileName) === $expectedIndex;
+ 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($directory)) {
+ if ($this->isFolderEmpty($storage, $directory)) {
$storage->deleteDirectory($directory);
}
}
}
/**
- * Save a gravatar image and set a the profile image for a user.
- * @param \BookStack\Auth\User $user
- * @param int $size
- * @return mixed
- * @throws Exception
+ * Check whether or not a folder is empty.
*/
- public function saveUserGravatar(User $user, $size = 500)
+ protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
{
- $emailHash = md5(strtolower(trim($user->email)));
- $url = 'https://www.gravatar.com/avatar/' . $emailHash . '?s=' . $size . '&d=identicon';
- $imageName = str_replace(' ', '-', $user->name . '-gravatar.png');
- $image = $this->saveNewFromUrl($url, 'user', $imageName);
- $image->created_by = $user->id;
- $image->updated_by = $user->id;
- $image->save();
- return $image;
+ $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.
* 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'])
+ public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
{
- $types = array_intersect($types, ['gallery', 'drawio']);
+ $types = ['gallery', 'drawio'];
$deletedPaths = [];
$this->image->newQuery()->whereIn('type', $types)
- ->chunk(1000, function ($images) use ($types, $checkRevisions, &$deletedPaths, $dryRun) {
+ ->chunk(1000, function ($images) use ($checkRevisions, &$deletedPaths, $dryRun) {
foreach ($images as $image) {
$searchQuery = '%' . basename($image->path) . '%';
$inPage = DB::table('pages')
- ->where('html', 'like', $searchQuery)->count() > 0;
+ ->where('html', 'like', $searchQuery)->count() > 0;
+
$inRevision = false;
if ($checkRevisions) {
- $inRevision = DB::table('page_revisions')
- ->where('html', 'like', $searchQuery)->count() > 0;
+ $inRevision = DB::table('page_revisions')
+ ->where('html', 'like', $searchQuery)->count() > 0;
}
if (!$inPage && !$inRevision) {
/**
* Convert a image URI to a Base64 encoded string.
- * Attempts to find locally via set storage method first.
- * @param string $uri
- * @return null|string
- * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
+ * 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)
+ public function imageUriToBase64(string $uri): ?string
{
- $isLocal = strpos(trim($uri), 'http') !== 0;
-
- // Attempt to find local files even if url not absolute
- $base = baseUrl('/');
- if (!$isLocal && strpos($uri, $base) === 0) {
- $isLocal = true;
- $uri = str_replace($base, '', $uri);
+ $storagePath = $this->imageUrlToStoragePath($uri);
+ if (empty($uri) || is_null($storagePath)) {
+ return null;
}
+ $storage = $this->getStorage();
$imageData = null;
+ if ($storage->exists($storagePath)) {
+ $imageData = $storage->get($storagePath);
+ }
- if ($isLocal) {
- $uri = trim($uri, '/');
- $storage = $this->getStorage();
- if ($storage->exists($uri)) {
- $imageData = $storage->get($uri);
- }
- } else {
- try {
- $ch = curl_init();
- curl_setopt_array($ch, [CURLOPT_URL => $uri, CURLOPT_RETURNTRANSFER => 1, CURLOPT_CONNECTTIMEOUT => 5]);
- $imageData = curl_exec($ch);
- $err = curl_error($ch);
- curl_close($ch);
- if ($err) {
- throw new \Exception("Image fetch failed, Received error: " . $err);
- }
- } catch (\Exception $e) {
- }
+ if (is_null($imageData)) {
+ return null;
+ }
+
+ $extension = pathinfo($uri, PATHINFO_EXTENSION);
+ if ($extension === 'svg') {
+ $extension = 'svg+xml';
}
- if ($imageData === null) {
+ return 'data:image/' . $extension . ';base64,' . base64_encode($imageData);
+ }
+
+ /**
+ * 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;
}
- return 'data:image/' . pathinfo($uri, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageData);
+ // 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.
- * @param string $filePath
- * @return string
+ * If s3-style store is in use it will default to guessing a public bucket URL.
*/
- private function getPublicUrl($filePath)
+ private function getPublicUrl(string $filePath): string
{
if ($this->storageUrl === null) {
$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 == false && config('filesystems.default') === 's3') {
+ if ($storageUrl == false && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
$this->storageUrl = $storageUrl;
}
- $basePath = ($this->storageUrl == false) ? baseUrl('/') : $this->storageUrl;
+ $basePath = ($this->storageUrl == false) ? url('/') : $this->storageUrl;
return rtrim($basePath, '/') . $filePath;
}
}