diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..2c6317af6 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, +religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Project Maintainer Standards + +Project maintainers should generally follow these additional standards: + +* Avoid using a negative or harsh tone in communication, Even if the other party +is being negative themselves. +* When providing criticism, try to make it constructive to lead the other person +down the correct path. +* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition) +in mind when deciding what's in scope of the Project. + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. In addition, Project +maintainers are responsible for following the standards themselves. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org diff --git a/.gitignore b/.gitignore index 042e77f69..4b8d88ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ nbproject .buildpath .project .settings/ +webpack-stats.json \ No newline at end of file diff --git a/LICENSE b/LICENSE index 281814bb8..080c54b3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) -Copyright (c) 2016 Dan Brown +Copyright (c) 2018 Dan Brown and the BookStack Project contributors +https://github.com/BookStackApp/BookStack/graphs/contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/app/Chapter.php b/app/Chapter.php index 3726c57f4..88b4c134c 100644 --- a/app/Chapter.php +++ b/app/Chapter.php @@ -6,8 +6,6 @@ class Chapter extends Entity protected $fillable = ['name', 'description', 'priority', 'book_id']; - protected $with = ['book']; - /** * Get the book this chapter is within. * @return \Illuminate\Database\Eloquent\Relations\BelongsTo diff --git a/app/Entity.php b/app/Entity.php index aeeab4960..5d4449f2b 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -197,9 +197,8 @@ class Entity extends Ownable * @param $path * @return string */ - public function getUrl($path) + public function getUrl($path = '/') { - return '/'; + return $path; } - } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index a4e0b6409..b737afc6d 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -107,17 +107,14 @@ class ChapterController extends Controller * @param $bookSlug * @param $chapterSlug * @return Response + * @throws \BookStack\Exceptions\NotFoundException */ public function update(Request $request, $bookSlug, $chapterSlug) { $chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug); $this->checkOwnablePermission('chapter-update', $chapter); - if ($chapter->name !== $request->get('name')) { - $chapter->slug = $this->entityRepo->findSuitableSlug('chapter', $request->get('name'), $chapter->id, $chapter->book->id); - } - $chapter->fill($request->all()); - $chapter->updated_by = user()->id; - $chapter->save(); + + $this->entityRepo->updateFromInput('chapter', $chapter, $request->all()); Activity::add($chapter, 'chapter_update', $chapter->book->id); return redirect($chapter->getUrl()); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 3a5fd2cb5..bbe1a8679 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -118,4 +118,20 @@ class HomeController extends Controller { return view('partials/custom-head-content'); } + + /** + * Show the view for /robots.txt + * @return $this + */ + public function getRobots() + { + $sitePublic = setting('app-public', false); + $allowRobots = config('app.allow_robots'); + if ($allowRobots === null) { + $allowRobots = $sitePublic; + } + return response() + ->view('robots', ['allowRobots' => $allowRobots]) + ->header('Content-Type', 'text/plain'); + } } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 17bce7eba..221e21a99 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -275,11 +275,10 @@ class PageController extends Controller $draft = $this->entityRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown'])); $updateTime = $draft->updated_at->timestamp; - $utcUpdateTimestamp = $updateTime + Carbon::createFromTimestamp(0)->offset; return response()->json([ 'status' => 'success', 'message' => trans('entities.pages_edit_draft_save_at'), - 'timestamp' => $utcUpdateTimestamp + 'timestamp' => $updateTime ]); } @@ -586,6 +585,8 @@ class PageController extends Controller return redirect()->back(); } + $this->checkOwnablePermission('page-create', $parent); + $this->entityRepo->changePageParent($page, $parent); Activity::add($page, 'page_move', $page->book->id); session()->flash('success', trans('entities.pages_move_success', ['parentName' => $parent->name])); @@ -593,12 +594,70 @@ class PageController extends Controller return redirect($page->getUrl()); } + /** + * Show the view to copy a page. + * @param string $bookSlug + * @param string $pageSlug + * @return mixed + * @throws NotFoundException + */ + public function showCopy($bookSlug, $pageSlug) + { + $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug); + $this->checkOwnablePermission('page-update', $page); + session()->flashInput(['name' => $page->name]); + return view('pages/copy', [ + 'book' => $page->book, + 'page' => $page + ]); + } + + /** + * Create a copy of a page within the requested target destination. + * @param string $bookSlug + * @param string $pageSlug + * @param Request $request + * @return mixed + * @throws NotFoundException + */ + public function copy($bookSlug, $pageSlug, Request $request) + { + $page = $this->entityRepo->getBySlug('page', $pageSlug, $bookSlug); + $this->checkOwnablePermission('page-update', $page); + + $entitySelection = $request->get('entity_selection', null); + if ($entitySelection === null || $entitySelection === '') { + $parent = $page->chapter ? $page->chapter : $page->book; + } else { + $stringExploded = explode(':', $entitySelection); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + try { + $parent = $this->entityRepo->getById($entityType, $entityId); + } catch (\Exception $e) { + session()->flash(trans('entities.selected_book_chapter_not_found')); + return redirect()->back(); + } + } + + $this->checkOwnablePermission('page-create', $parent); + + $pageCopy = $this->entityRepo->copyPage($page, $parent, $request->get('name', '')); + + Activity::add($pageCopy, 'page_create', $pageCopy->book->id); + session()->flash('success', trans('entities.pages_copy_success')); + + return redirect($pageCopy->getUrl()); + } + /** * Set the permissions for this page. * @param string $bookSlug * @param string $pageSlug * @param Request $request * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector + * @throws NotFoundException */ public function restrict($bookSlug, $pageSlug, Request $request) { diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index ddc92f705..49f9885ad 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -89,16 +89,17 @@ class SearchController extends Controller { $entityTypes = $request->filled('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); $searchTerm = $request->get('term', false); + $permission = $request->get('permission', 'view'); // Search for entities otherwise show most popular if ($searchTerm !== false) { $searchTerm .= ' {type:'. implode('|', $entityTypes->toArray()) .'}'; - $entities = $this->searchService->searchEntities($searchTerm)['results']; + $entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results']; } else { $entityNames = $entityTypes->map(function ($type) { return 'BookStack\\' . ucfirst($type); })->toArray(); - $entities = $this->viewService->getPopular(20, 0, $entityNames); + $entities = $this->viewService->getPopular(20, 0, $entityNames, $permission); } return view('search/entity-ajax-list', ['entities' => $entities]); diff --git a/app/Page.php b/app/Page.php index e169a1959..38feb610d 100644 --- a/app/Page.php +++ b/app/Page.php @@ -6,7 +6,6 @@ class Page extends Entity protected $simpleAttributes = ['name', 'id', 'slug']; - protected $with = ['book']; public $textField = 'text'; /** diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index ece9aa305..bdd1e37b1 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -492,14 +492,19 @@ class EntityRepo public function createFromInput($type, $input = [], $book = false) { $isChapter = strtolower($type) === 'chapter'; - $entity = $this->getEntity($type)->newInstance($input); - $entity->slug = $this->findSuitableSlug($type, $entity->name, false, $isChapter ? $book->id : false); - $entity->created_by = user()->id; - $entity->updated_by = user()->id; - $isChapter ? $book->chapters()->save($entity) : $entity->save(); - $this->permissionService->buildJointPermissionsForEntity($entity); - $this->searchService->indexEntity($entity); - return $entity; + $entityModel = $this->getEntity($type)->newInstance($input); + $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false); + $entityModel->created_by = user()->id; + $entityModel->updated_by = user()->id; + $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']); + } + + $this->permissionService->buildJointPermissionsForEntity($entityModel); + $this->searchService->indexEntity($entityModel); + return $entityModel; } /** @@ -518,6 +523,11 @@ class EntityRepo $entityModel->fill($input); $entityModel->updated_by = user()->id; $entityModel->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']); + } + $this->permissionService->buildJointPermissionsForEntity($entityModel); $this->searchService->indexEntity($entityModel); return $entityModel; @@ -583,6 +593,30 @@ class EntityRepo return $slug; } + /** + * Get a new draft page instance. + * @param Book $book + * @param Chapter|bool $chapter + * @return Page + */ + public function getDraftPage(Book $book, $chapter = false) + { + $page = $this->page->newInstance(); + $page->name = trans('entities.pages_initial_name'); + $page->created_by = user()->id; + $page->updated_by = user()->id; + $page->draft = true; + + if ($chapter) { + $page->chapter_id = $chapter->id; + } + + $book->pages()->save($page); + $page = $this->page->find($page->id); + $this->permissionService->buildJointPermissionsForEntity($page); + return $page; + } + /** * Publish a draft page to make it a normal page. * Sets the slug and updates the content. @@ -611,6 +645,43 @@ class EntityRepo return $draftPage; } + /** + * Create a copy of a page in a new location with a new name. + * @param Page $page + * @param Entity $newParent + * @param string $newName + * @return Page + */ + public function copyPage(Page $page, Entity $newParent, $newName = '') + { + $newBook = $newParent->isA('book') ? $newParent : $newParent->book; + $newChapter = $newParent->isA('chapter') ? $newParent : null; + $copyPage = $this->getDraftPage($newBook, $newChapter); + $pageData = $page->getAttributes(); + + // Update name + if (!empty($newName)) { + $pageData['name'] = $newName; + } + + // Copy tags from previous page if set + if ($page->tags) { + $pageData['tags'] = []; + foreach ($page->tags as $tag) { + $pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value]; + } + } + + // Set priority + if ($newParent->isA('chapter')) { + $pageData['priority'] = $this->getNewChapterPriority($newParent); + } else { + $pageData['priority'] = $this->getNewBookPriority($newParent); + } + + return $this->publishPageDraft($copyPage, $pageData); + } + /** * Saves a page revision into the system. * @param Page $page @@ -774,7 +845,9 @@ class EntityRepo $scriptSearchRegex = '/.*?<\/script>/ms'; $matches = []; preg_match_all($scriptSearchRegex, $html, $matches); - if (count($matches) === 0) return $html; + if (count($matches) === 0) { + return $html; + } foreach ($matches[0] as $match) { $html = str_replace($match, htmlentities($match), $html); @@ -793,30 +866,6 @@ class EntityRepo return strip_tags($html); } - /** - * Get a new draft page instance. - * @param Book $book - * @param Chapter|bool $chapter - * @return Page - */ - public function getDraftPage(Book $book, $chapter = false) - { - $page = $this->page->newInstance(); - $page->name = trans('entities.pages_initial_name'); - $page->created_by = user()->id; - $page->updated_by = user()->id; - $page->draft = true; - - if ($chapter) { - $page->chapter_id = $chapter->id; - } - - $book->pages()->save($page); - $page = $this->page->find($page->id); - $this->permissionService->buildJointPermissionsForEntity($page); - return $page; - } - /** * Search for image usage within page content. * @param $imageString diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index ada2261e4..01e87f167 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -9,14 +9,16 @@ class ExportService { protected $entityRepo; + protected $imageService; /** * ExportService constructor. * @param $entityRepo */ - public function __construct(EntityRepo $entityRepo) + public function __construct(EntityRepo $entityRepo, ImageService $imageService) { $this->entityRepo = $entityRepo; + $this->imageService = $imageService; } /** @@ -24,6 +26,7 @@ class ExportService * Includes required CSS & image content. Images are base64 encoded into the HTML. * @param Page $page * @return mixed|string + * @throws \Throwable */ public function pageToContainedHtml(Page $page) { @@ -38,6 +41,7 @@ class ExportService * Convert a chapter to a self-contained HTML file. * @param Chapter $chapter * @return mixed|string + * @throws \Throwable */ public function chapterToContainedHtml(Chapter $chapter) { @@ -56,6 +60,7 @@ class ExportService * Convert a book to a self-contained HTML file. * @param Book $book * @return mixed|string + * @throws \Throwable */ public function bookToContainedHtml(Book $book) { @@ -71,6 +76,7 @@ class ExportService * Convert a page to a PDF file. * @param Page $page * @return mixed|string + * @throws \Throwable */ public function pageToPdf(Page $page) { @@ -85,6 +91,7 @@ class ExportService * Convert a chapter to a PDF file. * @param Chapter $chapter * @return mixed|string + * @throws \Throwable */ public function chapterToPdf(Chapter $chapter) { @@ -103,6 +110,7 @@ class ExportService * Convert a book to a PDF file * @param Book $book * @return string + * @throws \Throwable */ public function bookToPdf(Book $book) { @@ -118,6 +126,7 @@ class ExportService * Convert normal webpage HTML to a PDF. * @param $html * @return string + * @throws \Exception */ protected function htmlToPdf($html) { @@ -146,45 +155,14 @@ class ExportService // Replace image src with base64 encoded image strings if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) { foreach ($imageTagsOutput[0] as $index => $imgMatch) { - $oldImgString = $imgMatch; + $oldImgTagString = $imgMatch; $srcString = $imageTagsOutput[2][$index]; - $isLocal = strpos(trim($srcString), 'http') !== 0; - if ($isLocal) { - $pathString = public_path(trim($srcString, '/')); - } else { - $pathString = $srcString; + $imageEncoded = $this->imageService->imageUriToBase64($srcString); + if ($imageEncoded === null) { + $imageEncoded = $srcString; } - - // Attempt to find local files even if url not absolute - $base = baseUrl('/'); - if (strpos($srcString, $base) === 0) { - $isLocal = true; - $relString = str_replace($base, '', $srcString); - $pathString = public_path(trim($relString, '/')); - } - - if ($isLocal && !file_exists($pathString)) { - continue; - } - try { - if ($isLocal) { - $imageContent = file_get_contents($pathString); - } else { - $ch = curl_init(); - curl_setopt_array($ch, [CURLOPT_URL => $pathString, CURLOPT_RETURNTRANSFER => 1, CURLOPT_CONNECTTIMEOUT => 5]); - $imageContent = curl_exec($ch); - $err = curl_error($ch); - curl_close($ch); - if ($err) { - throw new \Exception("Image fetch failed, Received error: " . $err); - } - } - $imageEncoded = 'data:image/' . pathinfo($pathString, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageContent); - $newImageString = str_replace($srcString, $imageEncoded, $oldImgString); - } catch (\ErrorException $e) { - $newImageString = ''; - } - $htmlContent = str_replace($oldImgString, $newImageString, $htmlContent); + $newImgTagString = str_replace($srcString, $imageEncoded, $oldImgTagString); + $htmlContent = str_replace($oldImgTagString, $newImgTagString, $htmlContent); } } diff --git a/app/Services/ImageService.php b/app/Services/ImageService.php index ebbcd9351..06ef3a0f0 100644 --- a/app/Services/ImageService.php +++ b/app/Services/ImageService.php @@ -192,7 +192,8 @@ class ImageService extends UploadService * @param Image $image * @return boolean */ - protected function isGif(Image $image) { + protected function isGif(Image $image) + { return strtolower(pathinfo($this->getPath($image), PATHINFO_EXTENSION)) === 'gif'; } @@ -320,6 +321,53 @@ class ImageService extends UploadService return $image; } + /** + * 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 + */ + public function imageUriToBase64(string $uri) + { + $isLocal = strpos(trim($uri), 'http') !== 0; + + // Attempt to find local files even if url not absolute + $base = baseUrl('/'); + if (!$isLocal && strpos($uri, $base) === 0) { + $isLocal = true; + $uri = str_replace($base, '', $uri); + } + + $imageData = null; + + if ($isLocal) { + $uri = trim($uri, '/'); + $storage = $this->getStorage(); + if ($storage->exists($uri)) { + $imageData = $storage->get($uri); + } + } else { + try { + $ch = curl_init(); + curl_setopt_array($ch, [CURLOPT_URL => $uri, CURLOPT_RETURNTRANSFER => 1, CURLOPT_CONNECTTIMEOUT => 5]); + $imageData = curl_exec($ch); + $err = curl_error($ch); + curl_close($ch); + if ($err) { + throw new \Exception("Image fetch failed, Received error: " . $err); + } + } catch (\Exception $e) { + } + } + + if ($imageData === null) { + return null; + } + + return 'data:image/' . pathinfo($uri, PATHINFO_EXTENSION) . ';base64,' . base64_encode($imageData); + } + /** * Gets a public facing url for an image by checking relevant environment variables. * @param string $filePath diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 331ed06c8..0dd316b34 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -67,13 +67,19 @@ class PermissionService /** * Prepare the local entity cache and ensure it's empty + * @param Entity[] $entities */ - protected function readyEntityCache() + protected function readyEntityCache($entities = []) { - $this->entityCache = [ - 'books' => collect(), - 'chapters' => collect() - ]; + $this->entityCache = []; + + foreach ($entities as $entity) { + $type = $entity->getType(); + if (!isset($this->entityCache[$type])) { + $this->entityCache[$type] = collect(); + } + $this->entityCache[$type]->put($entity->id, $entity); + } } /** @@ -83,17 +89,14 @@ class PermissionService */ protected function getBook($bookId) { - if (isset($this->entityCache['books']) && $this->entityCache['books']->has($bookId)) { - return $this->entityCache['books']->get($bookId); + if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) { + return $this->entityCache['book']->get($bookId); } $book = $this->book->find($bookId); if ($book === null) { $book = false; } - if (isset($this->entityCache['books'])) { - $this->entityCache['books']->put($bookId, $book); - } return $book; } @@ -105,17 +108,14 @@ class PermissionService */ protected function getChapter($chapterId) { - if (isset($this->entityCache['chapters']) && $this->entityCache['chapters']->has($chapterId)) { - return $this->entityCache['chapters']->get($chapterId); + if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) { + return $this->entityCache['chapter']->get($chapterId); } $chapter = $this->chapter->find($chapterId); if ($chapter === null) { $chapter = false; } - if (isset($this->entityCache['chapters'])) { - $this->entityCache['chapters']->put($chapterId, $chapter); - } return $chapter; } @@ -179,6 +179,7 @@ class PermissionService * @param Collection $books * @param array $roles * @param bool $deleteOld + * @throws \Throwable */ protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false) { @@ -250,7 +251,7 @@ class PermissionService $this->deleteManyJointPermissionsForRoles($roles); // Chunk through all books - $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) { + $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) { $this->buildJointPermissionsForBooks($books, $roles); }); } @@ -279,6 +280,7 @@ class PermissionService /** * Delete the entity jointPermissions for a particular entity. * @param Entity $entity + * @throws \Throwable */ public function deleteJointPermissionsForEntity(Entity $entity) { @@ -288,6 +290,7 @@ class PermissionService /** * Delete all of the entity jointPermissions for a list of entities. * @param Entity[] $entities + * @throws \Throwable */ protected function deleteManyJointPermissionsForEntities($entities) { @@ -314,10 +317,11 @@ class PermissionService * Create & Save entity jointPermissions for many entities and jointPermissions. * @param Collection $entities * @param array $roles + * @throws \Throwable */ protected function createManyJointPermissions($entities, $roles) { - $this->readyEntityCache(); + $this->readyEntityCache($entities); $jointPermissions = []; // Fetch Entity Permissions and create a mapping of entity restricted statuses @@ -342,7 +346,7 @@ class PermissionService // Create a mapping of role permissions $rolePermissionMap = []; foreach ($roles as $role) { - foreach ($role->getRelationValue('permissions') as $permission) { + foreach ($role->permissions as $permission) { $rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true; } } @@ -630,16 +634,17 @@ class PermissionService * @param string $tableName * @param string $entityIdColumn * @param string $entityTypeColumn + * @param string $action * @return mixed */ - public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn) + public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view') { if ($this->isAdmin()) { $this->clean(); return $query; } - $this->currentAction = 'view'; + $this->currentAction = $action; $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn]; $q = $query->where(function ($query) use ($tableDetails) { diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php index 056e1f077..6390b8bc4 100644 --- a/app/Services/SearchService.php +++ b/app/Services/SearchService.php @@ -67,7 +67,7 @@ class SearchService * @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed. * @return array[int, Collection]; */ - public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20) + public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view') { $terms = $this->parseSearchString($searchString); $entityTypes = array_keys($this->entities); @@ -87,8 +87,8 @@ class SearchService if (!in_array($entityType, $entityTypes)) { continue; } - $search = $this->searchEntityTable($terms, $entityType, $page, $count); - $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, true); + $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action); + $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true); if ($entityTotal > $page * $count) { $hasMore = true; } @@ -147,12 +147,13 @@ class SearchService * @param string $entityType * @param int $page * @param int $count + * @param string $action * @param bool $getCount Return the total count of the search * @return \Illuminate\Database\Eloquent\Collection|int|static[] */ - public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $getCount = false) + public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false) { - $query = $this->buildEntitySearchQuery($terms, $entityType); + $query = $this->buildEntitySearchQuery($terms, $entityType, $action); if ($getCount) { return $query->count(); } @@ -165,9 +166,10 @@ class SearchService * Create a search query for an entity * @param array $terms * @param string $entityType + * @param string $action * @return \Illuminate\Database\Eloquent\Builder */ - protected function buildEntitySearchQuery($terms, $entityType = 'page') + protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view') { $entity = $this->getEntity($entityType); $entitySelect = $entity->newQuery(); @@ -212,7 +214,7 @@ class SearchService } } - return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view'); + return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action); } diff --git a/app/Services/ViewService.php b/app/Services/ViewService.php index ddcf2eb7e..cd869018c 100644 --- a/app/Services/ViewService.php +++ b/app/Services/ViewService.php @@ -51,11 +51,13 @@ class ViewService * @param int $count * @param int $page * @param bool|false|array $filterModel + * @param string $action - used for permission checking + * @return */ - public function getPopular($count = 10, $page = 0, $filterModel = false) + public function getPopular($count = 10, $page = 0, $filterModel = false, $action = 'view') { $skipCount = $count * $page; - $query = $this->permissionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type') + $query = $this->permissionService->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action) ->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count')) ->groupBy('viewable_id', 'viewable_type') ->orderBy('view_count', 'desc'); diff --git a/config/app.php b/config/app.php index fad0c20f2..69e2380e8 100755 --- a/config/app.php +++ b/config/app.php @@ -4,12 +4,28 @@ return [ 'env' => env('APP_ENV', 'production'), + /** + * Set the default view type for various lists. Can be overridden by user preferences. + * This will be used for public viewers and users that have not set a preference. + */ 'views' => [ 'books' => env('APP_VIEWS_BOOKS', 'list') ], + /** + * Allow ':i=g.settings.video_template_callback?g.settings.video_template_callback(j):'"}return i};return{dataToHtml:g}}),g("l",["8"],function(a){return a("tinymce.util.Promise")}),g("i",["k","l"],function(a,b){var c=function(a,c,d){var e={};return new b(function(b,f){var g=function(d){return d.html&&(e[a.source1]=d),b({url:a.source1,html:d.html?d.html:c(a)})};e[a.source1]?g(e[a.source1]):d({url:a.source1},g,f)})},d=function(a,c){return new b(function(b){b({html:c(a),url:a.source1})})},e=function(b){return function(c){return a.dataToHtml(b,c)}},f=function(a,b){var f=a.settings.media_url_resolver;return f?c(b,e(a),f):d(b,e(a))};return{getEmbedHtml:f}}),g("j",[],function(){var a=function(a,b){a.state.set("oldVal",a.value()),b.state.set("oldVal",b.value())},b=function(a,b){var c=a.find("#width")[0],d=a.find("#height")[0],e=a.find("#constrain")[0];c&&d&&e&&b(c,d,e.checked())},c=function(b,c,d){var e=b.state.get("oldVal"),f=c.state.get("oldVal"),g=b.value(),h=c.value();d&&e&&f&&g&&h&&(g!==e?(h=Math.round(g/e*h),isNaN(h)||c.value(h)):(g=Math.round(h/f*g),isNaN(g)||b.value(g))),a(b,c)},d=function(c){b(c,a)},e=function(a){b(a,c)},f=function(a){var b=function(){a(function(a){e(a)})};return{type:"container",label:"Dimensions",layout:"flex",align:"center",spacing:5,items:[{name:"width",type:"textbox",maxLength:5,size:5,onchange:b,ariaLabel:"Width"},{type:"label",text:"x"},{name:"height",type:"textbox",maxLength:5,size:5,onchange:b,ariaLabel:"Height"},{name:"constrain",type:"checkbox",checked:!0,text:"Constrain proportions"}]}};return{createUi:f,syncSize:d,updateSize:e}}),g("7",["g","h","6","i","f","3","d","j"],function(a,b,c,d,e,f,g,h){var i=g.ie&&g.ie<=8?"onChange":"onInput",j=function(a){return function(b){var c=b&&b.msg?"Media embed handler error: "+b.msg:"Media embed handler threw unknown error.";a.notificationManager.open({type:"error",text:c})}},k=function(a){var c=a.selection.getNode(),d=c.getAttribute("data-ephox-embed-iri");return d?{source1:d,"data-ephox-embed-iri":d,width:e.getMaxWidth(c),height:e.getMaxHeight(c)}:c.getAttribute("data-mce-object")?b.htmlToData(a.settings.media_scripts,a.serializer.serialize(c,{selection:!0})):{}},l=function(a){var b=a.selection.getNode();if(b.getAttribute("data-mce-object")||b.getAttribute("data-ephox-embed-iri"))return a.selection.getContent()},m=function(a,c){return function(d){var e=d.html,g=a.find("#embed")[0],i=f.extend(b.htmlToData(c.settings.media_scripts,e),{source1:d.url});a.fromJSON(i),g&&(g.value(e),h.updateSize(a))}},n=function(a,b){var c,d,e=a.dom.select("img[data-mce-object]");for(c=0;c=0;d--)b[c]===e[d]&&e.splice(d,1);a.selection.select(e[0])},o=function(a,b){var c=a.dom.select("img[data-mce-object]");a.insertContent(b),n(a,c),a.nodeChanged()},p=function(a,b){var e=a.toJSON();e.embed=c.updateHtml(e.embed,e),e.embed?o(b,e.embed):d.getEmbedHtml(b,e).then(function(a){o(b,a.html)})["catch"](j(b))},q=function(a,b){f.each(b,function(b,c){a.find("#"+c).value(b)})},r=function(a){var e,g,n=[{name:"source1",type:"filepicker",filetype:"media",size:40,autofocus:!0,label:"Source",onpaste:function(){setTimeout(function(){d.getEmbedHtml(a,e.toJSON()).then(m(e,a))["catch"](j(a))},1)},onchange:function(b){d.getEmbedHtml(a,e.toJSON()).then(m(e,a))["catch"](j(a)),q(e,b.meta)},onbeforecall:function(a){a.meta=e.toJSON()}}],o=[],r=function(a){a(e),g=e.toJSON(),e.find("#embed").value(c.updateHtml(g.embed,g))};if(a.settings.media_alt_source!==!1&&o.push({name:"source2",type:"filepicker",filetype:"media",size:40,label:"Alternative source"}),a.settings.media_poster!==!1&&o.push({name:"poster",type:"filepicker",filetype:"image",size:40,label:"Poster"}),a.settings.media_dimensions!==!1){var s=h.createUi(r);n.push(s)}g=k(a);var t={id:"mcemediasource",type:"textbox",flex:1,name:"embed",value:l(a),multiline:!0,rows:5,label:"Source"},u=function(){g=f.extend({},b.htmlToData(a.settings.media_scripts,this.value())),this.parent().parent().fromJSON(g)};t[i]=u,e=a.windowManager.open({title:"Insert/edit media",data:g,bodyType:"tabpanel",body:[{title:"General",type:"form",items:n},{title:"Embed",type:"container",layout:"flex",direction:"column",align:"stretch",padding:10,spacing:10,items:[{type:"label",text:"Paste your embed code below:",forId:"mcemediasource"},t]},{title:"Advanced",type:"form",items:o}],onSubmit:function(){h.updateSize(e),p(e,a)}}),h.syncSize(e)};return{showDialog:r}}),g("0",["1","2","3","4","5","6","7"],function(a,b,c,d,e,f,g){var h=function(b){b.on("ResolveName",function(a){var b;1===a.target.nodeType&&(b=a.target.getAttribute("data-mce-object"))&&(a.name=b)}),b.on("preInit",function(){var f=b.schema.getSpecialElements();c.each("video audio iframe object".split(" "),function(a){f[a]=new RegExp("]*>","gi")});var g=b.schema.getBoolAttrs();c.each("webkitallowfullscreen mozallowfullscreen allowfullscreen".split(" "),function(a){g[a]={}}),b.parser.addNodeFilter("iframe,video,audio,object,embed,script",d.placeHolderConverter(b)),b.serializer.addAttributeFilter("data-mce-object",function(c,d){for(var f,g,h,i,j,k,l,m,n=c.length;n--;)if(f=c[n],f.parent){for(l=f.attr(d),g=new a(l,1),"audio"!==l&&"script"!==l&&(m=f.attr("class"),m&&m.indexOf("mce-preview-object")!==-1?g.attr({width:f.firstChild.attr("width"),height:f.firstChild.attr("height")}):g.attr({width:f.attr("width"),height:f.attr("height")})),g.attr({style:f.attr("style")}),i=f.attributes,h=i.length;h--;){var o=i[h].name;0===o.indexOf("data-mce-p-")&&g.attr(o.substr(11),i[h].value)}"script"===l&&g.attr("type","text/javascript"),j=f.attr("data-mce-html"),j&&(k=new a("#text",3),k.raw=!0,k.value=e.sanitize(b,unescape(j)),g.append(k)),f.replace(g)}})}),b.on("click keyup",function(){var a=b.selection.getNode();a&&b.dom.hasClass(a,"mce-preview-object")&&b.dom.getAttrib(a,"data-mce-selected")&&a.setAttribute("data-mce-selected","2")}),b.on("ObjectSelected",function(a){var b=a.target.getAttribute("data-mce-object");"audio"!==b&&"script"!==b||a.preventDefault()}),b.on("objectResized",function(a){var b,c=a.target;c.getAttribute("data-mce-object")&&(b=c.getAttribute("data-mce-html"),b&&(b=unescape(b),c.setAttribute("data-mce-html",escape(f.updateHtml(b,{width:a.width,height:a.height})))))}),this.showDialog=function(){g.showDialog(b)},b.addButton("media",{tooltip:"Insert/edit media",onclick:this.showDialog,stateSelector:["img[data-mce-object]","span[data-mce-object]","div[data-ephox-embed-iri]"]}),b.addMenuItem("media",{icon:"media",text:"Media",onclick:this.showDialog,context:"insert",prependToContext:!0}),b.on("setContent",function(){b.$("span.mce-preview-object").each(function(a,c){var d=b.$(c);0===d.find("span.mce-shim",c).length&&d.append('')})}),b.addCommand("mceMedia",this.showDialog)};return b.add("media",h),function(){}}),d("0")()}(); \ No newline at end of file +!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env"),r=tinymce.util.Tools.resolve("tinymce.util.Tools"),i=function(e){return e.getParam("media_scripts")},a=function(e){return e.getParam("audio_template_callback")},o=function(e){return e.getParam("video_template_callback")},n=function(e){return e.getParam("media_live_embeds",!0)},c=function(e){return e.getParam("media_filter_html",!0)},s=function(e){return e.getParam("media_url_resolver")},u=function(e){return e.getParam("media_alt_source",!0)},l=function(e){return e.getParam("media_poster",!0)},m=function(e){return e.getParam("media_dimensions",!0)},d=tinymce.util.Tools.resolve("tinymce.html.SaxParser"),h=tinymce.util.Tools.resolve("tinymce.dom.DOMUtils"),p=function(e,t){if(e)for(var r=0;r"):"application/x-shockwave-flash"===n.source1mime?(h='',d.poster&&(h+=''),h+=""):-1!==n.source1mime.indexOf("audio")?(l=n,(m=v)?m(l):'"):"script"===n.type?' ',k=a.settings.directionality?' dir="'+a.settings.directionality+'"':"";if(b=""+f+'"+a.getContent()+j+"",e)this.getEl("body").firstChild.src="data:text/html;charset=utf-8,"+encodeURIComponent(b);else{var l=this.getEl("body").firstChild.contentWindow.document;l.open(),l.write(b),l.close()}}})}),a.addButton("preview",{title:"Preview",cmd:"mcePreview"}),a.addMenuItem("preview",{text:"Preview",cmd:"mcePreview",context:"view"})}),function(){}}),d("0")()}(); \ No newline at end of file +!function(){"use strict";var e=tinymce.util.Tools.resolve("tinymce.PluginManager"),t=tinymce.util.Tools.resolve("tinymce.Env"),n=function(e){return parseInt(e.getParam("plugin_preview_width","650"),10)},i=function(e){return parseInt(e.getParam("plugin_preview_height","500"),10)},o=function(e){return e.getParam("content_style","")},r=tinymce.util.Tools.resolve("tinymce.util.Tools"),c=function(e){var t="",n=e.dom.encode,i=o(e);t+='',i&&(t+='"),r.each(e.contentCSS,function(i){t+=''});var c=e.settings.body_id||"tinymce";-1!==c.indexOf("=")&&(c=(c=e.getParam("body_id","","hash"))[e.id]||c);var a=e.settings.body_class||"";-1!==a.indexOf("=")&&(a=(a=e.getParam("body_class","","hash"))[e.id]||"");var s=e.settings.directionality?' dir="'+e.settings.directionality+'"':"";return""+t+'"+e.getContent()+' -@stop diff --git a/resources/views/robots.blade.php b/resources/views/robots.blade.php new file mode 100644 index 000000000..f3d10797a --- /dev/null +++ b/resources/views/robots.blade.php @@ -0,0 +1,6 @@ +User-agent: * +@if($allowRobots) +Disallow: +@else +Disallow: / +@endif \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 7a8634ca3..f7b2347a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ Route::get('/translations', 'HomeController@getTranslations'); Route::get('/icon/{iconName}.svg', 'HomeController@getIcon'); +Route::get('/robots.txt', 'HomeController@getRobots'); // Authenticated routes... Route::group(['middleware' => 'auth'], function () { @@ -46,6 +47,8 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{bookSlug}/page/{pageSlug}/edit', 'PageController@edit'); Route::get('/{bookSlug}/page/{pageSlug}/move', 'PageController@showMove'); Route::put('/{bookSlug}/page/{pageSlug}/move', 'PageController@move'); + Route::get('/{bookSlug}/page/{pageSlug}/copy', 'PageController@showCopy'); + Route::post('/{bookSlug}/page/{pageSlug}/copy', 'PageController@copy'); Route::get('/{bookSlug}/page/{pageSlug}/delete', 'PageController@showDelete'); Route::get('/{bookSlug}/draft/{pageId}/delete', 'PageController@showDeleteDraft'); Route::get('/{bookSlug}/page/{pageSlug}/permissions', 'PageController@showRestrict'); diff --git a/tests/BrowserKitTest.php b/tests/BrowserKitTest.php index a8ff03044..86f61a764 100644 --- a/tests/BrowserKitTest.php +++ b/tests/BrowserKitTest.php @@ -12,10 +12,7 @@ abstract class BrowserKitTest extends TestCase { use DatabaseTransactions; - - // Local user instances - private $admin; - private $editor; + use SharedTestHelpers; /** * The base URL to use while testing the application. @@ -43,38 +40,6 @@ abstract class BrowserKitTest extends TestCase return $app; } - /** - * Set the current user context to be an admin. - * @return $this - */ - public function asAdmin() - { - return $this->actingAs($this->getAdmin()); - } - - /** - * Get the current admin user. - * @return mixed - */ - public function getAdmin() { - if($this->admin === null) { - $adminRole = Role::getSystemRole('admin'); - $this->admin = $adminRole->users->first(); - } - return $this->admin; - } - - /** - * Set the current editor context to be an editor. - * @return $this - */ - public function asEditor() - { - if ($this->editor === null) { - $this->editor = $this->getEditor(); - } - return $this->actingAs($this->editor); - } /** * Get a user that's not a system user such as the guest user. @@ -127,28 +92,6 @@ abstract class BrowserKitTest extends TestCase $restrictionService->buildJointPermissionsForEntity($entity); } - /** - * Get an instance of a user with 'editor' permissions - * @param array $attributes - * @return mixed - */ - protected function getEditor($attributes = []) - { - $user = \BookStack\Role::getRole('editor')->users()->first(); - if (!empty($attributes)) $user->forceFill($attributes)->save(); - return $user; - } - - /** - * Get an instance of a user with 'viewer' permissions - * @return mixed - */ - protected function getViewer() - { - $user = \BookStack\Role::getRole('viewer')->users()->first(); - if (!empty($attributes)) $user->forceFill($attributes)->save(); - return $user; - } /** * Quick way to create a new user without any permissions diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index 352af1e42..f8ca8ea12 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -5,6 +5,7 @@ use BookStack\Chapter; use BookStack\Page; use BookStack\Repos\EntityRepo; use BookStack\Repos\UserRepo; +use Carbon\Carbon; class EntityTest extends BrowserKitTest { @@ -269,6 +270,9 @@ class EntityTest extends BrowserKitTest public function test_recently_updated_pages_on_home() { $page = Page::orderBy('updated_at', 'asc')->first(); + Page::where('id', '!=', $page->id)->update([ + 'updated_at' => Carbon::now()->subSecond(1) + ]); $this->asAdmin()->visit('/') ->dontSeeInElement('#recently-updated-pages', $page->name); $this->visit($page->getUrl() . '/edit') diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index 3b0831029..ea5ab665d 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -1,6 +1,7 @@ book = \BookStack\Book::first(); + $this->book = Book::first(); } public function test_drafts_do_not_show_up() @@ -29,17 +30,17 @@ class SortTest extends TestCase public function test_page_move() { - $page = \BookStack\Page::first(); + $page = Page::first(); $currentBook = $page->book; - $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first(); + $newBook = Book::where('id', '!=', $currentBook->id)->first(); - $resp = $this->asAdmin()->get($page->getUrl() . '/move'); + $resp = $this->asEditor()->get($page->getUrl('/move')); $resp->assertSee('Move Page'); - $movePageResp = $this->put($page->getUrl() . '/move', [ + $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id ]); - $page = \BookStack\Page::find($page->id); + $page = Page::find($page->id); $movePageResp->assertRedirect($page->getUrl()); $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); @@ -49,21 +50,46 @@ class SortTest extends TestCase $newBookResp->assertSee($page->name); } - public function test_chapter_move() + public function test_page_move_requires_create_permissions_on_parent() { - $chapter = \BookStack\Chapter::first(); - $currentBook = $chapter->book; - $pageToCheck = $chapter->pages->first(); - $newBook = \BookStack\Book::where('id', '!=', $currentBook->id)->first(); + $page = Page::first(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $editor = $this->getEditor(); - $chapterMoveResp = $this->asAdmin()->get($chapter->getUrl() . '/move'); - $chapterMoveResp->assertSee('Move Chapter'); + $this->setEntityRestrictions($newBook, ['view', 'edit', 'delete'], $editor->roles); - $moveChapterResp = $this->put($chapter->getUrl() . '/move', [ + $movePageResp = $this->actingAs($editor)->put($page->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id + ]); + $this->assertPermissionError($movePageResp); + + $this->setEntityRestrictions($newBook, ['view', 'edit', 'delete', 'create'], $editor->roles); + $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id ]); - $chapter = \BookStack\Chapter::find($chapter->id); + $page = Page::find($page->id); + $movePageResp->assertRedirect($page->getUrl()); + + $this->assertTrue($page->book->id == $newBook->id, 'Page book is now the new book'); + } + + public function test_chapter_move() + { + $chapter = Chapter::first(); + $currentBook = $chapter->book; + $pageToCheck = $chapter->pages->first(); + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $chapterMoveResp = $this->asEditor()->get($chapter->getUrl('/move')); + $chapterMoveResp->assertSee('Move Chapter'); + + $moveChapterResp = $this->put($chapter->getUrl('/move'), [ + 'entity_selection' => 'book:' . $newBook->id + ]); + + $chapter = Chapter::find($chapter->id); $moveChapterResp->assertRedirect($chapter->getUrl()); $this->assertTrue($chapter->book->id === $newBook->id, 'Chapter Book is now the new book'); @@ -71,7 +97,7 @@ class SortTest extends TestCase $newBookResp->assertSee('moved chapter'); $newBookResp->assertSee($chapter->name); - $pageToCheck = \BookStack\Page::find($pageToCheck->id); + $pageToCheck = Page::find($pageToCheck->id); $this->assertTrue($pageToCheck->book_id === $newBook->id, 'Chapter child page\'s book id has changed to the new book'); $pageCheckResp = $this->get($pageToCheck->getUrl()); $pageCheckResp->assertSee($newBook->name); @@ -104,7 +130,7 @@ class SortTest extends TestCase ]; } - $sortResp = $this->asAdmin()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]); + $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]); $sortResp->assertRedirect($newBook->getUrl()); $sortResp->assertStatus(302); $this->assertDatabaseHas('chapters', [ @@ -120,4 +146,43 @@ class SortTest extends TestCase $checkResp->assertSee($newBook->name); } + public function test_page_copy() + { + $page = Page::first(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page' + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $newBook->id, 'Page was copied to correct book'); + } + + public function test_page_copy_with_no_destination() + { + $page = Page::first(); + $currentBook = $page->book; + + $resp = $this->asEditor()->get($page->getUrl('/copy')); + $resp->assertSee('Copy Page'); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'name' => 'My copied test page' + ]); + + $pageCopy = Page::where('name', '=', 'My copied test page')->first(); + + $movePageResp->assertRedirect($pageCopy->getUrl()); + $this->assertTrue($pageCopy->book->id == $currentBook->id, 'Page was copied to correct book'); + $this->assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); + } + } \ No newline at end of file diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php index 1ef7b7bde..7e1166388 100644 --- a/tests/Entity/TagTest.php +++ b/tests/Entity/TagTest.php @@ -1,6 +1,7 @@ defaultTagCount)->make(); } - $page->tags()->saveMany($tags); - return $page; + $entity->tags()->saveMany($tags); + return $entity; } public function test_get_page_tags() { - $page = $this->getPageWithTags(); + $page = $this->getEntityWithTags(Page::class); // Add some other tags to check they don't interfere factory(Tag::class, $this->defaultTagCount)->create(); @@ -41,6 +42,34 @@ class TagTest extends BrowserKitTest $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected"); } + public function test_get_chapter_tags() + { + $chapter = $this->getEntityWithTags(Chapter::class); + + // Add some other tags to check they don't interfere + factory(Tag::class, $this->defaultTagCount)->create(); + + $this->asAdmin()->get("/ajax/tags/get/chapter/" . $chapter->id) + ->shouldReturnJson(); + + $json = json_decode($this->response->getContent()); + $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected"); + } + + public function test_get_book_tags() + { + $book = $this->getEntityWithTags(Book::class); + + // Add some other tags to check they don't interfere + factory(Tag::class, $this->defaultTagCount)->create(); + + $this->asAdmin()->get("/ajax/tags/get/book/" . $book->id) + ->shouldReturnJson(); + + $json = json_decode($this->response->getContent()); + $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected"); + } + public function test_tag_name_suggestions() { // Create some tags with similar names to test with @@ -51,7 +80,7 @@ class TagTest extends BrowserKitTest $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county'])); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet'])); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans'])); - $page = $this->getPageWithTags($attrs); + $page = $this->getEntityWithTags(Page::class, $attrs); $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]); $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']); @@ -69,7 +98,7 @@ class TagTest extends BrowserKitTest $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog'])); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult'])); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy'])); - $page = $this->getPageWithTags($attrs); + $page = $this->getEntityWithTags(Page::class, $attrs); $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]); $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']); @@ -85,7 +114,7 @@ class TagTest extends BrowserKitTest $attrs = collect(); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country'])); $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color'])); - $page = $this->getPageWithTags($attrs); + $page = $this->getEntityWithTags(Page::class, $attrs); $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']); $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']); diff --git a/tests/ImageTest.php b/tests/ImageTest.php index 8c96ae925..49912ec4c 100644 --- a/tests/ImageTest.php +++ b/tests/ImageTest.php @@ -106,6 +106,29 @@ class ImageTest extends TestCase } } + public function test_secure_images_included_in_exports() + { + config()->set('filesystems.default', 'local_secure'); + $this->asEditor(); + $galleryFile = $this->getTestImage('my-secure-test-upload'); + $page = Page::first(); + $expectedPath = storage_path('uploads/images/gallery/' . Date('Y-m-M') . '/my-secure-test-upload'); + + $upload = $this->call('POST', '/images/gallery/upload', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []); + $imageUrl = json_decode($upload->getContent(), true)['url']; + $page->html .= ""; + $page->save(); + $upload->assertStatus(200); + + $encodedImageContent = base64_encode(file_get_contents($expectedPath)); + $export = $this->get($page->getUrl('/export/html')); + $this->assertTrue(str_contains($export->getContent(), $encodedImageContent), 'Uploaded image in export content'); + + if (file_exists($expectedPath)) { + unlink($expectedPath); + } + } + public function test_system_images_remain_public() { config()->set('filesystems.default', 'local_secure'); diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index 433ae7ff9..53e7ad3f3 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -1,7 +1,7 @@ user = $this->getEditor(); $this->viewer = $this->getViewer(); - $this->permissionService = $this->app[PermissionService::class]; } - /** - * Manually set some permissions on an entity. - * @param \BookStack\Entity $entity - * @param $actions - */ - protected function setEntityRestrictions(\BookStack\Entity $entity, $actions) + protected function setEntityRestrictions(Entity $entity, $actions = [], $roles = []) { - $entity->restricted = true; - $entity->permissions()->delete(); - - $role = $this->user->roles->first(); - $viewerRole = $this->viewer->roles->first(); - - $permissions = []; - foreach ($actions as $action) { - $permissions[] = [ - 'role_id' => $role->id, - 'action' => strtolower($action) - ]; - $permissions[] = [ - 'role_id' => $viewerRole->id, - 'action' => strtolower($action) - ]; - } - $entity->permissions()->createMany($permissions); - - $entity->save(); - $entity->load('permissions'); - $this->permissionService->buildJointPermissionsForEntity($entity); - $entity->load('jointPermissions'); + $roles = [ + $this->user->roles->first(), + $this->viewer->roles->first(), + ]; + parent::setEntityRestrictions($entity, $actions, $roles); } public function test_book_view_restriction() diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php index 5bc66986b..f076e6734 100644 --- a/tests/Permissions/RolesTest.php +++ b/tests/Permissions/RolesTest.php @@ -16,14 +16,6 @@ class RolesTest extends BrowserKitTest $this->user = $this->getViewer(); } - protected function getViewer() - { - $role = \BookStack\Role::getRole('viewer'); - $viewer = $this->getNewBlankUser(); - $viewer->attachRole($role);; - return $viewer; - } - /** * Give the given user some permissions. * @param \BookStack\User $user diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 6f8590d4b..dadb37e46 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -90,4 +90,35 @@ class PublicActionTest extends BrowserKitTest $this->dontSee($page->name); } + public function test_robots_effected_by_public_status() + { + $this->visit('/robots.txt'); + $this->seeText("User-agent: *\nDisallow: /"); + + $this->setSettings(['app-public' => 'true']); + $this->visit('/robots.txt'); + + $this->seeText("User-agent: *\nDisallow:"); + $this->dontSeeText("Disallow: /"); + } + + public function test_robots_effected_by_setting() + { + $this->visit('/robots.txt'); + $this->seeText("User-agent: *\nDisallow: /"); + + config()->set('app.allow_robots', true); + $this->visit('/robots.txt'); + + $this->seeText("User-agent: *\nDisallow:"); + $this->dontSeeText("Disallow: /"); + + // Check config overrides app-public setting + config()->set('app.allow_robots', false); + $this->setSettings(['app-public' => 'true']); + $this->visit('/robots.txt'); + + $this->seeText("User-agent: *\nDisallow: /"); + } + } \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php new file mode 100644 index 000000000..325979e74 --- /dev/null +++ b/tests/SharedTestHelpers.php @@ -0,0 +1,143 @@ +actingAs($this->getAdmin()); + } + + /** + * Get the current admin user. + * @return mixed + */ + public function getAdmin() { + if($this->admin === null) { + $adminRole = Role::getSystemRole('admin'); + $this->admin = $adminRole->users->first(); + } + return $this->admin; + } + + /** + * Set the current user context to be an editor. + * @return $this + */ + public function asEditor() + { + return $this->actingAs($this->getEditor()); + } + + + /** + * Get a editor user. + * @return mixed + */ + protected function getEditor() { + if($this->editor === null) { + $editorRole = Role::getRole('editor'); + $this->editor = $editorRole->users->first(); + } + return $this->editor; + } + + /** + * Get an instance of a user with 'viewer' permissions + * @param $attributes + * @return mixed + */ + protected function getViewer($attributes = []) + { + $user = \BookStack\Role::getRole('viewer')->users()->first(); + if (!empty($attributes)) $user->forceFill($attributes)->save(); + return $user; + } + + /** + * Create and return a new book. + * @param array $input + * @return Book + */ + public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) { + return $this->app[EntityRepo::class]->createFromInput('book', $input, false); + } + + /** + * Create and return a new test chapter + * @param array $input + * @param Book $book + * @return Chapter + */ + public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { + return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book); + } + + /** + * Create and return a new test page + * @param array $input + * @return Chapter + */ + public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) { + $book = Book::first(); + $entityRepo = $this->app[EntityRepo::class]; + $draftPage = $entityRepo->getDraftPage($book); + return $entityRepo->publishPageDraft($draftPage, $input); + } + + /** + * Quickly sets an array of settings. + * @param $settingsArray + */ + protected function setSettings($settingsArray) + { + $settings = app(SettingService::class); + foreach ($settingsArray as $key => $value) { + $settings->put($key, $value); + } + } + + /** + * Manually set some permissions on an entity. + * @param Entity $entity + * @param array $actions + * @param array $roles + */ + protected function setEntityRestrictions(Entity $entity, $actions = [], $roles = []) + { + $entity->restricted = true; + $entity->permissions()->delete(); + + $permissions = []; + foreach ($actions as $action) { + foreach ($roles as $role) { + $permissions[] = [ + 'role_id' => $role->id, + 'action' => strtolower($action) + ]; + } + } + $entity->permissions()->createMany($permissions); + + $entity->save(); + $entity->load('permissions'); + $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity); + $entity->load('jointPermissions'); + } + +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index 5c37b6179..e0f160eed 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,21 +1,14 @@ actingAs($this->getAdmin()); - } - - /** - * Get the current admin user. - * @return mixed - */ - public function getAdmin() { - if($this->admin === null) { - $adminRole = Role::getSystemRole('admin'); - $this->admin = $adminRole->users->first(); - } - return $this->admin; - } - - /** - * Set the current user context to be an editor. - * @return $this - */ - public function asEditor() - { - return $this->actingAs($this->getEditor()); - } - - - /** - * Get a editor user. - * @return mixed - */ - public function getEditor() { - if($this->editor === null) { - $editorRole = Role::getRole('editor'); - $this->editor = $editorRole->users->first(); - } - return $this->editor; - } - - /** - * Get an instance of a user with 'viewer' permissions - * @param $attributes - * @return mixed - */ - protected function getViewer($attributes = []) - { - $user = \BookStack\Role::getRole('viewer')->users()->first(); - if (!empty($attributes)) $user->forceFill($attributes)->save(); - return $user; - } - - /** - * Create and return a new book. - * @param array $input - * @return Book - */ - public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) { - return $this->app[EntityRepo::class]->createFromInput('book', $input, false); - } - - /** - * Create and return a new test chapter - * @param array $input - * @param Book $book - * @return Chapter - */ - public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { - return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book); - } - - /** - * Create and return a new test page - * @param array $input - * @return Chapter - */ - public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) { - $book = Book::first(); - $entityRepo = $this->app[EntityRepo::class]; - $draftPage = $entityRepo->getDraftPage($book); - return $entityRepo->publishPageDraft($draftPage, $input); - } - - /** - * Quickly sets an array of settings. - * @param $settingsArray - */ - protected function setSettings($settingsArray) - { - $settings = app(SettingService::class); - foreach ($settingsArray as $key => $value) { - $settings->put($key, $value); - } + $response->assertRedirect('/'); + $this->assertTrue(session()->has('error')); + session()->remove('error'); } } \ No newline at end of file