Merge branch 'v0.30.x'

This commit is contained in:
Dan Brown 2020-12-06 21:32:01 +00:00
commit 65b2c90522
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
6 changed files with 136 additions and 148 deletions

View File

@ -12,11 +12,13 @@
APP_KEY=SomeRandomString APP_KEY=SomeRandomString
# Application URL # Application URL
# Remove the hash below and set a URL if using BookStack behind
# a proxy or if using a third-party authentication option.
# This must be the root URL that you want to host BookStack on. # This must be the root URL that you want to host BookStack on.
# All URL's in BookStack will be generated using this value. # All URLs in BookStack will be generated using this value
#APP_URL=https://example.com # to ensure URLs generated are consistent and secure.
# If you change this in the future you may need to run a command
# to update stored URLs in the database. Command example:
# php artisan bookstack:update-url https://old.example.com https://new.example.com
APP_URL=https://example.com
# Database details # Database details
DB_HOST=localhost DB_HOST=localhost
@ -28,8 +30,8 @@ DB_PASSWORD=database_user_password
# Can be 'smtp' or 'sendmail' # Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
# Mail sender options # Mail sender details
MAIL_FROM_NAME=BookStack MAIL_FROM_NAME="BookStack"
MAIL_FROM=bookstack@example.com MAIL_FROM=bookstack@example.com
# SMTP mail options # SMTP mail options

View File

@ -42,13 +42,6 @@ return [
'root' => storage_path(), 'root' => storage_path(),
], ],
'ftp' => [
'driver' => 'ftp',
'host' => 'ftp.example.com',
'username' => 'your-username',
'password' => 'your-password',
],
's3' => [ 's3' => [
'driver' => 's3', 'driver' => 's3',
'key' => env('STORAGE_S3_KEY', 'your-key'), 'key' => env('STORAGE_S3_KEY', 'your-key'),
@ -59,16 +52,6 @@ return [
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null, 'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
], ],
'rackspace' => [
'driver' => 'rackspace',
'username' => 'your-username',
'key' => 'your-key',
'container' => 'your-container',
'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/',
'region' => 'IAD',
'url_type' => 'publicURL',
],
], ],
]; ];

View File

