mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-12 19:06:27 +03:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
2acef3c2ec | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
3f3fad7113 | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
884664bfe9 | ||
|
|
8911e3f441 | ||
|
|
7d38c96a23 | ||
|
|
162d893143 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
6d8b0605a0 | ||
|
|
349162ea13 | ||
|
|
bbd1384acb | ||
|
|
6aa2bf9e27 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
18bcafaee4 | ||
|
|
8d07b7cf1c | ||
|
|
080f9c3025 | ||
|
|
617fe6bc8c | ||
|
|
bb1f1a9ecd | ||
|
|
d688e43197 | ||
|
|
c82c3023c5 | ||
|
|
d0d75afc66 | ||
|
|
467176ee78 | ||
|
|
521a002001 | ||
|
|
aca37b8784 | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
f3ee8f2d4c | ||
|
|
ea406690f5 | ||
|
|
465d405926 | ||
|
|
1097c61d6d | ||
|
|
def2d61ad8 | ||
|
|
8b0f5e7000 | ||
|
|
1e88e8086f | ||
|
|
d48ac0a37d |
14
.env.example
14
.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
|
||||
|
||||
@@ -238,9 +238,9 @@ DISABLE_EXTERNAL_SERVICES=false
|
||||
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
|
||||
AVATAR_URL=
|
||||
|
||||
# Enable draw.io integration
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the draw.io instance you want to use.
|
||||
# Alternatively, It can be URL to the diagrams.net instance you want to use.
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
|
||||
DRAWIO=true
|
||||
|
||||
|
||||
1
.github/translators.txt
vendored
1
.github/translators.txt
vendored
@@ -122,3 +122,4 @@ fadiapp :: Arabic
|
||||
Jakub Bouček (jakubboucek) :: Czech
|
||||
Marco (cdrfun) :: German
|
||||
10935336 :: Chinese Simplified
|
||||
孟繁阳 (FanyangMeng) :: Chinese Simplified
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ use Illuminate\Support\Collection;
|
||||
/**
|
||||
* Class Chapter
|
||||
* @property Collection<Page> $pages
|
||||
* @package BookStack\Entities
|
||||
*/
|
||||
class Chapter extends BookChild
|
||||
{
|
||||
@@ -52,15 +51,6 @@ class Chapter extends BookChild
|
||||
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this chapter has any child pages.
|
||||
* @return bool
|
||||
*/
|
||||
public function hasChildren()
|
||||
{
|
||||
return count($this->pages) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
*/
|
||||
|
||||
@@ -203,7 +203,7 @@ class ExportService
|
||||
{
|
||||
$text = $chapter->name . "\n\n";
|
||||
$text .= $chapter->description . "\n\n";
|
||||
foreach ($chapter->pages as $page) {
|
||||
foreach ($chapter->getVisiblePages() as $page) {
|
||||
$text .= $this->pageToPlainText($page);
|
||||
}
|
||||
return $text;
|
||||
@@ -214,7 +214,7 @@ class ExportService
|
||||
*/
|
||||
public function bookToPlainText(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$bookTree = (new BookContents($book))->getTree(false, false);
|
||||
$text = $book->name . "\n\n";
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild->isA('chapter')) {
|
||||
|
||||
@@ -53,12 +53,16 @@ class BookContents
|
||||
$pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
|
||||
$chapter = $chapterMap->get($chapter_id);
|
||||
if ($chapter) {
|
||||
$chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc()));
|
||||
$chapter->setAttribute('visible_pages', collect($pages)->sortBy($this->bookChildSortFunc()));
|
||||
} else {
|
||||
$lonePages = $lonePages->concat($pages);
|
||||
}
|
||||
});
|
||||
|
||||
$chapters->whereNull('visible_pages')->each(function (Chapter $chapter) {
|
||||
$chapter->setAttribute('visible_pages', collect([]));
|
||||
});
|
||||
|
||||
$all->each(function (Entity $entity) use ($renderPages) {
|
||||
$entity->setRelation('book', $this->book);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use BookStack\Entities\Page;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
|
||||
@@ -44,18 +43,24 @@ class PageContent
|
||||
$container = $doc->documentElement;
|
||||
$body = $container->childNodes->item(0);
|
||||
$childNodes = $body->childNodes;
|
||||
$xPath = new DOMXPath($doc);
|
||||
|
||||
// Set ids on top-level nodes
|
||||
$idMap = [];
|
||||
foreach ($childNodes as $index => $childNode) {
|
||||
$this->setUniqueId($childNode, $idMap);
|
||||
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no duplicate ids within child items
|
||||
$xPath = new DOMXPath($doc);
|
||||
$idElems = $xPath->query('//body//*//*[@id]');
|
||||
foreach ($idElems as $domElem) {
|
||||
$this->setUniqueId($domElem, $idMap);
|
||||
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate inner html as a string
|
||||
@@ -67,23 +72,34 @@ class PageContent
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the all links to the $old location to instead point to $new.
|
||||
*/
|
||||
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
|
||||
{
|
||||
$old = str_replace('"', '', $old);
|
||||
$matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]');
|
||||
foreach ($matchingLinks as $domElem) {
|
||||
$domElem->setAttribute('href', $new);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a unique id on the given DOMElement.
|
||||
* A map for existing ID's should be passed in to check for current existence.
|
||||
* @param DOMElement $element
|
||||
* @param array $idMap
|
||||
* Returns a pair of strings in the format [old_id, new_id]
|
||||
*/
|
||||
protected function setUniqueId($element, array &$idMap)
|
||||
protected function setUniqueId(\DOMNode $element, array &$idMap): array
|
||||
{
|
||||
if (get_class($element) !== 'DOMElement') {
|
||||
return;
|
||||
return ['', ''];
|
||||
}
|
||||
|
||||
// Overwrite id if not a BookStack custom id
|
||||
// Stop if there's an existing valid id that has not already been used.
|
||||
$existingId = $element->getAttribute('id');
|
||||
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
|
||||
$idMap[$existingId] = true;
|
||||
return;
|
||||
return [$existingId, $existingId];
|
||||
}
|
||||
|
||||
// Create an unique id for the element
|
||||
@@ -100,6 +116,7 @@ class PageContent
|
||||
|
||||
$element->setAttribute('id', $newId);
|
||||
$idMap[$newId] = true;
|
||||
return [$existingId, $newId];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,6 +296,24 @@ class PageContent
|
||||
$scriptElem->parentNode->removeChild($scriptElem);
|
||||
}
|
||||
|
||||
// Remove clickable links to JavaScript URI
|
||||
$badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
|
||||
foreach ($badLinks as $badLink) {
|
||||
$badLink->parentNode->removeChild($badLink);
|
||||
}
|
||||
|
||||
// Remove forms with calls to JavaScript URI
|
||||
$badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
|
||||
foreach ($badForms as $badForm) {
|
||||
$badForm->parentNode->removeChild($badForm);
|
||||
}
|
||||
|
||||
// Remove meta tag to prevent external redirects
|
||||
$metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
|
||||
foreach ($metaTags as $metaTag) {
|
||||
$metaTag->parentNode->removeChild($metaTag);
|
||||
}
|
||||
|
||||
// Remove data or JavaScript iFrames
|
||||
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
|
||||
foreach ($badIframes as $badIframe) {
|
||||
|
||||
@@ -110,7 +110,7 @@ class AttachmentController extends Controller
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_edit_name' => 'required|string|min:1|max:255',
|
||||
'attachment_edit_url' => 'string|min:1|max:255'
|
||||
'attachment_edit_url' => 'string|min:1|max:255|safe_url'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
|
||||
@@ -145,7 +145,7 @@ class AttachmentController extends Controller
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
|
||||
'attachment_link_name' => 'required|string|min:1|max:255',
|
||||
'attachment_link_url' => 'required|string|min:1|max:255'
|
||||
'attachment_link_url' => 'required|string|min:1|max:255|safe_url'
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
|
||||
@@ -161,7 +161,7 @@ class AttachmentController extends Controller
|
||||
|
||||
$attachmentName = $request->get('attachment_link_name');
|
||||
$link = $request->get('attachment_link_url');
|
||||
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
|
||||
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
|
||||
|
||||
return view('attachments.manager-link-form', [
|
||||
'pageId' => $pageId,
|
||||
|
||||
@@ -43,6 +43,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
return substr_count($uploadName, '.') < 2;
|
||||
});
|
||||
|
||||
Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
|
||||
$cleanLinkName = strtolower(trim($value));
|
||||
$isJs = strpos($cleanLinkName, 'javascript:') === 0;
|
||||
$isData = strpos($cleanLinkName, 'data:') === 0;
|
||||
return !$isJs && !$isData;
|
||||
});
|
||||
|
||||
// Custom blade view directives
|
||||
Blade::directive('icon', function ($expression) {
|
||||
return "<?php echo icon($expression); ?>";
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -88,12 +100,8 @@ class AttachmentService extends UploadService
|
||||
|
||||
/**
|
||||
* Save a new File attachment from a given link and name.
|
||||
* @param string $name
|
||||
* @param string $link
|
||||
* @param int $page_id
|
||||
* @return Attachment
|
||||
*/
|
||||
public function saveNewFromLink($name, $link, $page_id)
|
||||
public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
|
||||
{
|
||||
$largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
|
||||
return Attachment::forceCreate([
|
||||
@@ -123,13 +131,11 @@ class AttachmentService extends UploadService
|
||||
|
||||
/**
|
||||
* Update the details of a file.
|
||||
* @param Attachment $attachment
|
||||
* @param $requestData
|
||||
* @return Attachment
|
||||
*/
|
||||
public function updateFile(Attachment $attachment, $requestData)
|
||||
public function updateFile(Attachment $attachment, array $requestData): Attachment
|
||||
{
|
||||
$attachment->name = $requestData['name'];
|
||||
|
||||
if (isset($requestData['link']) && trim($requestData['link']) !== '') {
|
||||
$attachment->path = $requestData['link'];
|
||||
if (!$attachment->external) {
|
||||
@@ -137,6 +143,7 @@ class AttachmentService extends UploadService
|
||||
$attachment->external = true;
|
||||
}
|
||||
}
|
||||
|
||||
$attachment->save();
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class ImageRepo
|
||||
if ($filterType === 'page') {
|
||||
$query->where('uploaded_to', '=', $contextPage->id);
|
||||
} elseif ($filterType === 'book') {
|
||||
$validPageIds = $contextPage->book->pages()->get(['id'])->pluck('id')->toArray();
|
||||
$validPageIds = $contextPage->book->pages()->visible()->get(['id'])->pluck('id')->toArray();
|
||||
$query->whereIn('uploaded_to', $validPageIds);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -5,4 +5,4 @@ set -e
|
||||
npm install
|
||||
npm rebuild node-sass
|
||||
|
||||
exec npm run watch
|
||||
SHELL=/bin/sh exec npm run watch
|
||||
|
||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -253,9 +253,9 @@
|
||||
}
|
||||
},
|
||||
"esbuild": {
|
||||
"version": "0.6.30",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.6.30.tgz",
|
||||
"integrity": "sha512-ZSZY461UPzTYYC3rqy1QiMtngk2WyXf+58MgC7tC22jkI90FXNgEl0hN3ipfn/UgZYzTW2GBcHiO7t0rSbHT7g==",
|
||||
"version": "0.7.8",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.7.8.tgz",
|
||||
"integrity": "sha512-6UT1nZB+8ja5avctUC6d3kGOUAhy6/ZYHljL4nk3++1ipadghBhUCAcwsTHsmUvdu04CcGKzo13mE+ZQ2O3zrA==",
|
||||
"dev": true
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
@@ -496,9 +496,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"markdown-it": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.0.tgz",
|
||||
"integrity": "sha512-+CvOnmbSubmQFSA9dKz1BRiaSMV7rhexl3sngKqFyXSagoA3fBdJQ8oZWtRy2knXdpDXaBw44euz37DeJQ9asg==",
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz",
|
||||
"integrity": "sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ==",
|
||||
"requires": {
|
||||
"argparse": "^1.0.7",
|
||||
"entities": "~2.0.0",
|
||||
@@ -730,9 +730,9 @@
|
||||
}
|
||||
},
|
||||
"sass": {
|
||||
"version": "1.26.10",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.26.10.tgz",
|
||||
"integrity": "sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw==",
|
||||
"version": "1.26.11",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.26.11.tgz",
|
||||
"integrity": "sha512-W1l/+vjGjIamsJ6OnTe0K37U2DBO/dgsv2Z4c89XQ8ZOO6l/VwkqwLSqoYzJeJs6CLuGSTRWc91GbQFL3lvrvw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chokidar": ">=2.0.0 <4.0.0"
|
||||
@@ -777,9 +777,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"sortablejs": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
|
||||
"integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.12.0.tgz",
|
||||
"integrity": "sha512-bPn57rCjBRlt2sC24RBsu40wZsmLkSo2XeqG8k6DC1zru5eObQUIPPZAQG7W2SJ8FZQYq+BEJmvuw1Zxb3chqg=="
|
||||
},
|
||||
"spdx-correct": {
|
||||
"version": "3.1.1",
|
||||
|
||||
14
package.json
14
package.json
@@ -4,9 +4,9 @@
|
||||
"build:css:dev": "sass ./resources/sass:./public/dist",
|
||||
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
|
||||
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2020",
|
||||
"build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --minify",
|
||||
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
|
||||
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
|
||||
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
|
||||
"build": "npm-run-all --parallel build:*:dev",
|
||||
"production": "npm-run-all --parallel build:*:production",
|
||||
"dev": "npm-run-all --parallel watch livereload",
|
||||
@@ -16,18 +16,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar-cli": "^2.1.0",
|
||||
"esbuild": "0.6.30",
|
||||
"esbuild": "0.7.8",
|
||||
"livereload": "^0.9.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"punycode": "^2.1.1",
|
||||
"sass": "^1.26.10"
|
||||
"sass": "^1.26.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard": "^2.0.6",
|
||||
"codemirror": "^5.58.1",
|
||||
"dropzone": "^5.7.2",
|
||||
"markdown-it": "^11.0.0",
|
||||
"markdown-it": "^11.0.1",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"sortablejs": "^1.10.2"
|
||||
"sortablejs": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^(.*)/$ /$1 [L,R=301]
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Handle Front Controller...
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
88
public/dist/app.js
vendored
88
public/dist/app.js
vendored
File diff suppressed because one or more lines are too long
@@ -168,6 +168,6 @@ These are the great open-source projects used to help build BookStack:
|
||||
* [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
|
||||
* [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
|
||||
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
|
||||
* [Draw.io](https://github.com/jgraph/drawio)
|
||||
* [diagrams.net](https://github.com/jgraph/drawio)
|
||||
* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
|
||||
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Sortable, MultiDrag} from "sortablejs";
|
||||
import Sortable from "sortablejs";
|
||||
|
||||
// Auto sort control
|
||||
const sortOperations = {
|
||||
@@ -43,7 +43,6 @@ class BookSort {
|
||||
this.input = elem.querySelector('[book-sort-input]');
|
||||
|
||||
const initialSortBox = elem.querySelector('.sort-box');
|
||||
Sortable.mount(new MultiDrag());
|
||||
this.setupBookSortable(initialSortBox);
|
||||
this.setupSortPresets();
|
||||
|
||||
|
||||
@@ -440,10 +440,10 @@ class MarkdownEditor {
|
||||
|
||||
const data = {
|
||||
image: pngData,
|
||||
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
|
||||
uploaded_to: Number(this.pageId),
|
||||
};
|
||||
|
||||
window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
|
||||
window.$http.post("/images/drawio", data).then(resp => {
|
||||
this.insertDrawing(resp.data, cursorPos);
|
||||
DrawIO.close();
|
||||
}).catch(err => {
|
||||
@@ -476,10 +476,10 @@ class MarkdownEditor {
|
||||
|
||||
let data = {
|
||||
image: pngData,
|
||||
uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
|
||||
uploaded_to: Number(this.pageId),
|
||||
};
|
||||
|
||||
window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
|
||||
window.$http.post("/images/drawio", data).then(resp => {
|
||||
let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
|
||||
let newContent = this.cm.getValue().split('\n').map(line => {
|
||||
if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'codemirror/mode/rust/rust';
|
||||
import 'codemirror/mode/shell/shell';
|
||||
import 'codemirror/mode/sql/sql';
|
||||
import 'codemirror/mode/toml/toml';
|
||||
import 'codemirror/mode/vbscript/vbscript';
|
||||
import 'codemirror/mode/xml/xml';
|
||||
import 'codemirror/mode/yaml/yaml';
|
||||
|
||||
@@ -84,6 +85,8 @@ const modeMap = {
|
||||
bash: 'shell',
|
||||
toml: 'toml',
|
||||
sql: 'text/x-sql',
|
||||
vbs: 'vbscript',
|
||||
vbscript: 'vbscript',
|
||||
xml: 'xml',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
|
||||
@@ -141,10 +141,14 @@ async function request(url, options = {}) {
|
||||
/**
|
||||
* Get the content from a fetch response.
|
||||
* Checks the content-type header to determine the format.
|
||||
* @param response
|
||||
* @param {Response} response
|
||||
* @returns {Promise<Object|String>}
|
||||
*/
|
||||
async function getResponseContent(response) {
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const responseContentType = response.headers.get('Content-Type');
|
||||
const subType = responseContentType.split('/').pop();
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ return [
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute and :other must match.',
|
||||
'safe_url' => 'The provided link may not be safe.',
|
||||
'size' => [
|
||||
'numeric' => 'The :attribute must be :size.',
|
||||
'file' => 'The :attribute must be :size kilobytes.',
|
||||
|
||||
@@ -179,7 +179,7 @@ return [
|
||||
'user_api_token_name_desc' => 'Dale a tu token un nombre legible como un recordatorio futuro de su propósito.',
|
||||
'user_api_token_expiry' => 'Fecha de expiración',
|
||||
'user_api_token_expiry_desc' => 'Establece una fecha en la que este token expira. Después de esta fecha, las solicitudes realizadas usando este token ya no funcionarán. Dejar este campo en blanco fijará un vencimiento de 100 años en el futuro.',
|
||||
'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
|
||||
'user_api_token_create_secret_message' => 'Inmediatamente después de crear este token se generarán y mostrarán sus correspondientes "Token ID" y "Token Secret". El "Token Secret" sólo se mostrará una vez, así que asegúrese de copiar el valor a un lugar seguro antes de proceder.',
|
||||
'user_api_token_create_success' => 'Token API creado correctamente',
|
||||
'user_api_token_update_success' => 'Token API actualizado correctamente',
|
||||
'user_api_token' => 'Token API',
|
||||
@@ -187,8 +187,8 @@ return [
|
||||
'user_api_token_id_desc' => 'Este es un identificador no editable generado por el sistema y único para este token que necesitará ser proporcionado en solicitudes de API.',
|
||||
'user_api_token_secret' => 'Token Secret',
|
||||
'user_api_token_secret_desc' => 'Esta es una clave no editable generada por el sistema que necesitará ser proporcionada en solicitudes de API. Solo se monstraré esta vez así que guarde su valor en un lugar seguro.',
|
||||
'user_api_token_created' => 'Token created :timeAgo',
|
||||
'user_api_token_updated' => 'Token updated :timeAgo',
|
||||
'user_api_token_created' => 'Token creado :timeAgo',
|
||||
'user_api_token_updated' => 'Token actualizado :timeAgo',
|
||||
'user_api_token_delete' => 'Borrar token',
|
||||
'user_api_token_delete_warning' => 'Esto eliminará completamente este token API con el nombre \':tokenName\' del sistema.',
|
||||
'user_api_token_delete_confirm' => '¿Está seguro de que desea borrar este API token?',
|
||||
|
||||
@@ -57,7 +57,7 @@ return [
|
||||
'reg_enable_desc' => '启用注册后,用户将可以自己注册为站点用户。 注册后,他们将获得一个默认的单一用户角色。',
|
||||
'reg_default_role' => '注册后的默认用户角色',
|
||||
'reg_enable_external_warning' => '当启用外部LDAP或者SAML认证时,上面的选项会被忽略。当使用外部系统认证认证成功时,将自动创建非现有会员的用户账户。',
|
||||
'reg_email_confirmation' => '邮箱确认n',
|
||||
'reg_email_confirmation' => '邮件确认',
|
||||
'reg_email_confirmation_toggle' => '需要电子邮件确认',
|
||||
'reg_confirm_email_desc' => '如果使用域名限制,则需要Email验证,并且该值将被忽略。',
|
||||
'reg_confirm_restrict_domain' => '域名限制',
|
||||
@@ -92,8 +92,8 @@ return [
|
||||
'audit_table_event' => '事件',
|
||||
'audit_table_item' => '相关项目',
|
||||
'audit_table_date' => '活动日期',
|
||||
'audit_date_from' => 'Date Range From',
|
||||
'audit_date_to' => 'Date Range To',
|
||||
'audit_date_from' => '日期范围从',
|
||||
'audit_date_to' => '日期范围至',
|
||||
|
||||
// Role Settings
|
||||
'roles' => '角色',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<div component="ajax-form"
|
||||
option:ajax-form:url="/attachments/{{ $attachment->id }}"
|
||||
option:ajax-form:method="put"
|
||||
option:ajax-form:response-container=".attachment-edit-container"
|
||||
option:ajax-form:success-message="{{ trans('entities.attachments_updated_success') }}">
|
||||
<h5>{{ trans('entities.attachments_edit_file') }}</h5>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div component="ajax-form"
|
||||
option:ajax-form:url="/attachments/link"
|
||||
option:ajax-form:method="post"
|
||||
option:ajax-form:response-container=".link-form-container"
|
||||
option:ajax-form:success-message="{{ trans('entities.attachments_link_attached') }}">
|
||||
<input type="hidden" name="attachment_link_uploaded_to" value="{{ $pageId }}">
|
||||
<p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
'successMessage' => trans('entities.attachments_file_uploaded'),
|
||||
])
|
||||
</div>
|
||||
<div refs="tabs@contentLinks" class="hidden">
|
||||
<div refs="tabs@contentLinks" class="hidden link-form-container">
|
||||
@include('attachments.manager-link-form', ['pageId' => $page->id])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div refs="attachments@editContainer" class="hidden">
|
||||
<div refs="attachments@editContainer" class="hidden attachment-edit-container">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
<ul class="contents">
|
||||
@foreach($bookChildren as $bookChild)
|
||||
<li><a href="#{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</a></li>
|
||||
@if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
|
||||
@if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
|
||||
<ul>
|
||||
@foreach($bookChild->pages as $page)
|
||||
@foreach($bookChild->visible_pages as $page)
|
||||
<li><a href="#page-{{$page->id}}">{{ $page->name }}</a></li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -59,8 +59,8 @@
|
||||
@if($bookChild->isA('chapter'))
|
||||
<p>{{ $bookChild->description }}</p>
|
||||
|
||||
@if(count($bookChild->pages) > 0)
|
||||
@foreach($bookChild->pages as $page)
|
||||
@if(count($bookChild->visible_pages) > 0)
|
||||
@foreach($bookChild->visible_pages as $page)
|
||||
<div class="page-break"></div>
|
||||
<div class="chapter-hint">{{$bookChild->name}}</div>
|
||||
<h1 id="page-{{$page->id}}">{{ $page->name }}</h1>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
@if($bookChild->isA('chapter'))
|
||||
<ul>
|
||||
@foreach($bookChild->pages as $page)
|
||||
@foreach($bookChild->visible_pages as $page)
|
||||
<li class="text-page"
|
||||
data-id="{{$page->id}}" data-type="page"
|
||||
data-name="{{ $page->name }}" data-created="{{ $page->created_at->timestamp }}"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div class="chapter-child-menu">
|
||||
<button chapter-toggle type="button" aria-expanded="{{ $isOpen ? 'true' : 'false' }}"
|
||||
class="text-muted @if($isOpen) open @endif">
|
||||
@icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->pages->count()) }}</span>
|
||||
@icon('caret-right') @icon('page') <span>{{ trans_choice('entities.x_pages', $bookChild->visible_pages->count()) }}</span>
|
||||
</button>
|
||||
<ul class="sub-menu inset-list @if($isOpen) open @endif" @if($isOpen) style="display: block;" @endif role="menu">
|
||||
@foreach($bookChild->pages as $childPage)
|
||||
@foreach($bookChild->visible_pages as $childPage)
|
||||
<li class="list-item-page {{ $childPage->isA('page') && $childPage->draft ? 'draft' : '' }}" role="presentation">
|
||||
@include('partials.entity-list-item-basic', ['entity' => $childPage, 'classes' => $current->matches($childPage)? 'selected' : '' ])
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<a href="{{ $chapter->getUrl() }}" class="chapter entity-list-item @if($chapter->hasChildren()) has-children @endif" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
|
||||
{{--This view display child pages in a list if pre-loaded onto a 'visible_pages' property,--}}
|
||||
{{--To ensure that the pages have been loaded efficiently with permissions taken into account.--}}
|
||||
<a href="{{ $chapter->getUrl() }}" class="chapter entity-list-item @if($chapter->visible_pages->count() > 0) has-children @endif" data-entity-type="chapter" data-entity-id="{{$chapter->id}}">
|
||||
<span class="icon text-chapter">@icon('chapter')</span>
|
||||
<div class="content">
|
||||
<h4 class="entity-list-item-name break-text">{{ $chapter->name }}</h4>
|
||||
@@ -7,16 +9,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@if ($chapter->hasChildren())
|
||||
@if ($chapter->visible_pages->count() > 0)
|
||||
<div class="chapter chapter-expansion">
|
||||
<span class="icon text-chapter">@icon('page')</span>
|
||||
<div class="content">
|
||||
<button type="button" chapter-toggle
|
||||
aria-expanded="false"
|
||||
class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->pages->count()) }}</span></button>
|
||||
class="text-muted chapter-expansion-toggle">@icon('caret-right') <span>{{ trans_choice('entities.x_pages', $chapter->visible_pages->count()) }}</span></button>
|
||||
<div class="inset-list">
|
||||
<div class="entity-list-item-children">
|
||||
@include('partials.entity-list', ['entities' => $chapter->pages])
|
||||
@include('partials.entity-list', ['entities' => $chapter->visible_pages])
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<a refs="code-editor@languageLink" data-lang="Ruby">Ruby</a>
|
||||
<a refs="code-editor@languageLink" data-lang="shell">Shell/Bash</a>
|
||||
<a refs="code-editor@languageLink" data-lang="SQL">SQL</a>
|
||||
<a refs="code-editor@languageLink" data-lang="VBScript">VBScript</a>
|
||||
<a refs="code-editor@languageLink" data-lang="XML">XML</a>
|
||||
<a refs="code-editor@languageLink" data-lang="YAML">YAML</a>
|
||||
</small>
|
||||
@@ -66,4 +67,4 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div component="page-editor" class="page-editor flex-fill flex"
|
||||
option:page-editor:drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
|
||||
@if(config('services.drawio'))
|
||||
drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://www.draw.io/?embed=1&proto=json&spin=1' }}"
|
||||
drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://embed.diagrams.net/?embed=1&proto=json&spin=1' }}"
|
||||
@endif
|
||||
@if($model->name === trans('entities.pages_initial_name'))
|
||||
option:page-editor:has-default-title="true"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
|
||||
@include('partials.entity-list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
|
||||
|
||||
@if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
|
||||
@if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
|
||||
<div class="entity-list-item no-hover">
|
||||
<span role="presentation" class="icon text-chapter"></span>
|
||||
<div class="content">
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<div class="page-list">
|
||||
@if(count($pages) > 0)
|
||||
@foreach($pages as $pageIndex => $page)
|
||||
<div class="anim searchResult" style="animation-delay: {{$pageIndex*50 . 'ms'}};">
|
||||
@include('pages.list-item', ['page' => $page])
|
||||
<hr>
|
||||
</div>
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-muted">{{ trans('entities.search_no_pages') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if(count($chapters) > 0)
|
||||
<div class="page-list">
|
||||
@foreach($chapters as $chapterIndex => $chapter)
|
||||
<div class="anim searchResult" style="animation-delay: {{($chapterIndex+count($pages))*50 . 'ms'}};">
|
||||
@include('chapters.list-item', ['chapter' => $chapter, 'hidePages' => true])
|
||||
<hr>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -196,24 +196,6 @@ class Saml2Test extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_user_registration_with_existing_email()
|
||||
{
|
||||
config()->set([
|
||||
'saml2.onelogin.strict' => false,
|
||||
]);
|
||||
|
||||
$viewer = $this->getViewer();
|
||||
$viewer->email = 'user@example.com';
|
||||
$viewer->save();
|
||||
|
||||
$this->withPost(['SAMLResponse' => $this->acsPostData], function () {
|
||||
$acsPost = $this->post('/saml2/acs');
|
||||
$acsPost->assertRedirect('/');
|
||||
$errorMessage = session()->get('error');
|
||||
$this->assertEquals('A user with the email user@example.com already exists but with different credentials.', $errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_saml_routes_are_only_active_if_saml_enabled()
|
||||
{
|
||||
config()->set(['auth.method' => 'standard']);
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<?php namespace Tests\Entity;
|
||||
|
||||
|
||||
use BookStack\Entities\Chapter;
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Uploads\HttpFetcher;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -154,14 +153,39 @@ class ExportTest extends TestCase
|
||||
public function test_page_export_sets_right_data_type_for_svg_embeds()
|
||||
{
|
||||
$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();
|
||||
|
||||
$this->asEditor();
|
||||
$this->mockHttpFetch('<svg></svg>');
|
||||
$resp = $this->get($page->getUrl('/export/html'));
|
||||
Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
|
||||
|
||||
$resp->assertStatus(200);
|
||||
$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"');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -159,6 +159,72 @@ class PageContentTest extends TestCase
|
||||
|
||||
}
|
||||
|
||||
public function test_javascript_uri_links_are_removed()
|
||||
{
|
||||
$checks = [
|
||||
'<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
|
||||
'<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertElementNotContains('.page-content', '<a id="xss">');
|
||||
$pageView->assertElementNotContains('.page-content', 'href=javascript:');
|
||||
}
|
||||
}
|
||||
public function test_form_actions_with_javascript_are_removed()
|
||||
{
|
||||
$checks = [
|
||||
'<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
|
||||
'<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
|
||||
'<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertElementNotContains('.page-content', '<button id="xss"');
|
||||
$pageView->assertElementNotContains('.page-content', '<input id="xss"');
|
||||
$pageView->assertElementNotContains('.page-content', '<form id="xss"');
|
||||
$pageView->assertElementNotContains('.page-content', 'action=javascript:');
|
||||
$pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
|
||||
}
|
||||
}
|
||||
|
||||
public function test_metadata_redirects_are_removed()
|
||||
{
|
||||
$checks = [
|
||||
'<meta http-equiv="refresh" content="0; url=//external_url">',
|
||||
];
|
||||
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
|
||||
foreach ($checks as $check) {
|
||||
$page->html = $check;
|
||||
$page->save();
|
||||
|
||||
$pageView = $this->get($page->getUrl());
|
||||
$pageView->assertStatus(200);
|
||||
$pageView->assertElementNotContains('.page-content', '<meta>');
|
||||
$pageView->assertElementNotContains('.page-content', '</meta>');
|
||||
$pageView->assertElementNotContains('.page-content', 'content=');
|
||||
$pageView->assertElementNotContains('.page-content', 'external_url');
|
||||
}
|
||||
}
|
||||
public function test_page_inline_on_attributes_removed_by_default()
|
||||
{
|
||||
$this->asEditor();
|
||||
@@ -262,6 +328,23 @@ class PageContentTest extends TestCase
|
||||
$this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
|
||||
}
|
||||
|
||||
public function test_anchors_referencing_non_bkmrk_ids_rewritten_after_save()
|
||||
{
|
||||
$this->asEditor();
|
||||
$page = Page::first();
|
||||
|
||||
$content = '<h1 id="non-standard-id">test</h1><p><a href="#non-standard-id">link</a></p>';
|
||||
$this->put($page->getUrl(), [
|
||||
'name' => $page->name,
|
||||
'html' => $content,
|
||||
'summary' => ''
|
||||
]);
|
||||
|
||||
$updatedPage = Page::where('id', '=', $page->id)->first();
|
||||
$this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
|
||||
$this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
|
||||
}
|
||||
|
||||
public function test_get_page_nav_sets_correct_properties()
|
||||
{
|
||||
$content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
|
||||
|
||||
67
tests/Permissions/ExportPermissionsTest.php
Normal file
67
tests/Permissions/ExportPermissionsTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php namespace Tests\Permissions;
|
||||
|
||||
use BookStack\Entities\Book;
|
||||
use BookStack\Entities\Chapter;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExportPermissionsTest extends TestCase
|
||||
{
|
||||
|
||||
public function test_page_content_without_view_access_hidden_on_chapter_export()
|
||||
{
|
||||
$chapter = Chapter::query()->first();
|
||||
$page = $chapter->pages()->firstOrFail();
|
||||
$pageContent = Str::random(48);
|
||||
$page->html = '<p>' . $pageContent . '</p>';
|
||||
$page->save();
|
||||
$viewer = $this->getViewer();
|
||||
$this->actingAs($viewer);
|
||||
$formats = ['html', 'plaintext'];
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$resp = $this->get($chapter->getUrl("export/{$format}"));
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertSee($page->name);
|
||||
$resp->assertSee($pageContent);
|
||||
}
|
||||
|
||||
$this->setEntityRestrictions($page, []);
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$resp = $this->get($chapter->getUrl("export/{$format}"));
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertDontSee($page->name);
|
||||
$resp->assertDontSee($pageContent);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_page_content_without_view_access_hidden_on_book_export()
|
||||
{
|
||||
$book = Book::query()->first();
|
||||
$page = $book->pages()->firstOrFail();
|
||||
$pageContent = Str::random(48);
|
||||
$page->html = '<p>' . $pageContent . '</p>';
|
||||
$page->save();
|
||||
$viewer = $this->getViewer();
|
||||
$this->actingAs($viewer);
|
||||
$formats = ['html', 'plaintext'];
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$resp = $this->get($book->getUrl("export/{$format}"));
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertSee($page->name);
|
||||
$resp->assertSee($pageContent);
|
||||
}
|
||||
|
||||
$this->setEntityRestrictions($page, []);
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$resp = $this->get($book->getUrl("export/{$format}"));
|
||||
$resp->assertStatus(200);
|
||||
$resp->assertDontSee($page->name);
|
||||
$resp->assertDontSee($pageContent);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -490,6 +490,22 @@ class RestrictionsTest extends BrowserKitTest
|
||||
->dontSee($page->name);
|
||||
}
|
||||
|
||||
public function test_restricted_chapter_pages_not_visible_on_book_page()
|
||||
{
|
||||
$chapter = Chapter::query()->first();
|
||||
$this->actingAs($this->user)
|
||||
->visit($chapter->book->getUrl())
|
||||
->see($chapter->pages->first()->name);
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
$this->setEntityRestrictions($page, []);
|
||||
}
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->visit($chapter->book->getUrl())
|
||||
->dontSee($chapter->pages->first()->name);
|
||||
}
|
||||
|
||||
public function test_bookshelf_update_restriction_override()
|
||||
{
|
||||
$shelf = Bookshelf::first();
|
||||
|
||||
@@ -3,39 +3,51 @@
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Entities\Page;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Tests\TestCase;
|
||||
use Tests\TestResponse;
|
||||
|
||||
class AttachmentTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Get a test file that can be uploaded
|
||||
* @param $fileName
|
||||
* @return \Illuminate\Http\UploadedFile
|
||||
*/
|
||||
protected function getTestFile($fileName)
|
||||
protected function getTestFile(string $fileName): UploadedFile
|
||||
{
|
||||
return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
|
||||
return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file with the given name.
|
||||
* @param $name
|
||||
* @param int $uploadedTo
|
||||
* @return \Illuminate\Foundation\Testing\TestResponse
|
||||
*/
|
||||
protected function uploadFile($name, $uploadedTo = 0)
|
||||
protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Foundation\Testing\TestResponse
|
||||
{
|
||||
$file = $this->getTestFile($name);
|
||||
return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new attachment
|
||||
*/
|
||||
protected function createAttachment(Page $page): Attachment
|
||||
{
|
||||
$this->post('attachments/link', [
|
||||
'attachment_link_url' => 'https://example.com',
|
||||
'attachment_link_name' => 'Example Attachment Link',
|
||||
'attachment_link_uploaded_to' => $page->id,
|
||||
]);
|
||||
|
||||
return Attachment::query()->latest()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all uploaded files.
|
||||
* To assist with cleanup.
|
||||
*/
|
||||
protected function deleteUploads()
|
||||
{
|
||||
$fileService = $this->app->make(\BookStack\Uploads\AttachmentService::class);
|
||||
$fileService = $this->app->make(AttachmentService::class);
|
||||
foreach (Attachment::all() as $file) {
|
||||
$fileService->deleteFile($file);
|
||||
}
|
||||
@@ -145,21 +157,14 @@ class AttachmentTest extends TestCase
|
||||
$page = Page::first();
|
||||
$this->asAdmin();
|
||||
|
||||
$this->call('POST', 'attachments/link', [
|
||||
'attachment_link_url' => 'https://example.com',
|
||||
'attachment_link_name' => 'Example Attachment Link',
|
||||
'attachment_link_uploaded_to' => $page->id,
|
||||
]);
|
||||
|
||||
$attachmentId = Attachment::first()->id;
|
||||
|
||||
$update = $this->call('PUT', 'attachments/' . $attachmentId, [
|
||||
$attachment = $this->createAttachment($page);
|
||||
$update = $this->call('PUT', 'attachments/' . $attachment->id, [
|
||||
'attachment_edit_name' => 'My new attachment name',
|
||||
'attachment_edit_url' => 'https://test.example.com'
|
||||
]);
|
||||
|
||||
$expectedData = [
|
||||
'id' => $attachmentId,
|
||||
'id' => $attachment->id,
|
||||
'path' => 'https://test.example.com',
|
||||
'name' => 'My new attachment name',
|
||||
'uploaded_to' => $page->id
|
||||
@@ -242,4 +247,45 @@ class AttachmentTest extends TestCase
|
||||
|
||||
$this->deleteUploads();
|
||||
}
|
||||
|
||||
public function test_data_and_js_links_cannot_be_attached_to_a_page()
|
||||
{
|
||||
$page = Page::first();
|
||||
$this->asAdmin();
|
||||
|
||||
$badLinks = [
|
||||
'javascript:alert("bunny")',
|
||||
' javascript:alert("bunny")',
|
||||
'JavaScript:alert("bunny")',
|
||||
"\t\n\t\nJavaScript:alert(\"bunny\")",
|
||||
"data:text/html;<a></a>",
|
||||
"Data:text/html;<a></a>",
|
||||
"Data:text/html;<a></a>",
|
||||
];
|
||||
|
||||
foreach ($badLinks as $badLink) {
|
||||
$linkReq = $this->post('attachments/link', [
|
||||
'attachment_link_url' => $badLink,
|
||||
'attachment_link_name' => 'Example Attachment Link',
|
||||
'attachment_link_uploaded_to' => $page->id,
|
||||
]);
|
||||
$linkReq->assertStatus(422);
|
||||
$this->assertDatabaseMissing('attachments', [
|
||||
'path' => $badLink,
|
||||
]);
|
||||
}
|
||||
|
||||
$attachment = $this->createAttachment($page);
|
||||
|
||||
foreach ($badLinks as $badLink) {
|
||||
$linkReq = $this->put('attachments/' . $attachment->id, [
|
||||
'attachment_edit_url' => $badLink,
|
||||
'attachment_edit_name' => 'Example Attachment Link',
|
||||
]);
|
||||
$linkReq->assertStatus(422);
|
||||
$this->assertDatabaseMissing('attachments', [
|
||||
'path' => $badLink,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class DrawioTest extends TestCase
|
||||
$editor = $this->getEditor();
|
||||
|
||||
$resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
|
||||
$resp->assertSee('drawio-url="https://www.draw.io/?embed=1&proto=json&spin=1"');
|
||||
$resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&proto=json&spin=1"');
|
||||
|
||||
config()->set('services.drawio', false);
|
||||
$resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
|
||||
|
||||
Reference in New Issue
Block a user