diff --git a/.env.example b/.env.example
index 47f2367b0..05383f04a 100644
--- a/.env.example
+++ b/.env.example
@@ -12,11 +12,13 @@
APP_KEY=SomeRandomString
# 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.
-# All URL's in BookStack will be generated using this value.
-#APP_URL=https://example.com
+# All URLs in BookStack will be generated using this value
+# 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
DB_HOST=localhost
@@ -28,8 +30,8 @@ DB_PASSWORD=database_user_password
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
-# Mail sender options
-MAIL_FROM_NAME=BookStack
+# Mail sender details
+MAIL_FROM_NAME="BookStack"
MAIL_FROM=bookstack@example.com
# SMTP mail options
diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php
index bd7d28300..30a5c5369 100644
--- a/app/Config/filesystems.php
+++ b/app/Config/filesystems.php
@@ -42,13 +42,6 @@ return [
'root' => storage_path(),
],
- 'ftp' => [
- 'driver' => 'ftp',
- 'host' => 'ftp.example.com',
- 'username' => 'your-username',
- 'password' => 'your-password',
- ],
-
's3' => [
'driver' => 's3',
'key' => env('STORAGE_S3_KEY', 'your-key'),
@@ -59,16 +52,6 @@ return [
'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',
- ],
-
],
];
diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php
index e85901e17..b14f49473 100644
--- a/app/Uploads/AttachmentService.php
+++ b/app/Uploads/AttachmentService.php
@@ -2,17 +2,29 @@
use BookStack\Exceptions\FileUploadException;
use Exception;
+use Illuminate\Contracts\Filesystem\Factory as FileSystem;
+use Illuminate\Contracts\Filesystem\Filesystem as FileSystemInstance;
use Illuminate\Support\Str;
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.
- * @return \Illuminate\Contracts\Filesystem\Filesystem
*/
- protected function getStorage()
+ protected function getStorage(): FileSystemInstance
{
$storageType = config('filesystems.attachments');
diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php
index 89744386d..1e5ad8aa1 100644
--- a/app/Uploads/ImageService.php
+++ b/app/Uploads/ImageService.php
@@ -4,16 +4,18 @@ use BookStack\Auth\User;
use BookStack\Exceptions\HttpFetchException;
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 phpDocumentor\Reflection\Types\Integer;
use Symfony\Component\HttpFoundation\File\UploadedFile;
-class ImageService extends UploadService
+class ImageService
{
protected $imageTool;
@@ -21,30 +23,24 @@ class ImageService extends UploadService
protected $storageUrl;
protected $image;
protected $http;
+ protected $fileSystem;
/**
* 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)
{
$this->image = $image;
$this->imageTool = $imageTool;
+ $this->fileSystem = $fileSystem;
$this->cache = $cache;
$this->http = $http;
- 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.images');
@@ -58,12 +54,6 @@ class ImageService extends UploadService
/**
* 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
* @throws ImageUploadException
*/
@@ -107,10 +97,10 @@ class ImageService extends UploadService
/**
* Gets an image from url and saves it to the database.
* @param $url
- * @param string $type
+ * @param string $type
* @param bool|string $imageName
* @return mixed
- * @throws \Exception
+ * @throws Exception
*/
private function saveNewFromUrl($url, $type, $imageName = false)
{
@@ -118,7 +108,7 @@ class ImageService extends UploadService
try {
$imageData = $this->http->fetch($url);
} 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);
}
@@ -152,10 +142,10 @@ class ImageService extends UploadService
}
$imageDetails = [
- 'name' => $imageName,
- 'path' => $fullPath,
- 'url' => $this->getPublicUrl($fullPath),
- 'type' => $type,
+ 'name' => $imageName,
+ 'path' => $fullPath,
+ 'url' => $this->getPublicUrl($fullPath),
+ 'type' => $type,
'uploaded_to' => $uploadedTo
];
@@ -185,15 +175,13 @@ class ImageService extends UploadService
$name = Str::random(10);
}
- return $name . '.' . $extension;
+ 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';
}
@@ -253,7 +241,7 @@ class ImageService extends UploadService
try {
$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;
@@ -281,11 +269,9 @@ class ImageService extends UploadService
/**
* 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();
@@ -294,7 +280,6 @@ class ImageService extends UploadService
/**
* Destroy an image along with its revisions, thumbnails and remaining folders.
- * @param Image $image
* @throws Exception
*/
public function destroy(Image $image)
@@ -324,7 +309,7 @@ class ImageService extends UploadService
// 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);
}
}
@@ -332,14 +317,21 @@ class ImageService extends UploadService
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.
- * @param \BookStack\Auth\User $user
- * @param int $size
- * @return Image
* @throws Exception
*/
- public function saveUserAvatar(User $user, $size = 500)
+ public function saveUserAvatar(User $user, int $size = 500): Image
{
$avatarUrl = $this->getAvatarUrl();
$email = strtolower(trim($user->email));
@@ -363,9 +355,8 @@ class ImageService extends UploadService
/**
* Check if fetching external avatars is enabled.
- * @return bool
*/
- public function avatarFetchEnabled()
+ public function avatarFetchEnabled(): bool
{
$fetchUrl = $this->getAvatarUrl();
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
@@ -407,11 +398,11 @@ class ImageService extends UploadService
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) {
@@ -427,38 +418,25 @@ class ImageService extends UploadService
/**
* 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 = url('/');
- 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 ($isLocal) {
- $uri = trim($uri, '/');
- $storage = $this->getStorage();
- if ($storage->exists($uri)) {
- $imageData = $storage->get($uri);
- }
- } else {
- try {
- $imageData = $this->http->fetch($uri);
- } catch (\Exception $e) {
- }
+ if ($storage->exists($storagePath)) {
+ $imageData = $storage->get($storagePath);
}
- if ($imageData === null) {
+ if (is_null($imageData)) {
return null;
}
@@ -471,11 +449,44 @@ class ImageService extends UploadService
}
/**
- * Gets a public facing url for an image by checking relevant environment variables.
- * @param string $filePath
- * @return string
+ * 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 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) {
$storageUrl = config('filesystems.url');
diff --git a/app/Uploads/UploadService.php b/app/Uploads/UploadService.php
deleted file mode 100644
index 292e61e30..000000000
--- a/app/Uploads/UploadService.php
+++ /dev/null
@@ -1,45 +0,0 @@
-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);
- }
-}
diff --git a/tests/Entity/ExportTest.php b/tests/Entity/ExportTest.php
index e022f92f5..7c56a7268 100644
--- a/tests/Entity/ExportTest.php
+++ b/tests/Entity/ExportTest.php
@@ -3,7 +3,7 @@
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
-use BookStack\Uploads\HttpFetcher;
+use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -154,14 +154,39 @@ class ExportTest extends TestCase
public function test_page_export_sets_right_data_type_for_svg_embeds()
{
$page = Page::first();
- $page->html = '';
+ Storage::disk('local')->makeDirectory('uploads/images/gallery');
+ Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '');
+ $page->html = '';
$page->save();
$this->asEditor();
- $this->mockHttpFetch('');
$resp = $this->get($page->getUrl('/export/html'));
+ Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+
$resp->assertStatus(200);
$resp->assertSee(''
+ ."\n".''
+ ."\n".'';
+ $storageDisk = Storage::disk('local');
+ $storageDisk->makeDirectory('uploads/images/gallery');
+ $storageDisk->put('uploads/images/gallery/svg_test.svg', '');
+ $storageDisk->put('uploads/svg_test.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"');
+ }
+
+}