@ -2,17 +2,29 @@
use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\FileUploadException;
use Exception; use Exception;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService extends UploadService class AttachmentService
{ {
protected $fileSystem;
/**
* AttachmentService constructor.
*/
public function __construct(FileSystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/** /**
* Get the storage that will be used for storing files. * Get the storage that will be used for storing files.
* @return \Illuminate\Contracts\Filesystem\Filesystem
*/ */
protected function getStorage() protected function getStorage(): FileSystemInstance
{ {
$storageType = config('filesystems.attachments'); $storageType = config('filesystems.attachments');

View File

@ -4,16 +4,18 @@ use BookStack\Auth\User;
use BookStack\Exceptions\HttpFetchException; use BookStack\Exceptions\HttpFetchException;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use DB; use DB;
use ErrorException;
use Exception; use Exception;
use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\Factory as FileSystem; use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException; use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use phpDocumentor\Reflection\Types\Integer;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
class ImageService extends UploadService class ImageService
{ {
protected $imageTool; protected $imageTool;
@ -21,30 +23,24 @@ class ImageService extends UploadService
protected $storageUrl; protected $storageUrl;
protected $image; protected $image;
protected $http; protected $http;
protected $fileSystem;
/** /**
* ImageService constructor. * ImageService constructor.
* @param Image $image
* @param ImageManager $imageTool
* @param FileSystem $fileSystem
* @param Cache $cache
* @param HttpFetcher $http
*/ */
public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache, HttpFetcher $http) public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache, HttpFetcher $http)
{ {
$this->image = $image; $this->image = $image;
$this->imageTool = $imageTool; $this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache; $this->cache = $cache;
$this->http = $http; $this->http = $http;
parent::__construct($fileSystem);
} }
/** /**
* Get the storage that will be used for storing images. * 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.images'); $storageType = config('filesystems.images');
@ -58,12 +54,6 @@ class ImageService extends UploadService
/** /**
* Saves a new image from an upload. * Saves a new image from an upload.
* @param UploadedFile $uploadedFile
* @param string $type
* @param int $uploadedTo
* @param int|null $resizeWidth
* @param int|null $resizeHeight
* @param bool $keepRatio
* @return mixed * @return mixed
* @throws ImageUploadException * @throws ImageUploadException
*/ */
@ -110,7 +100,7 @@ class ImageService extends UploadService
* @param string $type * @param string $type
* @param bool|string $imageName * @param bool|string $imageName
* @return mixed * @return mixed
* @throws \Exception * @throws Exception
*/ */
private function saveNewFromUrl($url, $type, $imageName = false) private function saveNewFromUrl($url, $type, $imageName = false)
{ {
@ -118,7 +108,7 @@ class ImageService extends UploadService
try { try {
$imageData = $this->http->fetch($url); $imageData = $this->http->fetch($url);
} catch (HttpFetchException $exception) { } catch (HttpFetchException $exception) {
throw new \Exception(trans('errors.cannot_get_image_from_url', ['url' => $url])); throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
} }
return $this->saveNew($imageName, $imageData, $type); return $this->saveNew($imageName, $imageData, $type);
} }
@ -190,10 +180,8 @@ class ImageService extends UploadService
/** /**
* Checks if the image is a gif. Returns true if it is, else false. * 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 strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
} }
@ -253,7 +241,7 @@ class ImageService extends UploadService
try { try {
$thumb = $this->imageTool->make($imageData); $thumb = $this->imageTool->make($imageData);
} catch (Exception $e) { } 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 new ImageUploadException(trans('errors.cannot_create_thumbs'));
} }
throw $e; throw $e;
@ -281,11 +269,9 @@ class ImageService extends UploadService
/** /**
* Get the raw data content from an image. * Get the raw data content from an image.
* @param Image $image * @throws FileNotFoundException
* @return string
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
*/ */
public function getImageData(Image $image) public function getImageData(Image $image): string
{ {
$imagePath = $image->path; $imagePath = $image->path;
$storage = $this->getStorage(); $storage = $this->getStorage();
@ -294,7 +280,6 @@ class ImageService extends UploadService
/** /**
* Destroy an image along with its revisions, thumbnails and remaining folders. * Destroy an image along with its revisions, thumbnails and remaining folders.
* @param Image $image
* @throws Exception * @throws Exception
*/ */
public function destroy(Image $image) public function destroy(Image $image)
@ -324,7 +309,7 @@ class ImageService extends UploadService
// Cleanup of empty folders // Cleanup of empty folders
$foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder)); $foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
foreach ($foldersInvolved as $directory) { foreach ($foldersInvolved as $directory) {
if ($this->isFolderEmpty($directory)) { if ($this->isFolderEmpty($storage, $directory)) {
$storage->deleteDirectory($directory); $storage->deleteDirectory($directory);
} }
} }
@ -332,14 +317,21 @@ class ImageService extends UploadService
return true; return true;
} }
/**
* Check whether or not a folder is empty.
*/
protected function isFolderEmpty(FileSystemInstance $storage, string $path): bool
{
$files = $storage->files($path);
$folders = $storage->directories($path);
return (count($files) === 0 && count($folders) === 0);
}
/** /**
* Save an avatar image from an external service. * Save an avatar image from an external service.
* @param \BookStack\Auth\User $user
* @param int $size
* @return Image
* @throws Exception * @throws Exception
*/ */
public function saveUserAvatar(User $user, $size = 500) public function saveUserAvatar(User $user, int $size = 500): Image
{ {
$avatarUrl = $this->getAvatarUrl(); $avatarUrl = $this->getAvatarUrl();
$email = strtolower(trim($user->email)); $email = strtolower(trim($user->email));
@ -363,9 +355,8 @@ class ImageService extends UploadService
/** /**
* Check if fetching external avatars is enabled. * Check if fetching external avatars is enabled.
* @return bool
*/ */
public function avatarFetchEnabled() public function avatarFetchEnabled(): bool
{ {
$fetchUrl = $this->getAvatarUrl(); $fetchUrl = $this->getAvatarUrl();
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0; return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
@ -427,38 +418,25 @@ class ImageService extends UploadService
/** /**
* Convert a image URI to a Base64 encoded string. * Convert a image URI to a Base64 encoded string.
* Attempts to find locally via set storage method first. * Attempts to convert the URL to a system storage url then
* @param string $uri * fetch the data from the disk or storage location.
* @return null|string * Returns null if the image data cannot be fetched from storage.
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException * @throws FileNotFoundException
*/ */
public function imageUriToBase64(string $uri) public function imageUriToBase64(string $uri): ?string
{ {
$isLocal = strpos(trim($uri), 'http') !== 0; $storagePath = $this->imageUrlToStoragePath($uri);
if (empty($uri) || is_null($storagePath)) {
// Attempt to find local files even if url not absolute return null;
$base = url('/');
if (!$isLocal && strpos($uri, $base) === 0) {
$isLocal = true;
$uri = str_replace($base, '', $uri);
} }
$imageData = null;
if ($isLocal) {
$uri = trim($uri, '/');
$storage = $this->getStorage(); $storage = $this->getStorage();
if ($storage->exists($uri)) { $imageData = null;
$imageData = $storage->get($uri); if ($storage->exists($storagePath)) {
} $imageData = $storage->get($storagePath);
} else {
try {
$imageData = $this->http->fetch($uri);
} catch (\Exception $e) {
}
} }
if ($imageData === null) { if (is_null($imageData)) {
return null; return null;
} }
@ -471,11 +449,44 @@ class ImageService extends UploadService
} }
/** /**
* Gets a public facing url for an image by checking relevant environment variables. * Get a storage path for the given image URL.
* @param string $filePath * Ensures the path will start with "uploads/images".
* @return string * Returns null if the url cannot be resolved to a local URL.
*/ */
private function getPublicUrl($filePath) 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
{ {
if ($this->storageUrl === null) { if ($this->storageUrl === null) {
$storageUrl = config('filesystems.url'); $storageUrl = config('filesystems.url');

View File

@ -1,45 +0,0 @@
<?php namespace BookStack\Uploads;
use Illuminate\Contracts\Filesystem\Factory as FileSystem;
use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
abstract class UploadService
{
/**
* @var FileSystem
*/
protected $fileSystem;
/**
* FileService constructor.
* @param $fileSystem
*/
public function __construct(FileSystem $fileSystem)
{
$this->fileSystem = $fileSystem;
}
/**
* Get the storage that will be used for storing images.
* @return FileSystemInstance
*/
protected function getStorage()
{
$storageType = config('filesystems.default');
return $this->fileSystem->disk($storageType);
}
/**
* Check whether or not a folder is empty.
* @param $path
* @return bool
*/
protected function isFolderEmpty($path)
{
$files = $this->getStorage()->files($path);
$folders = $this->getStorage()->directories($path);
return (count($files) === 0 && count($folders) === 0);
}
}

View File

@ -3,7 +3,7 @@
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Uploads\HttpFetcher; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
@ -154,14 +154,39 @@ class ExportTest extends TestCase
public function test_page_export_sets_right_data_type_for_svg_embeds() public function test_page_export_sets_right_data_type_for_svg_embeds()
{ {
$page = Page::first(); $page = Page::first();
$page->html = '<img src="http://example.com/image.svg">'; Storage::disk('local')->makeDirectory('uploads/images/gallery');
Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
$page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg">';
$page->save(); $page->save();
$this->asEditor(); $this->asEditor();
$this->mockHttpFetch('<svg></svg>');
$resp = $this->get($page->getUrl('/export/html')); $resp = $this->get($page->getUrl('/export/html'));
Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
$resp->assertStatus(200); $resp->assertStatus(200);
$resp->assertSee('<img src="data:image/svg+xml;base64'); $resp->assertSee('<img src="data:image/svg+xml;base64');
} }
public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
{
$page = Page::first();
$page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
."\n".'<img src="http://localhost/uploads/svg_test.svg"/>'
."\n".'<img src="/uploads/svg_test.svg"/>';
$storageDisk = Storage::disk('local');
$storageDisk->makeDirectory('uploads/images/gallery');
$storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
$storageDisk->put('uploads/svg_test.svg', '<svg>bad</svg>');
$page->save();
$resp = $this->asEditor()->get($page->getUrl('/export/html'));
$storageDisk->delete('uploads/images/gallery/svg_test.svg');
$storageDisk->delete('uploads/svg_test.svg');
$resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test.svg');
$resp->assertSee('http://localhost/uploads/svg_test.svg');
$resp->assertSee('src="/uploads/svg_test.svg"');
}
} }