Entity Repo & Controller Refactor (#1690)

* Started mass-refactoring of the current entity repos

* Rewrote book tree logic

- Now does two simple queries instead of one really complex one.
- Extracted logic into its own class.
- Remove model-level akward union field listing.
- Logic now more readable than being large separate query and
compilation functions.

* Extracted and split book sort logic

* Finished up Book controller/repo organisation

* Refactored bookshelves controllers and repo parts

* Fixed issues found via phpunit

* Refactored Chapter controller

* Updated Chapter export controller

* Started Page controller/repo refactor

* Refactored another chunk of PageController

* Completed initial pagecontroller refactor pass

* Fixed tests and continued reduction of old repos

* Removed old page remove and further reduced entity repo

* Removed old entity repo, split out page controller

* Ran phpcbf and split out some page content methods

* Tidied up some EntityProvider elements

* Fixed issued caused by viewservice change
This commit is contained in:
Dan Brown 2019-10-05 12:55:01 +01:00 committed by GitHub
parent 7cd956b24b
commit 31f5786e01
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 2705 additions and 2751 deletions

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
class ActivityService
@ -68,7 +69,7 @@ class ActivityService
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
* @param Entity $entity
* @param \BookStack\Entities\Entity $entity
* @return mixed
*/
public function removeEntity(Entity $entity)
@ -106,7 +107,7 @@ class ActivityService
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
* @param Entity $entity
* @param \BookStack\Entities\Entity $entity
* @param int $count
* @param int $page
* @return array

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use DB;
@ -44,7 +45,7 @@ class ViewService
}
// Otherwise create new view count
$entity->views()->save($this->view->create([
$entity->views()->save($this->view->newInstance([
'user_id' => $user->id,
'views' => 1
]));
@ -60,7 +61,7 @@ class ViewService
* @param string $action - used for permission checking
* @return Collection
*/
public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view')
public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
{
$skipCount = $count * $page;
$query = $this->permissionService

View File

@ -633,42 +633,40 @@ class PermissionService
}
/**
* Get the children of a book in an efficient single query, Filtered by the permission system.
* @param integer $book_id
* @param bool $filterDrafts
* @param bool $fetchPageContent
* @return QueryBuilder
* Limited the given entity query so that the query will only
* return items that the user has permission for the given ability.
*/
public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
{
$entities = $this->entityProvider;
$pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
$query->where('draft', '=', 0);
if (!$filterDrafts) {
$query->orWhere(function ($query) {
$query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
});
}
});
$chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id);
$query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
// Add joint permission filter
$whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
->where(function ($query) {
$query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
$query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
});
});
$query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
$query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
$this->clean();
return $query;
return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $ability)
->where(function (Builder $query) {
$query->where('has_permission', '=', true)
->orWhere(function (Builder $query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function enforceDraftVisiblityOnQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
});
}
/**

View File

@ -75,7 +75,7 @@ class Role extends Model
*/
public static function getRole($roleName)
{
return static::where('name', '=', $roleName)->first();
return static::query()->where('name', '=', $roleName)->first();
}
/**
@ -85,7 +85,7 @@ class Role extends Model
*/
public static function getSystemRole($roleName)
{
return static::where('system_name', '=', $roleName)->first();
return static::query()->where('system_name', '=', $roleName)->first();
}
/**
@ -94,6 +94,15 @@ class Role extends Model
*/
public static function visible()
{
return static::where('hidden', '=', false)->orderBy('name')->get();
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
/**
* Get the roles that can be restricted.
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
*/
public static function restrictable()
{
return static::query()->where('system_name', '!=', 'admin')->get();
}
}

View File

@ -53,13 +53,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $permissions;
/**
* This holds the default user when loaded.
* @var null|User
*/
protected static $defaultUser = null;
/**
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
return static::where('system_name', '=', 'public')->first();
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
/**

View File

@ -1,32 +1,31 @@
<?php namespace BookStack\Auth;
use Activity;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Images;
use Log;
class UserRepo
{
protected $user;
protected $role;
protected $entityRepo;
/**
* UserRepo constructor.
* @param User $user
* @param Role $role
* @param EntityRepo $entityRepo
*/
public function __construct(User $user, Role $role, EntityRepo $entityRepo)
public function __construct(User $user, Role $role)
{
$this->user = $user;
$this->role = $role;
$this->entityRepo = $entityRepo;
}
/**
@ -81,7 +80,7 @@ class UserRepo
* Creates a new user and attaches a role to them.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
* @return User
*/
public function registerNew(array $data, $verifyEmail = false)
{
@ -121,7 +120,7 @@ class UserRepo
/**
* Checks if the give user is the only admin.
* @param \BookStack\Auth\User $user
* @param User $user
* @return bool
*/
public function isOnlyAdmin(User $user)
@ -175,7 +174,7 @@ class UserRepo
* Create a new basic instance of user.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
* @return User
*/
public function create(array $data, $verifyEmail = false)
{
@ -189,7 +188,7 @@ class UserRepo
/**
* Remove the given user from storage, Delete all related content.
* @param \BookStack\Auth\User $user
* @param User $user
* @throws Exception
*/
public function destroy(User $user)
@ -206,7 +205,7 @@ class UserRepo
/**
* Get the latest activity for a user.
* @param \BookStack\Auth\User $user
* @param User $user
* @param int $count
* @param int $page
* @return array
@ -218,36 +217,35 @@ class UserRepo
/**
* Get the recently created content for this given user.
* @param \BookStack\Auth\User $user
* @param int $count
* @return mixed
*/
public function getRecentlyCreated(User $user, $count = 20)
public function getRecentlyCreated(User $user, int $count = 20): array
{
$createdByUserQuery = function (Builder $query) use ($user) {
$query->where('created_by', '=', $user->id);
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
/**
* Get asset created counts for the give user.
* @param \BookStack\Auth\User $user
* @return array
*/
public function getAssetCounts(User $user)
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
'books' => $this->entityRepo->getUserTotalCreated('book', $user),
'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
@ -260,16 +258,6 @@ class UserRepo
return $this->role->newQuery()->orderBy('name', 'asc')->get();
}
/**
* Get all the roles which can be given restricted access to
* other entities in the system.
* @return mixed
*/
public function getRestrictableRoles()
{
return $this->role->where('system_name', '!=', 'admin')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
@ -288,7 +276,7 @@ class UserRepo
$user->save();
return true;
} catch (Exception $e) {
\Log::error('Failed to save user avatar image');
Log::error('Failed to save user avatar image');
return false;
}
}

View File

@ -1,6 +1,11 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
/**
* Class Book
@ -9,21 +14,12 @@ use BookStack\Uploads\Image;
* @property Image|null $cover
* @package BookStack\Entities
*/
class Book extends Entity
class Book extends Entity implements HasCoverImage
{
public $searchFactor = 2;
protected $fillable = ['name', 'description', 'image_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Book';
}
/**
* Get the url for this book.
* @param string|bool $path
@ -52,7 +48,7 @@ class Book extends Entity
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
} catch (Exception $err) {
$cover = $default;
}
return $cover;
@ -60,16 +56,23 @@ class Book extends Entity
/**
* Get the cover image of the book
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover()
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_book';
}
/**
* Get all pages within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function pages()
{
@ -78,7 +81,7 @@ class Book extends Entity
/**
* Get the direct child pages of this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function directPages()
{
@ -87,7 +90,7 @@ class Book extends Entity
/**
* Get all chapters within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function chapters()
{
@ -96,13 +99,24 @@ class Book extends Entity
/**
* Get the shelves this book is contained within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
* @return BelongsToMany
*/
public function shelves()
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get the direct child items within this book.
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
return $pages->contact($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
@ -113,13 +127,4 @@ class Book extends Entity
$description = $this->description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
}

View File

@ -1,14 +1,31 @@
<?php namespace BookStack\Entities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class BookChild
* @property int $book_id
* @property int $priority
* @property Book $book
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
class BookChild extends Entity
{
/**
* Scope a query to find items where the the child has the given childSlug
* where its parent has the bookSlug.
*/
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
{
return $query->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $childSlug);
}
/**
* Get the book this page sits in.
* @return BelongsTo
@ -18,4 +35,26 @@ class BookChild extends Entity
return $this->belongsTo(Book::class);
}
}
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
{
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
// Update related activity
$this->activity()->update(['book_id' => $newBookId]);
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
}

View File

@ -1,8 +1,10 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity
class Bookshelf extends Entity implements HasCoverImage
{
protected $table = 'bookshelves';
@ -10,15 +12,6 @@ class Bookshelf extends Entity
protected $fillable = ['name', 'description', 'image_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Bookshelf';
}
/**
* Get the books in this shelf.
* Should not be used directly since does not take into account permissions.
@ -31,6 +24,14 @@ class Bookshelf extends Entity
->orderBy('order', 'asc');
}
/**
* Related books that are visible to the current user.
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
}
/**
* Get the url for this bookshelf.
* @param string|bool $path
@ -68,13 +69,20 @@ class Bookshelf extends Entity
/**
* Get the cover image of the shelf
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover()
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_shelf';
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
@ -86,21 +94,12 @@ class Bookshelf extends Entity
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
/**
* Check if this shelf contains the given book.
* @param Book $book
* @return bool
*/
public function contains(Book $book): bool
public function contains(Book $book): bool
{
return $this->books()->where('id', '=', $book->id)->count() > 0;
}
@ -111,11 +110,11 @@ class Bookshelf extends Entity
*/
public function appendBook(Book $book)
{
if ($this->contains($book)) {
return;
}
if ($this->contains($book)) {
return;
}
$maxOrder = $this->books()->max('order');
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
$maxOrder = $this->books()->max('order');
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
}

View File

@ -1,5 +1,6 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Managers\EntityContext;
use Illuminate\View\View;
class BreadcrumbsViewComposer
@ -9,9 +10,9 @@ class BreadcrumbsViewComposer
/**
* BreadcrumbsViewComposer constructor.
* @param EntityContextManager $entityContextManager
* @param EntityContext $entityContextManager
*/
public function __construct(EntityContextManager $entityContextManager)
public function __construct(EntityContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
}

View File

@ -1,22 +1,18 @@
<?php namespace BookStack\Entities;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Collection;
/**
* Class Chapter
* @property Collection<Page> $pages
* @package BookStack\Entities
*/
class Chapter extends BookChild
{
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Chapter';
}
/**
* Get the pages that this chapter contains.
* @param string $dir
@ -55,15 +51,6 @@ class Chapter extends BookChild
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
/**
* Check if this chapter has any child pages.
* @return bool
@ -72,4 +59,15 @@ class Chapter extends BookChild
{
return count($this->pages) > 0;
}
/**
* Get the visible pages in this chapter.
*/
public function getVisiblePages(): Collection
{
return $this->pages()->visible()
->orderBy('draft', 'desc')
->orderBy('priority', 'asc')
->get();
}
}

View File

@ -9,6 +9,8 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Facades\Permissions;
use BookStack\Ownable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
@ -24,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
* @property int $created_by
* @property int $updated_by
* @property boolean $restricted
* @property Collection $tags
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
*
* @package BookStack\Entities
*/
@ -41,14 +48,45 @@ class Entity extends Ownable
public $searchFactor = 1.0;
/**
* Get the morph class for this model.
* Set here since, due to folder changes, the namespace used
* in the database no longer matches the class namespace.
* @return string
* Get the entities that are visible to the current user.
*/
public function getMorphClass()
public function scopeVisible(Builder $query)
{
return 'BookStack\\Entity';
return $this->scopeHasPermission($query, 'view');
}
/**
* Scope the query to those entities that the current user has the given permission for.
*/
public function scopeHasPermission(Builder $query, string $permission)
{
return Permissions::restrictEntityQuery($query, $permission);
}
/**
* Query scope to get the last view from the current user.
*/
public function scopeWithLastView(Builder $query)
{
$viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())
->where('user_id', '=', user()->id)
->take(1);
return $query->addSelect(['last_viewed_at' => $viewedAtQuery]);
}
/**
* Query scope to get the total view count of the entities.
*/
public function scopeWithViewCount(Builder $query)
{
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())->take(1);
$query->addSelect(['view_count' => $viewCountQuery]);
}
/**
@ -88,7 +126,7 @@ class Entity extends Ownable
/**
* Gets the activity objects for this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function activity()
{
@ -106,7 +144,7 @@ class Entity extends Ownable
/**
* Get the Tag models that have been user assigned to this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function tags()
{
@ -126,7 +164,7 @@ class Entity extends Ownable
/**
* Get the related search terms.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function searchTerms()
{
@ -155,7 +193,7 @@ class Entity extends Ownable
/**
* Get the entity jointPermissions this is connected to.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function jointPermissions()
{
@ -182,14 +220,6 @@ class Entity extends Ownable
return strtolower(static::getClassName());
}
/**
* Get the type of this entity.
*/
public function type(): string
{
return static::getType();
}
/**
* Get an instance of an entity of the given type.
* @param $type
@ -242,15 +272,6 @@ class Entity extends Ownable
return trim($text);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return '';
}
/**
* Get the url of this entity
* @param $path
@ -270,6 +291,15 @@ class Entity extends Ownable
Permissions::buildJointPermissionsForEntity($this);
}
/**
* Index the current entity for search
*/
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
$searchService->indexEntity($this);
}
/**
* Generate and set a new URL slug for this model.
*/

View File

@ -39,11 +39,6 @@ class EntityProvider
/**
* EntityProvider constructor.
* @param Bookshelf $bookshelf
* @param Book $book
* @param Chapter $chapter
* @param Page $page
* @param PageRevision $pageRevision
*/
public function __construct(
Bookshelf $bookshelf,
@ -62,9 +57,8 @@ class EntityProvider
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
* @return Entity[]
*/
public function all()
public function all(): array
{
return [
'bookshelf' => $this->bookshelf,
@ -76,10 +70,8 @@ class EntityProvider
/**
* Get an entity instance by it's basic name.
* @param string $type
* @return Entity
*/
public function get(string $type)
public function get(string $type): Entity
{
$type = strtolower($type);
return $this->all()[$type];
@ -87,15 +79,9 @@ class EntityProvider
/**
* Get the morph classes, as an array, for a single or multiple types.
* @param string|array $types
* @return array<string>
*/
public function getMorphClasses($types)
public function getMorphClasses(array $types): array
{
if (is_string($types)) {
$types = [$types];
}
$morphClasses = [];
foreach ($types as $type) {
$model = $this->get($type);

View File

@ -1,35 +1,34 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\PageContent;
use BookStack\Uploads\ImageService;
use DomPDF;
use Exception;
use SnappyPDF;
use Throwable;
class ExportService
{
protected $entityRepo;
protected $imageService;
/**
* ExportService constructor.
* @param EntityRepo $entityRepo
* @param ImageService $imageService
*/
public function __construct(EntityRepo $entityRepo, ImageService $imageService)
public function __construct(ImageService $imageService)
{
$this->entityRepo = $entityRepo;
$this->imageService = $imageService;
}
/**
* Convert a page to a self-contained HTML file.
* Includes required CSS & image content. Images are base64 encoded into the HTML.
* @param \BookStack\Entities\Page $page
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function pageToContainedHtml(Page $page)
{
$this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages/export', [
'page' => $page
])->render();
@ -38,15 +37,13 @@ class ExportService
/**
* Convert a chapter to a self-contained HTML file.
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
{
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
$page->html = $this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
});
$html = view('chapters/export', [
'chapter' => $chapter,
@ -57,13 +54,11 @@ class ExportService
/**
* Convert a book to a self-contained HTML file.
* @param Book $book
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function bookToContainedHtml(Book $book)
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree
@ -73,13 +68,11 @@ class ExportService
/**
* Convert a page to a PDF file.
* @param Page $page
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function pageToPdf(Page $page)
{
$this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
$html = view('pages/pdf', [
'page' => $page
])->render();
@ -88,32 +81,30 @@ class ExportService
/**
* Convert a chapter to a PDF file.
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function chapterToPdf(Chapter $chapter)
{
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
$page->html = $this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
});
$html = view('chapters/export', [
'chapter' => $chapter,
'pages' => $pages
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a book to a PDF file
* @param \BookStack\Entities\Book $book
* @return string
* @throws \Throwable
* Convert a book to a PDF file.
* @throws Throwable
*/
public function bookToPdf(Book $book)
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree
@ -122,31 +113,27 @@ class ExportService
}
/**
* Convert normal webpage HTML to a PDF.
* @param $html
* @return string
* @throws \Exception
* Convert normal web-page HTML to a PDF.
* @throws Exception
*/
protected function htmlToPdf($html)
protected function htmlToPdf(string $html): string
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false;
if ($useWKHTML) {
$pdf = \SnappyPDF::loadHTML($containedHtml);
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = \DomPDF::loadHTML($containedHtml);
$pdf = DomPDF::loadHTML($containedHtml);
}
return $pdf->output();
}
/**
* Bundle of the contents of a html file to be self-contained.
* @param $htmlContent
* @return mixed|string
* @throws \Exception
* @throws Exception
*/
protected function containHtml($htmlContent)
protected function containHtml(string $htmlContent): string
{
$imageTagsOutput = [];
preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
@ -188,12 +175,10 @@ class ExportService
/**
* Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output.
* @param Page $page
* @return mixed
*/
public function pageToPlainText(Page $page)
public function pageToPlainText(Page $page): string
{
$html = $this->entityRepo->renderPage($page);
$html = (new PageContent($page))->render();
$text = strip_tags($html);
// Replace multiple spaces with single spaces
$text = preg_replace('/\ {2,}/', ' ', $text);
@ -207,10 +192,8 @@ class ExportService
/**
* Convert a chapter into a plain text string.
* @param \BookStack\Entities\Chapter $chapter
* @return string
*/
public function chapterToPlainText(Chapter $chapter)
public function chapterToPlainText(Chapter $chapter): string
{
$text = $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
@ -222,12 +205,10 @@ class ExportService
/**
* Convert a book into a plain text string.
* @param Book $book
* @return string
*/
public function bookToPlainText(Book $book)
public function bookToPlainText(Book $book): string
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild->isA('chapter')) {

View File

@ -0,0 +1,20 @@
<?php
namespace BookStack\Entities;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage
{
/**
* Get the cover image for this item.
*/
public function cover(): BelongsTo;
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string;
}

View File

@ -0,0 +1,204 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Book;
use BookStack\Entities\BookChild;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
class BookContents
{
/**
* @var Book
*/
protected $book;
/**
* BookContents constructor.
* @param $book
*/
public function __construct(Book $book)
{
$this->book = $book;
}
/**
* Get the current priority of the last item
* at the top-level of the book.
*/
public function getLastPriority(): int
{
$maxPage = Page::visible()->where('book_id', '=', $this->book->id)
->where('draft', '=', false)
->where('chapter_id', '=', 0)->max('priority');
$maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
->max('priority');
return max($maxChapter, $maxPage, 1);
}
/**
* Get the contents as a sorted collection tree.
* TODO - Support $renderPages option
*/
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
{
$pages = $this->getPages($showDrafts);
$chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
$all = collect()->concat($pages)->concat($chapters);
$chapterMap = $chapters->keyBy('id');
$lonePages = collect();
$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()));
} else {
$lonePages = $lonePages->concat($pages);
}
});
$all->each(function (Entity $entity) {
$entity->setRelation('book', $this->book);
});
return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
}
/**
* Function for providing a sorting score for an entity in relation to the
* other items within the book.
*/
protected function bookChildSortFunc(): callable
{
return function (Entity $entity) {
if (isset($entity['draft']) && $entity['draft']) {
return -100;
}
return $entity['priority'] ?? 0;
};
}
/**
* Get the visible pages within this book.
*/
protected function getPages(bool $showDrafts = false): Collection
{
$query = Page::visible()->where('book_id', '=', $this->book->id);
if (!$showDrafts) {
$query->where('draft', '=', false);
}
return $query->get();
}
/**
* Sort the books content using the given map.
* The map is a single-dimension collection of objects in the following format:
* {
* +"id": "294" (ID of item)
* +"sort": 1 (Sort order index)
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }
*
* Returns a list of books that were involved in the operation.
* @throws SortOperationException
*/
public function sortUsingMap(Collection $sortMap): Collection
{
// Load models into map
$this->loadModelsIntoSortMap($sortMap);
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
// Perform the sort
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
$book->rebuildPermissions();
});
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required.
*/
protected function applySortUpdates(\stdClass $sortMapItem)
{
/** @var BookChild $model */
$model = $sortMapItem->model;
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
if ($bookChanged) {
$model->changeBook($sortMapItem->book);
}
if ($chapterChanged) {
$model->chapter_id = intval($sortMapItem->parentChapter);
$model->save();
}
if ($priorityChanged) {
$model->priority = intval($sortMapItem->sort);
$model->save();
}
}
/**
* Load models from the database into the given sort map.
*/
protected function loadModelsIntoSortMap(Collection $sortMap): void
{
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
return $sortMapItem->type . ':' . $sortMapItem->id;
});
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
$pages = Page::visible()->whereIn('id', $pageIds)->get();
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
foreach ($pages as $page) {
$sortItem = $keyMap->get('page:' . $page->id);
$sortItem->model = $page;
}
foreach ($chapters as $chapter) {
$sortItem = $keyMap->get('chapter:' . $chapter->id);
$sortItem->model = $chapter;
}
}
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
* @throws SortOperationException
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
{
$bookIdsInvolved = collect([$this->book->id]);
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
if (count($books) !== count($bookIdsInvolved)) {
throw new SortOperationException("Could not find all books requested in sort operation");
}
return $books;
}
}

View File

@ -1,44 +1,38 @@
<?php namespace BookStack\Entities;
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use Illuminate\Session\Store;
class EntityContextManager
class EntityContext
{
protected $session;
protected $entityRepo;
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
/**
* EntityContextManager constructor.
* @param Store $session
* @param EntityRepo $entityRepo
*/
public function __construct(Store $session, EntityRepo $entityRepo)
public function __construct(Store $session)
{
$this->session = $session;
$this->entityRepo = $entityRepo;
}
/**
* Get the current bookshelf context for the given book.
* @param Book $book
* @return Bookshelf|null
*/
public function getContextualShelfForBook(Book $book)
public function getContextualShelfForBook(Book $book): ?Bookshelf
{
$contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
if (is_int($contextBookshelfId)) {
/** @var Bookshelf $shelf */
$shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
if ($shelf && $shelf->contains($book)) {
return $shelf;
}
if (!is_int($contextBookshelfId)) {
return null;
}
return null;
$shelf = Bookshelf::visible()->find($contextBookshelfId);
$shelfContainsBook = $shelf && $shelf->contains($book);
return $shelfContainsBook ? $shelf : null;
}
/**

View File

@ -0,0 +1,304 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Page;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class PageContent
{
protected $page;
/**
* PageContent constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
}
/**
* Update the content of the page with new provided HTML.
*/
public function setNewHTML(string $html)
{
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
/**
* Formats a page's html to be tagged correctly within the system.
*/
protected function formatHtml(string $htmlText): string
{
if ($htmlText == '') {
return $htmlText;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* 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
*/
protected function setUniqueId($element, array &$idMap)
{
if (get_class($element) !== 'DOMElement') {
return;
}
// Overwrite id if not a BookStack custom id
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
}
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
}
/**
* Get a plain-text visualisation of this page.
*/
protected function toPlainText(): string
{
$html = $this->render(true);
return strip_tags($html);
}
/**
* Render the page for viewing
*/
public function render(bool $blankIncludes = false) : string
{
$content = $this->page->html;
if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
$content = $this->parsePageIncludes($content);
}
return $content;
}
/**
* Parse the headers on the page to get a navigation menu
*/
public function getNavigation(string $htmlContent): array
{
if (empty($htmlContent)) {
return [];
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
return $headers ? $this->headerNodesToLevelList($headers) : [];
}
/**
* Convert a DOMNodeList into an array of readable header attributes
* with levels normalised to the lower header level.
*/
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function ($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => $text,
];
})->filter(function ($header) {
return mb_strlen($header['text']) > 0;
});
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
return $tree->toArray();
}
/**
* Remove any page include tags within the given HTML.
*/
protected function blankPageIncludes(string $html) : string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
*/
protected function parsePageIncludes(string $html) : string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
foreach ($matches[1] as $index => $includeId) {
$fullMatch = $matches[0][$index];
$splitInclude = explode('#', $includeId, 2);
// Get page id from reference
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
// Find page and skip this if page not found
$matchedPage = Page::visible()->find($pageId);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
continue;
}
// If we only have page id, just insert all page html and continue.
if (count($splitInclude) === 1) {
$html = str_replace($fullMatch, $matchedPage->html, $html);
continue;
}
// Create and load HTML into a document
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$html = str_replace($fullMatch, trim($innerContent), $html);
}
return $html;
}
/**
* Fetch the content from a specific section of the given page.
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol'];
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
// Search included content for the id given and blank out if not exists.
$matchingElem = $doc->getElementById($sectionId);
if ($matchingElem === null) {
return '';
}
// Otherwise replace the content with the found content
// Checks if the top-level wrapper should be included by matching on tag types
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
return $innerContent;
}
/**
* Escape script tags within HTML content.
*/
protected function escapeScripts(string $html) : string
{
if (empty($html)) {
return $html;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
foreach ($scriptElems as $scriptElem) {
$scriptElem->parentNode->removeChild($scriptElem);
}
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
$badIframe->parentNode->removeChild($badIframe);
}
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
foreach ($onAttributes as $attr) {
/** @var \DOMAttr $attr*/
$attrName = $attr->nodeName;
$attr->parentNode->removeAttribute($attrName);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
}
}

View File

@ -0,0 +1,74 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Page;
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected $page;
/**
* PageEditActivity constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
}
/**
* Check if there's active editing being performed on this page.
* @return bool
*/
public function hasActiveEditing(): bool
{
return $this->activePageEditingQuery(60)->count() > 0;
}
/**
* Get a notification message concerning the editing activity on the page.
*/
public function activeEditingMessage(): string
{
$pageDraftEdits = $this->activePageEditingQuery(60)->get();
$count = $pageDraftEdits->count();
$userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Get the message to show when the user will be editing one of their drafts.
* @param PageRevision $draft
* @return string
*/
public function getEditingActiveDraftMessage(PageRevision $draft): string
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}
/**
* A query to check for active update drafts on a particular page
* within the last given many minutes.
*/
protected function activePageEditingQuery(int $withinMinutes): Builder
{
$checkTime = Carbon::now()->subMinutes($withinMinutes);
$query = PageRevision::query()
->where('type', '=', 'update_draft')
->where('page_id', '=', $this->page->id)
->where('updated_at', '>', $this->page->updated_at)
->where('created_by', '!=', user()->id)
->where('updated_at', '>=', $checkTime)
->with('createdBy');
return $query;
}
}

View File

@ -0,0 +1,109 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\HasCoverImage;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
class TrashCan
{
/**
* Remove a bookshelf from the system.
* @throws Exception
*/
public function destroyShelf(Bookshelf $shelf)
{
$this->destroyCommonRelations($shelf);
$shelf->delete();
}
/**
* Remove a book from the system.
* @throws NotifyException
* @throws BindingResolutionException
*/
public function destroyBook(Book $book)
{
foreach ($book->pages as $page) {
$this->destroyPage($page);
}
foreach ($book->chapters as $chapter) {
$this->destroyChapter($chapter);
}
$this->destroyCommonRelations($book);
$book->delete();
}
/**
* Remove a page from the system.
* @throws NotifyException
*/
public function destroyPage(Page $page)
{
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
setting()->remove('app-homepage');
}
$this->destroyCommonRelations($page);
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Remove a chapter from the system.
* @throws Exception
*/
public function destroyChapter(Chapter $chapter)
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
}
$this->destroyCommonRelations($chapter);
$chapter->delete();
}
/**
* Update entity relations to remove or update outstanding connections.
*/
protected function destroyCommonRelations(Entity $entity)
{
Activity::removeEntity($entity);
$entity->views()->delete();
$entity->permissions()->delete();
$entity->tags()->delete();
$entity->comments()->delete();
$entity->jointPermissions()->delete();
$entity->searchTerms()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
$imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover);
}
}
}

View File

@ -1,7 +1,24 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
/**
* Class Page
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
*/
class Page extends BookChild
{
protected $fillable = ['name', 'html', 'priority', 'markdown'];
@ -11,12 +28,12 @@ class Page extends BookChild
public $textField = 'text';
/**
* Get the morph class for this model.
* @return string
* Get the entities that are visible to the current user.
*/
public function getMorphClass()
public function scopeVisible(Builder $query)
{
return 'BookStack\\Page';
$query = Permissions::enforceDraftVisiblityOnQuery($query);
return parent::scopeVisible($query);
}
/**
@ -32,16 +49,15 @@ class Page extends BookChild
/**
* Get the parent item
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function parent()
public function parent(): Entity
{
return $this->chapter_id ? $this->chapter() : $this->book();
return $this->chapter_id ? $this->chapter : $this->book;
}
/**
* Get the chapter that this page is in, If applicable.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
* @return BelongsTo
*/
public function chapter()
{
@ -63,12 +79,12 @@ class Page extends BookChild
*/
public function revisions()
{
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function attachments()
{
@ -86,27 +102,17 @@ class Page extends BookChild
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
$url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
if ($path !== false) {
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
$url .= '/' . trim($path, '/');
}
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @param bool $withContent
* @return string
*/
public function entityRawQuery($withContent = false)
{
$htmlQuery = $withContent ? 'html' : "'' as html";
return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
return url($url);
}
/**
* Get the current revision for the page if existing
* @return \BookStack\Entities\PageRevision|null
* @return PageRevision|null
*/
public function getCurrentRevision()
{

View File

@ -2,7 +2,21 @@
use BookStack\Auth\User;
use BookStack\Model;
use Carbon\Carbon;
/**
* Class PageRevision
* @property int $page_id
* @property string $slug
* @property string $book_slug
* @property int $created_by
* @property Carbon $created_at
* @property string $type
* @property string $summary
* @property string $markdown
* @property string $html
* @property int $revision_number
*/
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
@ -41,13 +55,18 @@ class PageRevision extends Model
/**
* Get the previous revision for the same page if existing
* @return \BookStack\PageRevision|null
* @return \BookStack\Entities\PageRevision|null
*/
public function getPrevious()
{
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)
->max('id');
if ($id) {
return static::query()->find($id);
}
return null;
}

View File

@ -0,0 +1,118 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\TagRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
use BookStack\Entities\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
/**
* BaseRepo constructor.
* @param $tagRepo
*/
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
}
/**
* Create a new entity in the system
*/
public function create(Entity $entity, array $input)
{
$entity->fill($input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
]);
$entity->refreshSlug();
$entity->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->rebuildPermissions();
$entity->indexForSearch();
}
/**
* Update the given entity.
*/
public function update(Entity $entity, array $input)
{
$entity->fill($input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name')) {
$entity->refreshSlug();
}
$entity->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->rebuildPermissions();
$entity->indexForSearch();
}
/**
* Update the given items' cover image, or clear it.
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage(HasCoverImage $entity, UploadedFile $coverImage = null, bool $removeImage = false)
{
if ($coverImage) {
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
$entity->cover()->associate($image);
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover);
$entity->image_id = 0;
$entity->save();
}
}
/**
* Update the permissions of an entity.
*/
public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null)
{
$entity->restricted = $restricted;
$entity->permissions()->delete();
if (!is_null($permissions)) {
$entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) {
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
return [
'role_id' => $roleId,
'action' => strtolower($action),
] ;
});
});
$entity->permissions()->createMany($entityPermissionData);
}
$entity->save();
$entity->rebuildPermissions();
}
}

View File

@ -1,46 +1,134 @@
<?php
namespace BookStack\Entities\Repos;
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\TagRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookRepo extends EntityRepo
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
/**
* Fetch a book by its slug.
* @param string $slug
* @return Book
* @throws NotFoundException
* BookRepo constructor.
* @param $tagRepo
*/
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->baseRepo = $baseRepo;
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
}
/**
* Get all books in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Book::visible()->orderBy($sort, $order)->paginate($count);
}
/**
* Get the books that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Book::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular books in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Book::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created books from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Book::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a book by its slug.
*/
public function getBySlug(string $slug): Book
{
/** @var Book $book */
$book = $this->getEntityBySlug('book', $slug);
$book = Book::visible()->where('slug', '=', $slug)->first();
if ($book === null) {
throw new NotFoundException(trans('errors.book_not_found'));
}
return $book;
}
/**
* Destroy the provided book and all its child entities.
* @param Book $book
* @throws NotifyException
* @throws \Throwable
* Create a new book in the system
*/
public function destroyBook(Book $book)
public function create(array $input): Book
{
foreach ($book->pages as $page) {
$this->destroyPage($page);
}
foreach ($book->chapters as $chapter) {
$this->destroyChapter($chapter);
}
$this->destroyEntityCommonRelations($book);
$book->delete();
$book = new Book();
$this->baseRepo->create($book, $input);
return $book;
}
}
/**
* Update the given book.
*/
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
return $book;
}
/**
* Update the given book's cover image, or clear it.
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Book $book, UploadedFile $coverImage = null, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
}
/**
* Update the permissions of a book.
*/
public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($book, $restricted, $permissions);
}
/**
* Remove a book from the system.
* @throws NotifyException
* @throws BindingResolutionException
*/
public function destroy(Book $book)
{
$trashCan = new TrashCan();
$trashCan->destroyBook($book);
}
}

View File

@ -0,0 +1,173 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookshelfRepo
{
protected $baseRepo;
/**
* BookshelfRepo constructor.
* @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
}
/**
* Get all bookshelves in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Bookshelf::visible()->with('visibleBooks')
->orderBy($sort, $order)->paginate($count);
}
/**
* Get the bookshelves that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Bookshelf::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular bookshelves in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Bookshelf::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created bookshelves from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Bookshelf::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a shelf by its slug.
*/
public function getBySlug(string $slug): Bookshelf
{
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
if ($shelf === null) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
/**
* Create a new shelf in the system.
*/
public function create(array $input, array $bookIds): Bookshelf
{
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
return $shelf;
}
/**
* Create a new shelf in the system.
*/
public function update(Bookshelf $shelf, array $input, array $bookIds): Bookshelf
{
$this->baseRepo->update($shelf, $input);
$this->updateBooks($shelf, $bookIds);
return $shelf;
}
/**
* Update which books are assigned to this shelf by
* syncing the given book ids.
* Function ensures the books are visible to the current user and existing.
*/
protected function updateBooks(Bookshelf $shelf, array $bookIds)
{
$numericIDs = collect($bookIds)->map(function ($id) {
return intval($id);
});
$syncData = Book::visible()
->whereIn('id', $bookIds)
->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
return [$bookId => ['order' => $numericIDs->search($bookId)]];
});
$shelf->books()->sync($syncData);
}
/**
* Update the given shelf cover image, or clear it.
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Bookshelf $shelf, UploadedFile $coverImage = null, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
}
/**
* Update the permissions of a bookshelf.
*/
public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($shelf, $restricted, $permissions);
}
/**
* Copy down the permissions of the given shelf to all child books.
*/
public function copyDownPermissions(Bookshelf $shelf): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $shelf->books()->get();
$updatedBookCount = 0;
/** @var Book $book */
foreach ($shelfBooks as $book) {
if (!userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->restricted = $shelf->restricted;
$book->permissions()->createMany($shelfPermissions);
$book->save();
$book->rebuildPermissions();
$updatedBookCount++;
}
return $updatedBookCount;
}
/**
* Remove a bookshelf from the system.
* @throws Exception
*/
public function destroy(Bookshelf $shelf)
{
$trashCan = new TrashCan();
$trashCan->destroyShelf($shelf);
}
}

View File

@ -0,0 +1,108 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class ChapterRepo
{
protected $baseRepo;
/**
* ChapterRepo constructor.
* @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
}
/**
* Get a chapter via the slug.
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
{
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
if ($chapter === null) {
throw new NotFoundException(trans('errors.chapter_not_found'));
}
return $chapter;
}
/**
* Create a new chapter in the system.
*/
public function create(array $input, Book $parentBook): Chapter
{
$chapter = new Chapter();
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
return $chapter;
}
/**
* Update the given chapter.
*/
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
return $chapter;
}
/**
* Update the permissions of a chapter.
*/
public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($chapter, $restricted, $permissions);
}
/**
* Remove a chapter from the system.
* @throws Exception
*/
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
$trashCan->destroyChapter($chapter);
}
/**
* Move the given chapter into a new parent book.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* @throws MoveOperationException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$stringExploded = explode(':', $parentIdentifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be moved into books');
}
$parent = Book::visible()->where('id', '=', $entityId)->first();
if ($parent === null) {
throw new MoveOperationException('Book to move chapter into not found');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
return $parent;
}
}

View File

@ -1,843 +0,0 @@
<?php namespace BookStack\Entities\Repos;
use Activity;
use BookStack\Actions\TagRepo;
use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Book;
use BookStack\Entities\BookChild;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Page;
use BookStack\Entities\SearchService;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\AttachmentService;
use DOMDocument;
use DOMXPath;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Throwable;
class EntityRepo
{
/**
* @var EntityProvider
*/
protected $entityProvider;
/**
* @var PermissionService
*/
protected $permissionService;
/**
* @var ViewService
*/
protected $viewService;
/**
* @var TagRepo
*/
protected $tagRepo;
/**
* @var SearchService
*/
protected $searchService;
/**
* EntityRepo constructor.
* @param EntityProvider $entityProvider
* @param ViewService $viewService
* @param PermissionService $permissionService
* @param TagRepo $tagRepo
* @param SearchService $searchService
*/
public function __construct(
EntityProvider $entityProvider,
ViewService $viewService,
PermissionService $permissionService,
TagRepo $tagRepo,
SearchService $searchService
) {
$this->entityProvider = $entityProvider;
$this->viewService = $viewService;
$this->permissionService = $permissionService;
$this->tagRepo = $tagRepo;
$this->searchService = $searchService;
}
/**
* Base query for searching entities via permission system
* @param string $type
* @param bool $allowDrafts
* @param string $permission
* @return QueryBuilder
*/
protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
{
$q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
if (strtolower($type) === 'page' && !$allowDrafts) {
$q = $q->where('draft', '=', false);
}
return $q;
}
/**
* Check if an entity with the given id exists.
* @param $type
* @param $id
* @return bool
*/
public function exists($type, $id)
{
return $this->entityQuery($type)->where('id', '=', $id)->exists();
}
/**
* Get an entity by ID
* @param string $type
* @param integer $id
* @param bool $allowDrafts
* @param bool $ignorePermissions
* @return Entity
*/
public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
{
$query = $this->entityQuery($type, $allowDrafts);
if ($ignorePermissions) {
$query = $this->entityProvider->get($type)->newQuery();
}
return $query->find($id);
}
/**
* @param string $type
* @param []int $ids
* @param bool $allowDrafts
* @param bool $ignorePermissions
* @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
*/
public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
{
$query = $this->entityQuery($type, $allowDrafts);
if ($ignorePermissions) {
$query = $this->entityProvider->get($type)->newQuery();
}
return $query->whereIn('id', $ids)->get();
}
/**
* Get an entity by its url slug.
* @param string $type
* @param string $slug
* @param string|null $bookSlug
* @return Entity
* @throws NotFoundException
*/
public function getEntityBySlug(string $type, string $slug, string $bookSlug = null): Entity
{
$type = strtolower($type);
$query = $this->entityQuery($type)->where('slug', '=', $slug);
if ($type === 'chapter' || $type === 'page') {
$query = $query->where('book_id', '=', function (QueryBuilder $query) use ($bookSlug) {
$query->select('id')
->from($this->entityProvider->book->getTable())
->where('slug', '=', $bookSlug)->limit(1);
});
}
$entity = $query->first();
if ($entity === null) {
throw new NotFoundException(trans('errors.' . $type . '_not_found'));
}
return $entity;
}
/**
* Get all entities of a type with the given permission, limited by count unless count is false.
* @param string $type
* @param integer|bool $count
* @param string $permission
* @return Collection
*/
public function getAll($type, $count = 20, $permission = 'view')
{
$q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
if ($count !== false) {
$q = $q->take($count);
}
return $q->get();
}
/**
* Get all entities in a paginated format
* @param $type
* @param int $count
* @param string $sort
* @param string $order
* @param null|callable $queryAddition
* @return LengthAwarePaginator
*/
public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
{
$query = $this->entityQuery($type);
$query = $this->addSortToQuery($query, $sort, $order);
if ($queryAddition) {
$queryAddition($query);
}
return $query->paginate($count);
}
/**
* Add sorting operations to an entity query.
* @param Builder $query
* @param string $sort
* @param string $order
* @return Builder
*/
protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
{
$order = ($order === 'asc') ? 'asc' : 'desc';
$propertySorts = ['name', 'created_at', 'updated_at'];
if (in_array($sort, $propertySorts)) {
return $query->orderBy($sort, $order);
}
return $query;
}
/**
* Get the most recently created entities of the given type.
* @param string $type
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
* @return Collection
*/
public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
->orderBy('created_at', 'desc');
if (strtolower($type) === 'page') {
$query = $query->where('draft', '=', false);
}
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
}
return $query->skip($page * $count)->take($count)->get();
}
/**
* Get the most recently updated entities of the given type.
* @param string $type
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
* @return Collection
*/
public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
->orderBy('updated_at', 'desc');
if (strtolower($type) === 'page') {
$query = $query->where('draft', '=', false);
}
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
}
return $query->skip($page * $count)->take($count)->get();
}
/**
* Get the most recently viewed entities.
* @param string|bool $type
* @param int $count
* @param int $page
* @return mixed
*/
public function getRecentlyViewed($type, $count = 10, $page = 0)
{
$filter = is_bool($type) ? false : $this->entityProvider->get($type);
return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
}
/**
* Get the latest pages added to the system with pagination.
* @param string $type
* @param int $count
* @return mixed
*/
public function getRecentlyCreatedPaginated($type, $count = 20)
{
return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
}
/**
* Get the latest pages added to the system with pagination.
* @param string $type
* @param int $count
* @return mixed
*/
public function getRecentlyUpdatedPaginated($type, $count = 20)
{
return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
}
/**
* Get the most popular entities base on all views.
* @param string $type
* @param int $count
* @param int $page
* @return mixed
*/
public function getPopular(string $type, int $count = 10, int $page = 0)
{
return $this->viewService->getPopular($count, $page, $type);
}
/**
* Get draft pages owned by the current user.
* @param int $count
* @param int $page
* @return Collection
*/
public function getUserDraftPages($count = 20, $page = 0)
{
return $this->entityProvider->page->where('draft', '=', true)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get();
}
/**
* Get the number of entities the given user has created.
* @param string $type
* @param User $user
* @return int
*/
public function getUserTotalCreated(string $type, User $user)
{
return $this->entityProvider->get($type)
->where('created_by', '=', $user->id)->count();
}
/**
* Get the child items for a chapter sorted by priority but
* with draft items floated to the top.
* @param Bookshelf $bookshelf
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getBookshelfChildren(Bookshelf $bookshelf)
{
return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
}
/**
* Get the direct children of a book.
* @param Book $book
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBookDirectChildren(Book $book)
{
$pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
$chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get all child objects of a book.
* Returns a sorted collection of Pages and Chapters.
* Loads the book slug onto child elements to prevent access database access for getting the slug.
* @param Book $book
* @param bool $filterDrafts
* @param bool $renderPages
* @return mixed
*/
public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
{
$q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
$entities = [];
$parents = [];
$tree = [];
foreach ($q as $index => $rawEntity) {
if ($rawEntity->entity_type === $this->entityProvider->page->getMorphClass()) {
$entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
if ($renderPages) {
$entities[$index]->html = $rawEntity->html;
$entities[$index]->html = $this->renderPage($entities[$index]);
};
} else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
$entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
$key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
$parents[$key] = $entities[$index];
$parents[$key]->setAttribute('pages', collect());
}
if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
$tree[] = $entities[$index];
}
$entities[$index]->book = $book;
}
foreach ($entities as $entity) {
if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
continue;
}
$parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
if (!isset($parents[$parentKey])) {
$tree[] = $entity;
continue;
}
$chapter = $parents[$parentKey];
$chapter->pages->push($entity);
}
return collect($tree);
}
/**
* Get the child items for a chapter sorted by priority but
* with draft items floated to the top.
* @param Chapter $chapter
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getChapterChildren(Chapter $chapter)
{
return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
}
/**
* Get the next sequential priority for a new child element in the given book.
* @param Book $book
* @return int
*/
public function getNewBookPriority(Book $book)
{
$lastElem = $this->getBookChildren($book)->pop();
return $lastElem ? $lastElem->priority + 1 : 0;
}
/**
* Get a new priority for a new page to be added to the given chapter.
* @param Chapter $chapter
* @return int
*/
public function getNewChapterPriority(Chapter $chapter)
{
$lastPage = $chapter->pages('DESC')->first();
return $lastPage !== null ? $lastPage->priority + 1 : 0;
}
/**
* Find a suitable slug for an entity.
* @param string $type
* @param string $name
* @param bool|integer $currentId
* @param bool|integer $bookId Only pass if type is not a book
* @return string
*/
public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
{
$slug = $this->nameToSlug($name);
while ($this->slugExists($type, $slug, $currentId, $bookId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Updates entity restrictions from a request
* @param Request $request
* @param Entity $entity
* @throws Throwable
*/
public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
{
$entity->restricted = $request->get('restricted', '') === 'true';
$entity->permissions()->delete();
if ($request->filled('restrictions')) {
$entityPermissionData = collect($request->get('restrictions'))->flatMap(function($restrictions, $roleId) {
return collect($restrictions)->keys()->map(function($action) use ($roleId) {
return [
'role_id' => $roleId,
'action' => strtolower($action),
] ;
});
});
$entity->permissions()->createMany($entityPermissionData);
}
$entity->save();
$entity->rebuildPermissions();
}
/**
* Create a new entity from request input.
* Used for books and chapters.
* @param string $type
* @param array $input
* @param Book|null $book
* @return Entity
*/
public function createFromInput(string $type, array $input = [], Book $book = null)
{
$entityModel = $this->entityProvider->get($type)->newInstance($input);
$entityModel->created_by = user()->id;
$entityModel->updated_by = user()->id;
if ($book) {
$entityModel->book_id = $book->id;
}
$entityModel->refreshSlug();
$entityModel->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
}
$entityModel->rebuildPermissions();
$this->searchService->indexEntity($entityModel);
return $entityModel;
}
/**
* Update entity details from request input.
* Used for shelves, books and chapters.
*/
public function updateFromInput(Entity $entityModel, array $input): Entity
{
$entityModel->fill($input);
$entityModel->updated_by = user()->id;
if ($entityModel->isDirty('name')) {
$entityModel->refreshSlug();
}
$entityModel->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
}
$entityModel->rebuildPermissions();
$this->searchService->indexEntity($entityModel);
return $entityModel;
}
/**
* Sync the books assigned to a shelf from a comma-separated list
* of book IDs.
* @param Bookshelf $shelf
* @param string $books
*/
public function updateShelfBooks(Bookshelf $shelf, string $books)
{
$ids = explode(',', $books);
// Check books exist and match ordering
$bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
$syncData = [];
foreach ($ids as $index => $id) {
if ($bookIds->contains($id)) {
$syncData[$id] = ['order' => $index];
}
}
$shelf->books()->sync($syncData);
}
/**
* Change the book that an entity belongs to.
*/
public function changeBook(BookChild $bookChild, int $newBookId): Entity
{
$bookChild->book_id = $newBookId;
$bookChild->refreshSlug();
$bookChild->save();
// Update related activity
$bookChild->activity()->update(['book_id' => $newBookId]);
// Update all child pages if a chapter
if ($bookChild->isA('chapter')) {
foreach ($bookChild->pages as $page) {
$this->changeBook($page, $newBookId);
}
}
return $bookChild;
}
/**
* Render the page for viewing
* @param Page $page
* @param bool $blankIncludes
* @return string
*/
public function renderPage(Page $page, bool $blankIncludes = false) : string
{
$content = $page->html;
if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
$content = $this->parsePageIncludes($content);
}
return $content;
}
/**
* Remove any page include tags within the given HTML.
* @param string $html
* @return string
*/
protected function blankPageIncludes(string $html) : string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
* @param string $html
* @return mixed|string
*/
protected function parsePageIncludes(string $html) : string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
$topLevelTags = ['table', 'ul', 'ol'];
foreach ($matches[1] as $index => $includeId) {
$splitInclude = explode('#', $includeId, 2);
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
$matchedPage = $this->getById('page', $pageId);
if ($matchedPage === null) {
$html = str_replace($matches[0][$index], '', $html);
continue;
}
if (count($splitInclude) === 1) {
$html = str_replace($matches[0][$index], $matchedPage->html, $html);
continue;
}
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
$matchingElem = $doc->getElementById($splitInclude[1]);
if ($matchingElem === null) {
$html = str_replace($matches[0][$index], '', $html);
continue;
}
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
$html = str_replace($matches[0][$index], trim($innerContent), $html);
}
return $html;
}
/**
* Escape script tags within HTML content.
* @param string $html
* @return string
*/
protected function escapeScripts(string $html) : string
{
if ($html == '') {
return $html;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
foreach ($scriptElems as $scriptElem) {
$scriptElem->parentNode->removeChild($scriptElem);
}
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
$badIframe->parentNode->removeChild($badIframe);
}
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
foreach ($onAttributes as $attr) {
/** @var \DOMAttr $attr*/
$attrName = $attr->nodeName;
$attr->parentNode->removeAttribute($attrName);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
}
/**
* Search for image usage within page content.
* @param $imageString
* @return mixed
*/
public function searchForImage($imageString)
{
$pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
}
return count($pages) > 0 ? $pages : false;
}
/**
* Destroy a bookshelf instance
* @param Bookshelf $shelf
* @throws Throwable
*/
public function destroyBookshelf(Bookshelf $shelf)
{
$this->destroyEntityCommonRelations($shelf);
$shelf->delete();
}
/**
* Destroy a chapter and its relations.
* @param Chapter $chapter
* @throws Throwable
*/
public function destroyChapter(Chapter $chapter)
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
}
$this->destroyEntityCommonRelations($chapter);
$chapter->delete();
}
/**
* Destroy a given page along with its dependencies.
* @param Page $page
* @throws NotifyException
* @throws Throwable
*/
public function destroyPage(Page $page)
{
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
setting()->remove('app-homepage');
}
$this->destroyEntityCommonRelations($page);
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Destroy or handle the common relations connected to an entity.
* @param Entity $entity
* @throws Throwable
*/
protected function destroyEntityCommonRelations(Entity $entity)
{
Activity::removeEntity($entity);
$entity->views()->delete();
$entity->permissions()->delete();
$entity->tags()->delete();
$entity->comments()->delete();
$this->permissionService->deleteJointPermissionsForEntity($entity);
$this->searchService->deleteEntityTerms($entity);
}
/**
* Copy the permissions of a bookshelf to all child books.
* Returns the number of books that had permissions updated.
* @param Bookshelf $bookshelf
* @return int
* @throws Throwable
*/
public function copyBookshelfPermissions(Bookshelf $bookshelf)
{
$shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $bookshelf->books()->get();
$updatedBookCount = 0;
/** @var Book $book */
foreach ($shelfBooks as $book) {
if (!userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->restricted = $bookshelf->restricted;
$book->permissions()->createMany($shelfPermissions);
$book->save();
$book->rebuildPermissions();
$updatedBookCount++;
}
return $updatedBookCount;
}
}

View File

@ -3,91 +3,199 @@
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Entities\Page;
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use DOMDocument;
use DOMElement;
use DOMXPath;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class PageRepo extends EntityRepo
class PageRepo
{
protected $baseRepo;
/**
* Get page by slug.
* @param string $pageSlug
* @param string $bookSlug
* @return Page
* @throws \BookStack\Exceptions\NotFoundException
* PageRepo constructor.
*/
public function getBySlug(string $pageSlug, string $bookSlug)
public function __construct(BaseRepo $baseRepo)
{
return $this->getEntityBySlug('page', $pageSlug, $bookSlug);
$this->baseRepo = $baseRepo;
}
/**
* Search through page revisions and retrieve the last page in the
* current book that has a slug equal to the one given.
* @param string $pageSlug
* @param string $bookSlug
* @return null|Page
* Get a page by ID.
* @throws NotFoundException
*/
public function getPageByOldSlug(string $pageSlug, string $bookSlug)
public function getById(int $id): Page
{
$revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
->whereHas('page', function ($query) {
$this->permissionService->enforceEntityRestrictions('page', $query);
$page = Page::visible()->with(['book'])->find($id);
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page its book and own slug.
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $pageSlug): Page
{
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page by its old slug but checking the revisions table
* for the last revision that matched the given page and book slug.
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->visible();
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')->first();
return $revision !== null ? $revision->page : null;
->with('page')
->first();
return $revision ? $revision->page : null;
}
/**
* Updates a page with any fillable data and saves it into the database.
* @param Page $page
* @param int $book_id
* @param array $input
* @return Page
* @throws \Exception
* Get pages that have been marked as a template.
*/
public function updatePage(Page $page, int $book_id, array $input)
public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
{
$query = Page::visible()
->where('template', '=', true)
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
return $paginator;
}
/**
* Get a parent item via slugs.
*/
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
}
/**
* Get the draft copy of the given page for the current user.
*/
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
}
/**
* Get a new draft page belonging to the given parent entity.
*/
public function getNewDraftPage(Entity $parent)
{
$page = (new Page())->forceFill([
'name' => trans('entities.pages_initial_name'),
'created_by' => user()->id,
'updated_by' => user()->id,
'draft' => true,
]);
if ($parent instanceof Chapter) {
$page->chapter_id = $parent->id;
$page->book_id = $parent->book_id;
} else {
$page->book_id = $parent->id;
}
$page->save();
$page->refresh()->rebuildPermissions();
return $page;
}
/**
* Publish a draft page to make it a live, non-draft page.
*/
public function publishDraft(Page $draft, array $input): Page
{
$this->baseRepo->update($draft, $input);
if (isset($input['template']) && userCan('templates-manage')) {
$draft->template = ($input['template'] === 'true');
}
$pageContent = new PageContent($draft);
$pageContent->setNewHTML($input['html']);
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->refreshSlug();
$draft->save();
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
return $draft->refresh();
}
/**
* Update a page in the system.
*/
public function update(Page $page, array $input): Page
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
}
$this->baseRepo->update($page, $input);
// Update with new details
$userId = user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = $this->pageToPlainText($page);
$page->updated_by = $userId;
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$page->revision_count++;
if (setting('app-editor') !== 'markdown') {
$page->markdown = '';
}
if ($page->isDirty('name')) {
$page->refreshSlug();
}
$page->save();
// Remove all update drafts for this user & page.
$this->userUpdatePageDraftsQuery($page, $userId)->delete();
$this->getUserDraftQuery($page)->delete();
// Save a revision after updating
$summary = $input['summary'] ?? null;
@ -95,24 +203,20 @@ class PageRepo extends EntityRepo
$this->savePageRevision($page, $summary);
}
$this->searchService->indexEntity($page);
return $page;
}
/**
* Saves a page revision into the system.
* @param Page $page
* @param null|string $summary
* @return PageRevision
* @throws \Exception
*/
public function savePageRevision(Page $page, string $summary = null)
protected function savePageRevision(Page $page, string $summary = null)
{
$revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
$revision = new PageRevision($page->toArray());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
}
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@ -123,163 +227,29 @@ class PageRepo extends EntityRepo
$revision->revision_number = $page->revision_count;
$revision->save();
$revisionLimit = config('app.revision_limit');
if ($revisionLimit !== false) {
$revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
if ($revisionsToDelete->count() > 0) {
$this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Formats a page's html to be tagged correctly within the system.
* @param string $htmlText
* @return string
*/
protected function formatHtml(string $htmlText)
{
if ($htmlText == '') {
return $htmlText;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* 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
*/
protected function setUniqueId($element, array &$idMap)
{
if (get_class($element) !== 'DOMElement') {
return;
}
// Overwrite id if not a BookStack custom id
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
}
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
}
/**
* Get the plain text version of a page's content.
* @param \BookStack\Entities\Page $page
* @return string
*/
protected function pageToPlainText(Page $page) : string
{
$html = $this->renderPage($page, true);
return strip_tags($html);
}
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|null $chapter
* @return \BookStack\Entities\Page
* @throws \Throwable
*/
public function getDraftPage(Book $book, Chapter $chapter = null)
{
$page = $this->entityProvider->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->refresh()->rebuildPermissions();
return $page;
}
/**
* Save a page update draft.
* @param Page $page
* @param array $data
* @return PageRevision|Page
*/
public function updatePageDraft(Page $page, array $data = [])
public function updatePageDraft(Page $page, array $input)
{
// If the page itself is a draft simply update that
if ($page->draft) {
$page->fill($data);
if (isset($data['html'])) {
$page->text = $this->pageToPlainText($page);
$page->fill($input);
if (isset($input['html'])) {
$content = new PageContent($page);
$content->setNewHTML($input['html']);
}
$page->save();
return $page;
}
// Otherwise save the data to a revision
$userId = user()->id;
$drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
$draft = $drafts->first();
} else {
$draft = $this->entityProvider->pageRevision->newInstance();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = $userId;
$draft->type = 'update_draft';
}
$draft->fill($data);
$draft = $this->getPageRevisionToUpdate($page);
$draft->fill($input);
if (setting('app-editor') !== 'markdown') {
$draft->markdown = '';
}
@ -289,227 +259,76 @@ class PageRepo extends EntityRepo
}
/**
* Publish a draft page to make it a normal page.
* Sets the slug and updates the content.
* @param Page $draftPage
* @param array $input
* @return Page
* @throws \Exception
* Destroy a page from the system.
* @throws NotifyException
*/
public function publishPageDraft(Page $draftPage, array $input)
public function destroy(Page $page)
{
$draftPage->fill($input);
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$draftPage->template = ($input['template'] === 'true');
}
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = $this->pageToPlainText($draftPage);
$draftPage->draft = false;
$draftPage->revision_count = 1;
$draftPage->refreshSlug();
$draftPage->save();
$this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
$this->searchService->indexEntity($draftPage);
return $draftPage;
}
/**
* The base query for getting user update drafts.
* @param Page $page
* @param $userId
* @return mixed
*/
protected function userUpdatePageDraftsQuery(Page $page, int $userId)
{
return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
/**
* Get the latest updated draft revision for a particular page and user.
* @param Page $page
* @param $userId
* @return PageRevision|null
*/
public function getUserPageDraft(Page $page, int $userId)
{
return $this->userUpdatePageDraftsQuery($page, $userId)->first();
}
/**
* Get the notification message that informs the user that they are editing a draft page.
* @param PageRevision $draft
* @return string
*/
public function getUserPageDraftMessage(PageRevision $draft)
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}
/**
* A query to check for active update drafts on a particular page.
* @param Page $page
* @param int $minRange
* @return mixed
*/
protected function activePageEditingQuery(Page $page, int $minRange = null)
{
$query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', user()->id)
->with('createdBy');
if ($minRange !== null) {
$query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
}
return $query;
}
/**
* Check if a page is being actively editing.
* Checks for edits since last page updated.
* Passing in a minuted range will check for edits
* within the last x minutes.
* @param Page $page
* @param int $minRange
* @return bool
*/
public function isPageEditingActive(Page $page, int $minRange = null)
{
$draftSearch = $this->activePageEditingQuery($page, $minRange);
return $draftSearch->count() > 0;
}
/**
* Get a notification message concerning the editing activity on a particular page.
* @param Page $page
* @param int $minRange
* @return string
*/
public function getPageEditingActiveMessage(Page $page, int $minRange = null)
{
$pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
$userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Parse the headers on the page to get a navigation menu
* @param string $pageContent
* @return array
*/
public function getPageNav(string $pageContent)
{
if ($pageContent == '') {
return [];
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
if (is_null($headers)) {
return [];
}
$tree = collect($headers)->map(function ($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => $text,
];
})->filter(function ($header) {
return mb_strlen($header['text']) > 0;
});
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
return $tree->toArray();
$trashCan = new TrashCan();
$trashCan->destroyPage($page);
}
/**
* Restores a revision's content back into a page.
* @param Page $page
* @param Book $book
* @param int $revisionId
* @return Page
* @throws \Exception
*/
public function restorePageRevision(Page $page, Book $book, int $revisionId)
public function restoreRevision(Page $page, int $revisionId): Page
{
$page->revision_count++;
$this->savePageRevision($page);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
$page->text = $this->pageToPlainText($page);
$content = new PageContent($page);
$content->setNewHTML($page->html);
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();
$this->searchService->indexEntity($page);
$page->indexForSearch();
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
* Move the given page into a new parent book or chapter.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* @throws MoveOperationException
* @throws PermissionsException
*/
public function changePageParent(Page $page, Entity $parent)
public function move(Page $page, string $parentIdentifier): Book
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page = $this->changeBook($page, $book->id);
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
$page->load('book');
$book->rebuildPermissions();
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
$page->rebuildPermissions();
return $parent;
}
/**
* Create a copy of a page in a new location with a new name.
* @param \BookStack\Entities\Page $page
* @param \BookStack\Entities\Entity $newParent
* @param string $newName
* @return \BookStack\Entities\Page
* @throws \Throwable
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
* @throws MoveOperationException
* @throws PermissionsException
*/
public function copyPage(Page $page, Entity $newParent, string $newName = '')
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
$newBook = $newParent->isA('book') ? $newParent : $newParent->book;
$newChapter = $newParent->isA('chapter') ? $newParent : null;
$copyPage = $this->getDraftPage($newBook, $newChapter);
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();
// Update name
@ -525,38 +344,116 @@ class PageRepo extends EntityRepo
}
}
// Set priority
if ($newParent->isA('chapter')) {
$pageData['priority'] = $this->getNewChapterPriority($newParent);
} else {
$pageData['priority'] = $this->getNewBookPriority($newParent);
}
return $this->publishPageDraft($copyPage, $pageData);
return $this->publishDraft($copyPage, $pageData);
}
/**
* Get pages that have been marked as templates.
* @param int $count
* @param int $page
* @param string $search
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
* Find a page parent entity via a identifier string in the format:
* {type}:{id}
* Example: (book:5)
* @throws MoveOperationException
*/
public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
protected function findParentByIdentifier(string $identifier): ?Entity
{
$query = $this->entityQuery('page')
->where('template', '=', true)
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
if ($entityType !== 'book' && $entityType !== 'chapter') {
throw new MoveOperationException('Pages can only be in books or chapters');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
return $paginator;
/**
* Update the permissions of a page.
*/
public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($page, $restricted, $permissions);
}
/**
* Change the page's parent to the given entity.
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Book) ? $parent : $parent->book;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page->changeBook($book->id);
}
$page->load('book');
$book->rebuildPermissions();
}
/**
* Get a page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
protected function getPageRevisionToUpdate(Page $page): PageRevision
{
$drafts = $this->getUserDraftQuery($page)->get();
if ($drafts->count() > 0) {
return $drafts->first();
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Get a new priority for a page
*/
protected function getNewPriority(Page $page): int
{
if ($page->parent() instanceof Chapter) {
$lastPage = $page->parent()->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;
}
return (new BookContents($page->book))->getLastPriority() + 1;
}
/**
* Get the query to find the user's draft copies of the given page.
*/
protected function getUserDraftQuery(Page $page)
{
return PageRevision::query()->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
}

View File

@ -59,6 +59,4 @@ class SlugGenerator
return $query->count() > 0;
}
}
}

View File

@ -0,0 +1,8 @@
<?php namespace BookStack\Exceptions;
use Exception;
class MoveOperationException extends Exception
{
}

View File

@ -0,0 +1,8 @@
<?php namespace BookStack\Exceptions;
use Exception;
class SortOperationException extends Exception
{
}

View File

@ -1,37 +1,37 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\FileUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $attachment;
protected $entityRepo;
protected $pageRepo;
/**
* AttachmentController constructor.
* @param \BookStack\Uploads\AttachmentService $attachmentService
* @param Attachment $attachment
* @param EntityRepo $entityRepo
*/
public function __construct(AttachmentService $attachmentService, Attachment $attachment, EntityRepo $entityRepo)
public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->entityRepo = $entityRepo;
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Endpoint at which attachments are uploaded to.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
* @throws ValidationException
* @throws NotFoundException
*/
public function upload(Request $request)
{
@ -41,7 +41,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
@ -59,10 +59,8 @@ class AttachmentController extends Controller
/**
* Update an uploaded attachment.
* @param Request $request
* @param int $attachmentId
* @return mixed
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
* @throws NotFoundException
*/
public function uploadUpdate(Request $request, $attachmentId)
{
@ -72,7 +70,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
@ -95,10 +93,8 @@ class AttachmentController extends Controller
/**
* Update the details of an existing file.
* @param Request $request
* @param $attachmentId
* @return Attachment|mixed
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
* @throws NotFoundException
*/
public function update(Request $request, $attachmentId)
{
@ -109,7 +105,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
@ -125,8 +121,8 @@ class AttachmentController extends Controller
/**
* Attach a link to a page.
* @param Request $request
* @return mixed
* @throws ValidationException
* @throws NotFoundException
*/
public function attachLink(Request $request)
{
@ -137,7 +133,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
@ -151,30 +147,26 @@ class AttachmentController extends Controller
/**
* Get the attachments for a specific page.
* @param $pageId
* @return mixed
*/
public function listForPage($pageId)
public function listForPage(int $pageId)
{
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
}
/**
* Update the attachment sorting.
* @param Request $request
* @param $pageId
* @return mixed
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
* @throws NotFoundException
*/
public function sortForPage(Request $request, $pageId)
public function sortForPage(Request $request, int $pageId)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
]);
$page = $this->entityRepo->getById('page', $pageId);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
@ -184,16 +176,15 @@ class AttachmentController extends Controller
/**
* Get an attachment from storage.
* @param $attachmentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
* @throws FileNotFoundException
* @throws NotFoundException
*/
public function get($attachmentId)
public function get(int $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$page = $this->entityRepo->getById('page', $attachment->uploaded_to);
if ($page === null) {
try {
$page = $this->pageRepo->getById($attachment->uploaded_to);
} catch (NotFoundException $exception) {
throw new NotFoundException(trans('errors.attachment_not_found'));
}
@ -211,9 +202,9 @@ class AttachmentController extends Controller
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
* @throws \Exception
* @throws Exception
*/
public function delete($attachmentId)
public function delete(int $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);

View File

@ -65,14 +65,14 @@ class ConfirmEmailController extends Controller
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
if ($exception instanceof UserTokenNotFoundException) {
$this->showErrorNotification( trans('errors.email_confirmation_invalid'));
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
return redirect('/register');
}
if ($exception instanceof UserTokenExpiredException) {
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
$this->showErrorNotification( trans('errors.email_confirmation_expired'));
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
return redirect('/register/confirm');
}
@ -84,7 +84,7 @@ class ConfirmEmailController extends Controller
$user->save();
auth()->login($user);
$this->showSuccessNotification( trans('auth.email_confirm_success'));
$this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);
return redirect('/');
@ -106,11 +106,11 @@ class ConfirmEmailController extends Controller
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (Exception $e) {
$this->showErrorNotification( trans('auth.email_confirm_send_error'));
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
}
$this->showSuccessNotification( trans('auth.email_confirm_resent'));
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
return redirect('/register/confirm');
}
}

View File

@ -53,7 +53,7 @@ class ForgotPasswordController extends Controller
if ($response === Password::RESET_LINK_SENT) {
$message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
$this->showSuccessNotification( $message);
$this->showSuccessNotification($message);
return back()->with('status', trans($response));
}

View File

@ -44,7 +44,7 @@ class ResetPasswordController extends Controller
protected function sendResetResponse(Request $request, $response)
{
$message = trans('auth.reset_password_success');
$this->showSuccessNotification( $message);
$this->showSuccessNotification($message);
return redirect($this->redirectPath())
->with('status', trans($response));
}

View File

@ -77,7 +77,7 @@ class UserInviteController extends Controller
$user->save();
auth()->login($user);
$this->showSuccessNotification( trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
return redirect('/');
@ -96,7 +96,7 @@ class UserInviteController extends Controller
}
if ($exception instanceof UserTokenExpiredException) {
$this->showErrorNotification( trans('errors.invite_token_expired'));
$this->showErrorNotification(trans('errors.invite_token_expired'));
return redirect('/password/email');
}

View File

@ -1,22 +1,14 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\EntityContextManager;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Redirector;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Throwable;
use Views;
@ -24,33 +16,20 @@ class BookController extends Controller
{
protected $bookRepo;
protected $userRepo;
protected $entityContextManager;
protected $imageRepo;
/**
* BookController constructor.
* @param BookRepo $bookRepo
* @param UserRepo $userRepo
* @param EntityContextManager $entityContextManager
* @param ImageRepo $imageRepo
*/
public function __construct(
BookRepo $bookRepo,
UserRepo $userRepo,
EntityContextManager $entityContextManager,
ImageRepo $imageRepo
) {
public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
$this->userRepo = $userRepo;
$this->entityContextManager = $entityContextManager;
$this->imageRepo = $imageRepo;
parent::__construct();
}
/**
* Display a listing of the book.
* @return Response
*/
public function index()
{
@ -58,10 +37,10 @@ class BookController extends Controller
$sort = setting()->getForCurrentUser('books_sort', 'name');
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
$books = $this->bookRepo->getAllPaginated('book', 18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed('book', 4, 0) : false;
$popular = $this->bookRepo->getPopular('book', 4, 0);
$new = $this->bookRepo->getRecentlyCreated('book', 4, 0);
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$this->entityContextManager->clearShelfContext();
@ -79,19 +58,17 @@ class BookController extends Controller
/**
* Show the form for creating a new book.
* @param string $shelfSlug
* @return Response
* @throws NotFoundException
*/
public function create(string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug);
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
$this->checkPermission('book-create-all');
$this->setPageTitle(trans('entities.books_create'));
return view('books.create', [
'bookshelf' => $bookshelf
@ -100,11 +77,6 @@ class BookController extends Controller
/**
* Store a newly created book in storage.
*
* @param Request $request
* @param string $shelfSlug
* @return Response
* @throws NotFoundException
* @throws ImageUploadException
* @throws ValidationException
*/
@ -114,19 +86,17 @@ class BookController extends Controller
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'image' => $this->imageRepo->getImageValidationRules(),
'image' => $this->getImageValidationRules(),
]);
$bookshelf = null;
if ($shelfSlug !== null) {
/** @var Bookshelf $bookshelf */
$bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug);
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
/** @var Book $book */
$book = $this->bookRepo->createFromInput('book', $request->all());
$this->bookUpdateActions($book, $request);
$book = $this->bookRepo->create($request->all());
$this->bookRepo->updateCoverImage($book, $request->file('image', null));
Activity::add($book, 'book_create', $book->id);
if ($bookshelf) {
@ -139,17 +109,11 @@ class BookController extends Controller
/**
* Display the specified book.
* @param Request $request
* @param string $slug
* @return Response
* @throws NotFoundException
*/
public function show(Request $request, string $slug)
{
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $book);
$bookChildren = $this->bookRepo->getBookChildren($book);
$bookChildren = (new BookContents($book))->getTree(true);
Views::add($book);
if ($request->has('shelf')) {
@ -167,9 +131,6 @@ class BookController extends Controller
/**
* Show the form for editing the specified book.
* @param string $slug
* @return Response
* @throws NotFoundException
*/
public function edit(string $slug)
{
@ -181,11 +142,7 @@ class BookController extends Controller
/**
* Update the specified book in storage.
* @param Request $request
* @param string $slug
* @return Response
* @throws ImageUploadException
* @throws NotFoundException
* @throws ValidationException
* @throws Throwable
*/
@ -196,22 +153,20 @@ class BookController extends Controller
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'image' => $this->imageRepo->getImageValidationRules(),
'image' => $this->getImageValidationRules(),
]);
$book = $this->bookRepo->updateFromInput($book, $request->all());
$this->bookUpdateActions($book, $request);
$book = $this->bookRepo->update($book, $request->all());
$resetCover = $request->has('image_reset');
$this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
Activity::add($book, 'book_update', $book->id);
Activity::add($book, 'book_update', $book->id);
return redirect($book->getUrl());
return redirect($book->getUrl());
}
/**
* Shows the page to confirm deletion
* @param string $bookSlug
* @return View
* @throws NotFoundException
* Shows the page to confirm deletion.
*/
public function showDelete(string $bookSlug)
{
@ -222,115 +177,7 @@ class BookController extends Controller
}
/**
* Shows the view which allows pages to be re-ordered and sorted.
* @param string $bookSlug
* @return View
* @throws NotFoundException
*/
public function sort(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = $this->bookRepo->getBookChildren($book, true);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Shows the sort box for a single book.
* Used via AJAX when loading in extra books to a sort.
* @param string $bookSlug
* @return Factory|View
* @throws NotFoundException
*/
public function sortItem(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$bookChildren = $this->bookRepo->getBookChildren($book);
return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Saves an array of sort mapping to pages and chapters.
* @param Request $request
* @param string $bookSlug
* @return RedirectResponse|Redirector
* @throws NotFoundException
*/
public function saveSort(Request $request, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
}
// Sort pages and chapters
$sortMap = collect(json_decode($request->get('sort-tree')));
$bookIdsInvolved = collect([$book->id]);
// Load models into map
$sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
$mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
$mapItem->model = $this->bookRepo->getById($mapItem->type, $mapItem->id);
// Store source and target books
$bookIdsInvolved->push(intval($mapItem->model->book_id));
$bookIdsInvolved->push(intval($mapItem->book));
});
// Get the books involved in the sort
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$booksInvolved = $this->bookRepo->getManyById('book', $bookIdsInvolved, false, true);
// Throw permission error if invalid ids or inaccessible books given.
if (count($bookIdsInvolved) !== count($booksInvolved)) {
$this->showPermissionError();
}
// Check permissions of involved books
$booksInvolved->each(function (Book $book) {
$this->checkOwnablePermission('book-update', $book);
});
// Perform the sort
$sortMap->each(function ($mapItem) {
$model = $mapItem->model;
$priorityChanged = intval($model->priority) !== intval($mapItem->sort);
$bookChanged = intval($model->book_id) !== intval($mapItem->book);
$chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
if ($bookChanged) {
$this->bookRepo->changeBook($model, $mapItem->book);
}
if ($chapterChanged) {
$model->chapter_id = intval($mapItem->parentChapter);
$model->save();
}
if ($priorityChanged) {
$model->priority = intval($mapItem->sort);
$model->save();
}
});
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
$book->rebuildPermissions();
Activity::add($book, 'book_sort', $book->id);
});
return redirect($book->getUrl());
}
/**
* Remove the specified book from storage.
* @param string $bookSlug
* @return Response
* @throws NotFoundException
* Remove the specified book from the system.
* @throws Throwable
* @throws NotifyException
*/
@ -338,72 +185,40 @@ class BookController extends Controller
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
Activity::addMessage('book_delete', $book->name);
if ($book->cover) {
$this->imageRepo->destroyImage($book->cover);
}
$this->bookRepo->destroyBook($book);
Activity::addMessage('book_delete', $book->name);
$this->bookRepo->destroy($book);
return redirect('/books');
}
/**
* Show the Restrictions view.
* @param string $bookSlug
* @return Factory|View
* @throws NotFoundException
* Show the permissions view.
*/
public function showPermissions(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$roles = $this->userRepo->getRestrictableRoles();
return view('books.permissions', [
'book' => $book,
'roles' => $roles
]);
}
/**
* Set the restrictions for this book.
* @param Request $request
* @param string $bookSlug
* @return RedirectResponse|Redirector
* @throws NotFoundException
* @throws Throwable
*/
public function permissions(Request $request, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$this->bookRepo->updateEntityPermissionsFromRequest($request, $book);
$restricted = $request->get('restricted') === 'true';
$permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
$this->bookRepo->updatePermissions($book, $restricted, $permissions);
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
return redirect($book->getUrl());
}
/**
* Common actions to run on book update.
* Handles updating the cover image.
* @param Book $book
* @param Request $request
* @throws ImageUploadException
*/
protected function bookUpdateActions(Book $book, Request $request)
{
// Update the cover image if in request
if ($request->has('image')) {
$this->imageRepo->destroyImage($book->cover);
$newImage = $request->file('image');
$image = $this->imageRepo->saveNew($newImage, 'cover_book', $book->id, 512, 512, true);
$book->image_id = $image->id;
$book->save();
}
if ($request->has('image_reset')) {
$this->imageRepo->destroyImage($book->cover);
$book->image_id = 0;
$book->save();
}
}
}

View File

@ -4,25 +4,16 @@ namespace BookStack\Http\Controllers;
use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\NotFoundException;
use Throwable;
class BookExportController extends Controller
{
/**
* @var BookRepo
*/
protected $bookRepo;
/**
* @var ExportService
*/
protected $bookRepo;
protected $exportService;
/**
* BookExportController constructor.
* @param BookRepo $bookRepo
* @param ExportService $exportService
*/
public function __construct(BookRepo $bookRepo, ExportService $exportService)
{
@ -33,9 +24,6 @@ class BookExportController extends Controller
/**
* Export a book as a PDF file.
* @param string $bookSlug
* @return mixed
* @throws NotFoundException
* @throws Throwable
*/
public function pdf(string $bookSlug)
@ -47,9 +35,6 @@ class BookExportController extends Controller
/**
* Export a book as a contained HTML file.
* @param string $bookSlug
* @return mixed
* @throws NotFoundException
* @throws Throwable
*/
public function html(string $bookSlug)
@ -61,9 +46,6 @@ class BookExportController extends Controller
/**
* Export a book as a plain text file.
* @param $bookSlug
* @return mixed
* @throws NotFoundException
*/
public function plainText(string $bookSlug)
{

View File

@ -0,0 +1,82 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\SortOperationException;
use BookStack\Facades\Activity;
use Illuminate\Http\Request;
class BookSortController extends Controller
{
protected $bookRepo;
/**
* BookSortController constructor.
* @param $bookRepo
*/
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
parent::__construct();
}
/**
* Shows the view which allows pages to be re-ordered and sorted.
*/
public function show(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = (new BookContents($book))->getTree(false);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Shows the sort box for a single book.
* Used via AJAX when loading in extra books to a sort.
*/
public function showItem(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$bookChildren = (new BookContents($book))->getTree();
return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Sorts a book using a given mapping array.
*/
public function update(Request $request, string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
}
$sortMap = collect(json_decode($request->get('sort-tree')));
$bookContents = new BookContents($book);
$booksInvolved = collect();
try {
$booksInvolved = $bookContents->sortUsingMap($sortMap);
} catch (SortOperationException $exception) {
$this->showPermissionError();
}
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
Activity::add($book, 'book_sort', $book->id);
});
return redirect($book->getUrl());
}
}

View File

@ -1,34 +1,30 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\EntityContextManager;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Views;
class BookshelfController extends Controller
{
protected $entityRepo;
protected $userRepo;
protected $bookshelfRepo;
protected $entityContextManager;
protected $imageRepo;
/**
* BookController constructor.
* @param EntityRepo $entityRepo
* @param UserRepo $userRepo
* @param EntityContextManager $entityContextManager
* @param ImageRepo $imageRepo
*/
public function __construct(EntityRepo $entityRepo, UserRepo $userRepo, EntityContextManager $entityContextManager, ImageRepo $imageRepo)
public function __construct(BookshelfRepo $bookshelfRepo, EntityContext $entityContextManager, ImageRepo $imageRepo)
{
$this->entityRepo = $entityRepo;
$this->userRepo = $userRepo;
$this->bookshelfRepo = $bookshelfRepo;
$this->entityContextManager = $entityContextManager;
$this->imageRepo = $imageRepo;
parent::__construct();
@ -36,7 +32,6 @@ class BookshelfController extends Controller
/**
* Display a listing of the book.
* @return Response
*/
public function index()
{
@ -49,14 +44,10 @@ class BookshelfController extends Controller
'updated_at' => trans('common.sort_updated_at'),
];
$shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $sort, $order);
foreach ($shelves as $shelf) {
$shelf->books = $this->entityRepo->getBookshelfChildren($shelf);
}
$recents = $this->isSignedIn() ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false;
$popular = $this->entityRepo->getPopular('bookshelf', 4, 0);
$new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0);
$shelves = $this->bookshelfRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookshelfRepo->getRecentlyViewed(4) : false;
$popular = $this->bookshelfRepo->getPopular(4);
$new = $this->bookshelfRepo->getRecentlyCreated(4);
$this->entityContextManager->clearShelfContext();
$this->setPageTitle(trans('entities.shelves'));
@ -74,21 +65,19 @@ class BookshelfController extends Controller
/**
* Show the form for creating a new bookshelf.
* @return Response
*/
public function create()
{
$this->checkPermission('bookshelf-create-all');
$books = $this->entityRepo->getAll('book', false, 'update');
$books = Book::hasPermission('update')->get();
$this->setPageTitle(trans('entities.shelves_create'));
return view('shelves.create', ['books' => $books]);
}
/**
* Store a newly created bookshelf in storage.
* @param Request $request
* @return Response
* @throws \BookStack\Exceptions\ImageUploadException
* @throws ValidationException
* @throws ImageUploadException
*/
public function store(Request $request)
{
@ -96,80 +85,63 @@ class BookshelfController extends Controller
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'image' => $this->imageRepo->getImageValidationRules(),
'image' => $this->getImageValidationRules(),
]);
$shelf = $this->entityRepo->createFromInput('bookshelf', $request->all());
$this->shelfUpdateActions($shelf, $request);
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
$this->bookshelfRepo->updateCoverImage($shelf);
Activity::add($shelf, 'bookshelf_create');
return redirect($shelf->getUrl());
}
/**
* Display the specified bookshelf.
* @param String $slug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* Display the bookshelf of the given slug.
* @throws NotFoundException
*/
public function show(string $slug)
{
/** @var Bookshelf $shelf */
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('book-view', $shelf);
$books = $this->entityRepo->getBookshelfChildren($shelf);
Views::add($shelf);
$this->entityContextManager->setShelfContext($shelf->id);
$this->setPageTitle($shelf->getShortName());
return view('shelves.show', [
'shelf' => $shelf,
'books' => $books,
'activity' => Activity::entityActivity($shelf, 20, 1)
]);
}
/**
* Show the form for editing the specified bookshelf.
* @param $slug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
*/
public function edit(string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$shelfBooks = $this->entityRepo->getBookshelfChildren($shelf);
$shelfBookIds = $shelfBooks->pluck('id');
$books = $this->entityRepo->getAll('book', false, 'update');
$books = $books->filter(function ($book) use ($shelfBookIds) {
return !$shelfBookIds->contains($book->id);
});
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get();
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
return view('shelves.edit', [
'shelf' => $shelf,
'books' => $books,
'shelfBooks' => $shelfBooks,
]);
}
/**
* Update the specified bookshelf in storage.
* @param Request $request
* @param string $slug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* @throws \BookStack\Exceptions\ImageUploadException
* @throws ValidationException
* @throws ImageUploadException
* @throws NotFoundException
*/
public function update(Request $request, string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->validate($request, [
'name' => 'required|string|max:255',
@ -177,24 +149,22 @@ class BookshelfController extends Controller
'image' => $this->imageRepo->getImageValidationRules(),
]);
$shelf = $this->entityRepo->updateFromInput($shelf, $request->all());
$this->shelfUpdateActions($shelf, $request);
Activity::add($shelf, 'bookshelf_update');
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds);
$resetCover = $request->has('image_reset');
$this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover);
Activity::add($shelf, 'bookshelf_update');
return redirect($shelf->getUrl());
return redirect($shelf->getUrl());
}
/**
* Shows the page to confirm deletion
* @param $slug
* @return \Illuminate\View\View
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showDelete(string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@ -203,101 +173,58 @@ class BookshelfController extends Controller
/**
* Remove the specified bookshelf from storage.
* @param string $slug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* @throws \Throwable
* @throws Exception
*/
public function destroy(string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
Activity::addMessage('bookshelf_delete', $shelf->name);
if ($shelf->cover) {
$this->imageRepo->destroyImage($shelf->cover);
}
$this->entityRepo->destroyBookshelf($shelf);
Activity::addMessage('bookshelf_delete', $shelf->name);
$this->bookshelfRepo->destroy($shelf);
return redirect('/shelves');
}
/**
* Show the permissions view.
* @param string $slug
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws \BookStack\Exceptions\NotFoundException
*/
public function showPermissions(string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
$roles = $this->userRepo->getRestrictableRoles();
return view('shelves.permissions', [
'shelf' => $shelf,
'roles' => $roles
]);
}
/**
* Set the permissions for this bookshelf.
* @param Request $request
* @param string $slug
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws \BookStack\Exceptions\NotFoundException
* @throws \Throwable
*/
public function permissions(Request $request, string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
$this->entityRepo->updateEntityPermissionsFromRequest($request, $shelf);
$this->showSuccessNotification( trans('entities.shelves_permissions_updated'));
$restricted = $request->get('restricted') === 'true';
$permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
$this->bookshelfRepo->updatePermissions($shelf, $restricted, $permissions);
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
return redirect($shelf->getUrl());
}
/**
* Copy the permissions of a bookshelf to the child books.
* @param string $slug
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws \BookStack\Exceptions\NotFoundException
*/
public function copyPermissions(string $slug)
{
$shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug);
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('restrictions-manage', $shelf);
$updateCount = $this->entityRepo->copyBookshelfPermissions($shelf);
$this->showSuccessNotification( trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
$updateCount = $this->bookshelfRepo->copyDownPermissions($shelf);
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
return redirect($shelf->getUrl());
}
/**
* Common actions to run on bookshelf update.
* @param Bookshelf $shelf
* @param Request $request
* @throws \BookStack\Exceptions\ImageUploadException
*/
protected function shelfUpdateActions(Bookshelf $shelf, Request $request)
{
// Update the books that the shelf references
$this->entityRepo->updateShelfBooks($shelf, $request->get('books', ''));
// Update the cover image if in request
if ($request->has('image')) {
$newImage = $request->file('image');
$this->imageRepo->destroyImage($shelf->cover);
$image = $this->imageRepo->saveNew($newImage, 'cover_shelf', $shelf->id, 512, 512, true);
$shelf->image_id = $image->id;
$shelf->save();
}
if ($request->has('image_reset')) {
$this->imageRepo->destroyImage($shelf->cover);
$shelf->image_id = 0;
$shelf->save();
}
}
}

View File

@ -1,50 +1,45 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Throwable;
use Views;
class ChapterController extends Controller
{
protected $userRepo;
protected $entityRepo;
protected $chapterRepo;
/**
* ChapterController constructor.
* @param EntityRepo $entityRepo
* @param UserRepo $userRepo
*/
public function __construct(EntityRepo $entityRepo, UserRepo $userRepo)
public function __construct(ChapterRepo $chapterRepo)
{
$this->entityRepo = $entityRepo;
$this->userRepo = $userRepo;
$this->chapterRepo = $chapterRepo;
parent::__construct();
}
/**
* Show the form for creating a new chapter.
* @param $bookSlug
* @return Response
*/
public function create($bookSlug)
public function create(string $bookSlug)
{
$book = $this->entityRepo->getEntityBySlug('book', $bookSlug);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$this->checkOwnablePermission('chapter-create', $book);
$this->setPageTitle(trans('entities.chapters_create'));
return view('chapters.create', ['book' => $book, 'current' => $book]);
}
/**
* Store a newly created chapter in storage.
* @param Request $request
* @param string $bookSlug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function store(Request $request, string $bookSlug)
{
@ -52,30 +47,28 @@ class ChapterController extends Controller
'name' => 'required|string|max:255'
]);
$book = $this->entityRepo->getEntityBySlug('book', $bookSlug);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$this->checkOwnablePermission('chapter-create', $book);
$input = $request->all();
$input['priority'] = $this->entityRepo->getNewBookPriority($book);
$chapter = $this->entityRepo->createFromInput('chapter', $input, $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
/**
* Display the specified chapter.
* @param $bookSlug
* @param $chapterSlug
* @return Response
*/
public function show($bookSlug, $chapterSlug)
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = $this->entityRepo->getBookChildren($chapter->book);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $chapter->getVisiblePages();
Views::add($chapter);
$this->setPageTitle($chapter->getShortName());
$pages = $this->entityRepo->getChapterChildren($chapter);
return view('chapters.show', [
'book' => $chapter->book,
'chapter' => $chapter,
@ -87,79 +80,71 @@ class ChapterController extends Controller
/**
* Show the form for editing the specified chapter.
* @param $bookSlug
* @param $chapterSlug
* @return Response
*/
public function edit($bookSlug, $chapterSlug)
public function edit(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
}
/**
* Update the specified chapter in storage.
* @param Request $request
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*/
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->entityRepo->updateFromInput($chapter, $request->all());
$this->chapterRepo->update($chapter, $request->all());
Activity::add($chapter, 'chapter_update', $chapter->book->id);
return redirect($chapter->getUrl());
}
/**
* Shows the page to confirm deletion of this chapter.
* @param $bookSlug
* @param $chapterSlug
* @return \Illuminate\View\View
* @throws NotFoundException
*/
public function showDelete($bookSlug, $chapterSlug)
public function showDelete(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]);
}
/**
* Remove the specified chapter from storage.
* @param $bookSlug
* @param $chapterSlug
* @return Response
* @throws NotFoundException
* @throws Throwable
*/
public function destroy($bookSlug, $chapterSlug)
public function destroy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$book = $chapter->book;
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
Activity::addMessage('chapter_delete', $chapter->name, $book->id);
$this->entityRepo->destroyChapter($chapter);
return redirect($book->getUrl());
Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
$this->chapterRepo->destroy($chapter);
return redirect($chapter->book->getUrl());
}
/**
* Show the page for moving a chapter.
* @param $bookSlug
* @param $chapterSlug
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*/
public function showMove($bookSlug, $chapterSlug)
public function showMove(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
return view('chapters.move', [
'chapter' => $chapter,
'book' => $chapter->book
@ -168,15 +153,11 @@ class ChapterController extends Controller
/**
* Perform the move action for a chapter.
* @param Request $request
* @param string $bookSlug
* @param string $chapterSlug
* @return mixed
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*/
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
@ -185,63 +166,47 @@ class ChapterController extends Controller
return redirect($chapter->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
$parent = false;
if ($entityType == 'book') {
$parent = $this->entityRepo->getById('book', $entityId);
}
if ($parent === false || $parent === null) {
$this->showErrorNotification( trans('errors.selected_book_not_found'));
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
}
$this->entityRepo->changeBook($chapter, $parent->id);
$chapter->rebuildPermissions();
Activity::add($chapter, 'chapter_move', $chapter->book->id);
$this->showSuccessNotification( trans('entities.chapter_move_success', ['bookName' => $parent->name]));
Activity::add($chapter, 'chapter_move', $newBook->id);
$this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
return redirect($chapter->getUrl());
}
/**
* Show the Restrictions view.
* @param $bookSlug
* @param $chapterSlug
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws \BookStack\Exceptions\NotFoundException
* @throws NotFoundException
*/
public function showPermissions($bookSlug, $chapterSlug)
public function showPermissions(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$roles = $this->userRepo->getRestrictableRoles();
return view('chapters.permissions', [
'chapter' => $chapter,
'roles' => $roles
]);
}
/**
* Set the restrictions for this chapter.
* @param Request $request
* @param string $bookSlug
* @param string $chapterSlug
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws \BookStack\Exceptions\NotFoundException
* @throws \Throwable
* @throws NotFoundException
*/
public function permissions(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('restrictions-manage', $chapter);
$this->entityRepo->updateEntityPermissionsFromRequest($request, $chapter);
$this->showSuccessNotification( trans('entities.chapters_permissions_success'));
$restricted = $request->get('restricted') === 'true';
$permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
$this->chapterRepo->updatePermissions($chapter, $restricted, $permissions);
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
return redirect($chapter->getUrl());
}
}

View File

@ -1,77 +1,57 @@
<?php
namespace BookStack\Http\Controllers;
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Http\Response;
use Throwable;
class ChapterExportController extends Controller
{
/**
* @var EntityRepo
*/
protected $entityRepo;
/**
* @var ExportService
*/
protected $chapterRepo;
protected $exportService;
/**
* ChapterExportController constructor.
* @param EntityRepo $entityRepo
* @param ExportService $exportService
*/
public function __construct(EntityRepo $entityRepo, ExportService $exportService)
public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
{
$this->entityRepo = $entityRepo;
$this->chapterRepo = $chapterRepo;
$this->exportService = $exportService;
parent::__construct();
}
/**
* Exports a chapter to pdf .
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* Exports a chapter to pdf.
* @throws NotFoundException
* @throws Throwable
*/
public function pdf(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$pdfContent = $this->exportService->chapterToPdf($chapter);
return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
}
/**
* Export a chapter to a self-contained HTML file.
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* @throws NotFoundException
* @throws Throwable
*/
public function html(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$containedHtml = $this->exportService->chapterToContainedHtml($chapter);
return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
}
/**
* Export a chapter to a simple plaintext .txt file.
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* @throws NotFoundException
*/
public function plainText(string $bookSlug, string $chapterSlug)
{
$chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$chapterText = $this->exportService->chapterToPlainText($chapter);
return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
}

View File

@ -2,44 +2,36 @@
use Activity;
use BookStack\Actions\CommentRepo;
use BookStack\Entities\Repos\EntityRepo;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use BookStack\Entities\Page;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class CommentController extends Controller
{
protected $entityRepo;
protected $commentRepo;
/**
* CommentController constructor.
* @param \BookStack\Entities\Repos\EntityRepo $entityRepo
* @param \BookStack\Actions\CommentRepo $commentRepo
*/
public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo)
public function __construct(CommentRepo $commentRepo)
{
$this->entityRepo = $entityRepo;
$this->commentRepo = $commentRepo;
parent::__construct();
}
/**
* Save a new comment for a Page
* @param Request $request
* @param integer $pageId
* @param null|integer $commentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
* @throws ValidationException
*/
public function savePageComment(Request $request, $pageId, $commentId = null)
public function savePageComment(Request $request, int $pageId, int $commentId = null)
{
$this->validate($request, [
'text' => 'required|string',
'html' => 'required|string',
]);
try {
$page = $this->entityRepo->getById('page', $pageId, true);
} catch (ModelNotFoundException $e) {
$page = Page::visible()->find($pageId);
if ($page === null) {
return response('Not found', 404);
}
@ -59,11 +51,9 @@ class CommentController extends Controller
/**
* Update an existing comment.
* @param Request $request
* @param integer $commentId
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws ValidationException
*/
public function update(Request $request, $commentId)
public function update(Request $request, int $commentId)
{
$this->validate($request, [
'text' => 'required|string',
@ -80,13 +70,12 @@ class CommentController extends Controller
/**
* Delete a comment from the system.
* @param integer $id
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
public function destroy(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('comment-delete', $comment);
$this->commentRepo->delete($comment);
return response()->json(['message' => trans('entities.comment_deleted')]);
}

View File

@ -59,7 +59,7 @@ abstract class Controller extends BaseController
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
$this->showErrorNotification( trans('errors.permission'));
$this->showErrorNotification(trans('errors.permission'));
}
throw new HttpResponseException($response);
@ -129,7 +129,7 @@ abstract class Controller extends BaseController
*/
protected function jsonError($messageText = "", $statusCode = 500)
{
return response()->json(['message' => $messageText], $statusCode);
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
}
/**
@ -189,4 +189,12 @@ abstract class Controller extends BaseController
{
session()->flash('error', $message);
}
/**
* Get the validation rules for image files.
*/
protected function getImageValidationRules(): string
{
return 'image_extension|no_double_extension|mimes:jpeg,png,gif,bmp,webp,tiff';
}
}

View File

@ -1,23 +1,16 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Http\Response;
use Views;
class HomeController extends Controller
{
protected $entityRepo;
/**
* HomeController constructor.
* @param EntityRepo $entityRepo
*/
public function __construct(EntityRepo $entityRepo)
{
$this->entityRepo = $entityRepo;
parent::__construct();
}
/**
* Display the homepage.
@ -26,10 +19,20 @@ class HomeController extends Controller
public function index()
{
$activity = Activity::latest(10);
$draftPages = $this->isSignedIn() ? $this->entityRepo->getUserDraftPages(6) : [];
$draftPages = [];
if ($this->isSignedIn()) {
$draftPages = Page::visible()->where('draft', '=', true)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')->take(6)->get();
}
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor);
$recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12);
$recents = $this->isSignedIn() ?
Views::getUserRecentlyViewed(12*$recentFactor, 0)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$recentlyUpdatedPages = Page::visible()->where('draft', false)
->orderBy('updated_at', 'desc')->take(12)->get();
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
$homepageOption = setting('app-homepage-type', 'default');
@ -66,16 +69,18 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $commonData['sort'], $commonData['order']);
$shelfRepo = app(BookshelfRepo::class);
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
foreach ($shelves as $shelf) {
$shelf->books = $this->entityRepo->getBookshelfChildren($shelf);
$shelf->books = $shelf->visibleBooks;
}
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('common.home-shelves', $data);
}
if ($homepageOption === 'books') {
$books = $this->entityRepo->getAllPaginated('book', 18, $commonData['sort'], $commonData['order']);
$bookRepo = app(BookRepo::class);
$books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
$data = array_merge($commonData, ['books' => $books]);
return view('common.home-book', $data);
}
@ -83,8 +88,9 @@ class HomeController extends Controller
if ($homepageOption === 'page') {
$homepageSetting = setting('app-homepage', '0:');
$id = intval(explode(':', $homepageSetting)[0]);
$customHomepage = $this->entityRepo->getById('page', $id, false, true);
$this->entityRepo->renderPage($customHomepage, true);
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
$pageContent = new PageContent($customHomepage);
$customHomepage->html = $pageContent->render(true);
return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage]));
}

View File

@ -1,6 +1,6 @@
<?php namespace BookStack\Http\Controllers\Images;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Page;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controllers\Controller;
use BookStack\Repos\PageRepo;
@ -69,16 +69,21 @@ class ImageController extends Controller
/**
* Show the usage of an image on pages.
* @param \BookStack\Entities\Repos\EntityRepo $entityRepo
* @param $id
* @return \Illuminate\Http\JsonResponse
*/
public function usage(EntityRepo $entityRepo, $id)
public function usage(int $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$pageSearch = $entityRepo->searchForImage($image->url);
return response()->json($pageSearch);
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
}
$result = count($pages) > 0 ? $pages : false;
return response()->json($result);
}
/**

View File

@ -1,18 +1,17 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Managers\PageEditActivity;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use Exception;
use GatherContent\Htmldiff\Htmldiff;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
use Illuminate\Validation\ValidationException;
use Throwable;
use Views;
@ -20,44 +19,28 @@ class PageController extends Controller
{
protected $pageRepo;
protected $userRepo;
/**
* PageController constructor.
* @param PageRepo $pageRepo
* @param UserRepo $userRepo
*/
public function __construct(PageRepo $pageRepo, UserRepo $userRepo)
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
$this->userRepo = $userRepo;
parent::__construct();
}
/**
* Show the form for creating a new page.
* @param string $bookSlug
* @param string $chapterSlug
* @return Response
* @internal param bool $pageSlug
* @throws NotFoundException
* @throws Throwable
*/
public function create($bookSlug, $chapterSlug = null)
public function create(string $bookSlug, string $chapterSlug = null)
{
if ($chapterSlug !== null) {
$chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$book = $chapter->book;
} else {
$chapter = null;
$book = $this->pageRepo->getEntityBySlug('book', $bookSlug);
}
$parent = $chapter ? $chapter : $book;
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
$this->checkOwnablePermission('page-create', $parent);
// Redirect to draft edit screen if signed in
if ($this->isSignedIn()) {
$draft = $this->pageRepo->getDraftPage($book, $chapter);
$draft = $this->pageRepo->getNewDraftPage($parent);
return redirect($draft->getUrl());
}
@ -68,51 +51,38 @@ class PageController extends Controller
/**
* Create a new page as a guest user.
* @param Request $request
* @param string $bookSlug
* @param string|null $chapterSlug
* @return mixed
* @throws NotFoundException
* @throws ValidationException
*/
public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null)
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
{
$this->validate($request, [
'name' => 'required|string|max:255'
]);
if ($chapterSlug !== null) {
$chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug);
$book = $chapter->book;
} else {
$chapter = null;
$book = $this->pageRepo->getEntityBySlug('book', $bookSlug);
}
$parent = $chapter ? $chapter : $book;
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
$this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->getDraftPage($book, $chapter);
$this->pageRepo->publishPageDraft($page, [
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'html' => ''
]);
return redirect($page->getUrl('/edit'));
}
/**
* Show form to continue editing a draft page.
* @param string $bookSlug
* @param int $pageId
* @return Factory|View
* @throws NotFoundException
*/
public function editDraft($bookSlug, $pageId)
public function editDraft(string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById('page', $pageId, true);
$this->checkOwnablePermission('page-create', $draft->parent);
$draft = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-create', $draft->parent());
$this->setPageTitle(trans('entities.pages_edit_draft'));
$draftsEnabled = $this->isSignedIn();
$templates = $this->pageRepo->getPageTemplates(10);
$templates = $this->pageRepo->getTemplates(10);
return view('pages.edit', [
'page' => $draft,
@ -125,63 +95,50 @@ class PageController extends Controller
/**
* Store a new page by changing a draft into a page.
* @param Request $request
* @param string $bookSlug
* @param int $pageId
* @return Response
* @throws NotFoundException
* @throws ValidationException
*/
public function store(Request $request, $bookSlug, $pageId)
public function store(Request $request, string $bookSlug, int $pageId)
{
$this->validate($request, [
'name' => 'required|string|max:255'
]);
$draftPage = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-create', $draftPage->parent());
$input = $request->all();
$draftPage = $this->pageRepo->getById('page', $pageId, true);
$book = $draftPage->book;
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
Activity::add($page, 'page_create', $draftPage->book->id);
$parent = $draftPage->parent;
$this->checkOwnablePermission('page-create', $parent);
if ($parent->isA('chapter')) {
$input['priority'] = $this->pageRepo->getNewChapterPriority($parent);
} else {
$input['priority'] = $this->pageRepo->getNewBookPriority($parent);
}
$page = $this->pageRepo->publishPageDraft($draftPage, $input);
Activity::add($page, 'page_create', $book->id);
return redirect($page->getUrl());
}
/**
* Display the specified page.
* If the page is not found via the slug the revisions are searched for a match.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws NotFoundException
*/
public function show($bookSlug, $pageSlug)
public function show(string $bookSlug, string $pageSlug)
{
try {
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$page = $this->pageRepo->getPageByOldSlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug);
if ($page === null) {
throw $e;
}
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-view', $page);
$page->html = $this->pageRepo->renderPage($page);
$sidebarTree = $this->pageRepo->getBookChildren($page->book);
$pageNav = $this->pageRepo->getPageNav($page->html);
$pageContent = (new PageContent($page));
$page->html = $pageContent->render();
$sidebarTree = (new BookContents($page->book))->getTree();
$pageNav = $pageContent->getNavigation($page->html);
// check if the comment's are enabled
// Check if page comments are enabled
$commentsEnabled = !setting('app-disable-comments');
if ($commentsEnabled) {
$page->load(['comments.createdBy']);
@ -190,7 +147,8 @@ class PageController extends Controller
Views::add($page);
$this->setPageTitle($page->getShortName());
return view('pages.show', [
'page' => $page,'book' => $page->book,
'page' => $page,
'book' => $page->book,
'current' => $page,
'sidebarTree' => $sidebarTree,
'commentsEnabled' => $commentsEnabled,
@ -200,52 +158,47 @@ class PageController extends Controller
/**
* Get page from an ajax request.
* @param int $pageId
* @return JsonResponse
* @throws NotFoundException
*/
public function getPageAjax($pageId)
public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById('page', $pageId);
$page = $this->pageRepo->getById($pageId);
return response()->json($page);
}
/**
* Show the form for editing the specified page.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws NotFoundException
*/
public function edit($bookSlug, $pageSlug)
public function edit(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_editing_named', ['pageName'=>$page->getShortName()]));
$page->isDraft = false;
$editActivity = new PageEditActivity($page);
// Check for active editing
$warnings = [];
if ($this->pageRepo->isPageEditingActive($page, 60)) {
$warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60);
if ($editActivity->hasActiveEditing()) {
$warnings[] = $editActivity->activeEditingMessage();
}
// Check for a current draft version for this user
$userPageDraft = $this->pageRepo->getUserPageDraft($page, user()->id);
if ($userPageDraft !== null) {
$page->name = $userPageDraft->name;
$page->html = $userPageDraft->html;
$page->markdown = $userPageDraft->markdown;
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$page->isDraft = true;
$warnings [] = $this->pageRepo->getUserPageDraftMessage($userPageDraft);
$warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
}
if (count($warnings) > 0) {
$this->showWarningNotification( implode("\n", $warnings));
$this->showWarningNotification(implode("\n", $warnings));
}
$templates = $this->pageRepo->getTemplates(10);
$draftsEnabled = $this->isSignedIn();
$templates = $this->pageRepo->getPageTemplates(10);
$this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
return view('pages.edit', [
'page' => $page,
'book' => $page->book,
@ -257,39 +210,34 @@ class PageController extends Controller
/**
* Update the specified page in storage.
* @param Request $request
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws ValidationException
* @throws NotFoundException
*/
public function update(Request $request, $bookSlug, $pageSlug)
public function update(Request $request, string $bookSlug, string $pageSlug)
{
$this->validate($request, [
'name' => 'required|string|max:255'
]);
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->pageRepo->updatePage($page, $page->book->id, $request->all());
$this->pageRepo->update($page, $request->all());
Activity::add($page, 'page_update', $page->book->id);
return redirect($page->getUrl());
}
/**
* Save a draft update as a revision.
* @param Request $request
* @param int $pageId
* @return JsonResponse
* @throws NotFoundException
*/
public function saveDraft(Request $request, $pageId)
public function saveDraft(Request $request, int $pageId)
{
$page = $this->pageRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
if (!$this->isSignedIn()) {
return response()->json([
'status' => 'error',
'message' => trans('errors.guests_cannot_save_drafts'),
], 500);
return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
}
$draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown']));
@ -303,211 +251,98 @@ class PageController extends Controller
}
/**
* Redirect from a special link url which
* uses the page id rather than the name.
* @param int $pageId
* @return RedirectResponse|Redirector
* Redirect from a special link url which uses the page id rather than the name.
* @throws NotFoundException
*/
public function redirectFromLink($pageId)
public function redirectFromLink(int $pageId)
{
$page = $this->pageRepo->getById('page', $pageId);
$page = $this->pageRepo->getById($pageId);
return redirect($page->getUrl());
}
/**
* Show the deletion page for the specified page.
* @param string $bookSlug
* @param string $pageSlug
* @return View
* @throws NotFoundException
*/
public function showDelete($bookSlug, $pageSlug)
public function showDelete(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName'=>$page->getShortName()]));
return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page
]);
}
/**
* Show the deletion page for the specified page.
* @param string $bookSlug
* @param int $pageId
* @return View
* @throws NotFoundException
*/
public function showDeleteDraft($bookSlug, $pageId)
public function showDeleteDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName'=>$page->getShortName()]));
return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]);
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page
]);
}
/**
* Remove the specified page from storage.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @internal param int $id
* @throws NotFoundException
* @throws Throwable
* @throws NotifyException
*/
public function destroy($bookSlug, $pageSlug)
public function destroy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$book = $page->book;
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->pageRepo->destroyPage($page);
$book = $page->book;
$this->pageRepo->destroy($page);
Activity::addMessage('page_delete', $page->name, $book->id);
$this->showSuccessNotification( trans('entities.pages_delete_success'));
$this->showSuccessNotification(trans('entities.pages_delete_success'));
return redirect($book->getUrl());
}
/**
* Remove the specified draft page from storage.
* @param string $bookSlug
* @param int $pageId
* @return Response
* @throws NotFoundException
* @throws NotifyException
* @throws Throwable
*/
public function destroyDraft($bookSlug, $pageId)
public function destroyDraft(string $bookSlug, int $pageId)
{
$page = $this->pageRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$book = $page->book;
$chapter = $page->chapter;
$this->checkOwnablePermission('page-update', $page);
$this->showSuccessNotification( trans('entities.pages_delete_draft_success'));
$this->pageRepo->destroyPage($page);
$this->pageRepo->destroy($page);
$this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
if ($chapter && userCan('view', $chapter)) {
return redirect($chapter->getUrl());
}
return redirect($book->getUrl());
}
/**
* Shows the last revisions for this page.
* @param string $bookSlug
* @param string $pageSlug
* @return View
* @throws NotFoundException
*/
public function showRevisions($bookSlug, $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
return view('pages.revisions', ['page' => $page, 'current' => $page]);
}
/**
* Shows a preview of a single revision
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return View
*/
public function showRevision($bookSlug, $pageSlug, $revisionId)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
abort(404);
}
$page->fill($revision->toArray());
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
'page' => $page,
'book' => $page->book,
'diff' => null,
'revision' => $revision
]);
}
/**
* Shows the changes of a single revision
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return View
*/
public function showRevisionChanges($bookSlug, $pageSlug, $revisionId)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
abort(404);
}
$prev = $revision->getPrevious();
$prevContent = ($prev === null) ? '' : $prev->html;
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages.revision', [
'page' => $page,
'book' => $page->book,
'diff' => $diff,
'revision' => $revision
]);
}
/**
* Restores a page using the content of the specified revision.
* @param string $bookSlug
* @param string $pageSlug
* @param int $revisionId
* @return RedirectResponse|Redirector
*/
public function restoreRevision($bookSlug, $pageSlug, $revisionId)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restorePageRevision($page, $page->book, $revisionId);
Activity::add($page, 'page_restore', $page->book->id);
return redirect($page->getUrl());
}
/**
* Deletes a revision using the id of the specified revision.
* @param string $bookSlug
* @param string $pageSlug
* @param int $revId
* @return RedirectResponse|Redirector
*@throws BadRequestException
* @throws NotFoundException
*/
public function destroyRevision($bookSlug, $pageSlug, $revId)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$this->checkOwnablePermission('page-delete', $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
if ($revision === null) {
throw new NotFoundException("Revision #{$revId} not found");
}
// Get the current revision for the page
$currentRevision = $page->getCurrentRevision();
// Check if its the latest revision, cannot delete latest revision.
if (intval($currentRevision->id) === intval($revId)) {
$this->showErrorNotification( trans('entities.revision_cannot_delete_latest'));
return response()->view('pages.revisions', ['page' => $page, 'book' => $page->book, 'current' => $page], 400);
}
$revision->delete();
$this->showSuccessNotification( trans('entities.revision_delete_success'));
return redirect($page->getUrl('/revisions'));
}
/**
* Show a listing of recently created pages
* @return Factory|View
* Show a listing of recently created pages.
*/
public function showRecentlyUpdated()
{
// TODO - Still exist?
$pages = $this->pageRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(url('/pages/recently-updated'));
$pages = Page::visible()->orderBy('updated_at', 'desc')
->paginate(20)
->setPath(url('/pages/recently-updated'));
return view('pages.detailed-listing', [
'title' => trans('entities.recently_updated_pages'),
'pages' => $pages
@ -516,14 +351,11 @@ class PageController extends Controller
/**
* Show the view to choose a new parent to move a page into.
* @param string $bookSlug
* @param string $pageSlug
* @return mixed
* @throws NotFoundException
*/
public function showMove($bookSlug, $pageSlug)
public function showMove(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
return view('pages.move', [
@ -533,17 +365,13 @@ class PageController extends Controller
}
/**
* Does the action of moving the location of a page
* @param Request $request
* @param string $bookSlug
* @param string $pageSlug
* @return mixed
* Does the action of moving the location of a page.
* @throws NotFoundException
* @throws Throwable
*/
public function move(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
@ -552,37 +380,29 @@ class PageController extends Controller
return redirect($page->getUrl());
}
$stringExploded = explode(':', $entitySelection);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
try {
$parent = $this->pageRepo->getById($entityType, $entityId);
} catch (Exception $e) {
session()->flash(trans('entities.selected_book_chapter_not_found'));
$parent = $this->pageRepo->move($page, $entitySelection);
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('page-create', $parent);
$this->pageRepo->changePageParent($page, $parent);
Activity::add($page, 'page_move', $page->book->id);
$this->showSuccessNotification( trans('entities.pages_move_success', ['parentName' => $parent->name]));
$this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
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)
public function showCopy(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
session()->flashInput(['name' => $page->name]);
return view('pages.copy', [
@ -591,79 +411,65 @@ class PageController extends Controller
]);
}
/**
* Create a copy of a page within the requested target destination.
* @param Request $request
* @param string $bookSlug
* @param string $pageSlug
* @return mixed
* @throws NotFoundException
* @throws Throwable
*/
public function copy(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $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]);
$entitySelection = $request->get('entity_selection', null) ?? null;
$newName = $request->get('name', null);
try {
$parent = $this->pageRepo->getById($entityType, $entityId);
} catch (Exception $e) {
$this->showErrorNotification(trans('entities.selected_book_chapter_not_found'));
return redirect()->back();
try {
$pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName);
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
}
$this->checkOwnablePermission('page-create', $parent);
$pageCopy = $this->pageRepo->copyPage($page, $parent, $request->get('name', ''));
Activity::add($pageCopy, 'page_create', $pageCopy->book->id);
$this->showSuccessNotification( trans('entities.pages_copy_success'));
$this->showSuccessNotification(trans('entities.pages_copy_success'));
return redirect($pageCopy->getUrl());
}
/**
* Show the Permissions view.
* @param string $bookSlug
* @param string $pageSlug
* @return Factory|View
* @throws NotFoundException
*/
public function showPermissions($bookSlug, $pageSlug)
public function showPermissions(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('restrictions-manage', $page);
$roles = $this->userRepo->getRestrictableRoles();
return view('pages.permissions', [
'page' => $page,
'roles' => $roles
]);
}
/**
* Set the permissions for this page.
* @param string $bookSlug
* @param string $pageSlug
* @param Request $request
* @return RedirectResponse|Redirector
* @throws NotFoundException
* @throws Throwable
*/
public function permissions(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('restrictions-manage', $page);
$this->pageRepo->updateEntityPermissionsFromRequest($request, $page);
$this->showSuccessNotification( trans('entities.pages_permissions_success'));
$restricted = $request->get('restricted') === 'true';
$permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
$this->pageRepo->updatePermissions($page, $restricted, $permissions);
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
return redirect($page->getUrl());
}
}

View File

@ -3,21 +3,15 @@
namespace BookStack\Http\Controllers;
use BookStack\Entities\ExportService;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Http\Response;
use Throwable;
class PageExportController extends Controller
{
/**
* @var PageRepo
*/
protected $pageRepo;
/**
* @var ExportService
*/
protected $pageRepo;
protected $exportService;
/**
@ -35,46 +29,37 @@ class PageExportController extends Controller
/**
* Exports a page to a PDF.
* https://github.com/barryvdh/laravel-dompdf
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws NotFoundException
* @throws Throwable
*/
public function pdf(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page->html = $this->pageRepo->renderPage($page);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$pdfContent = $this->exportService->pageToPdf($page);
return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
}
/**
* Export a page to a self-contained HTML file.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws NotFoundException
* @throws Throwable
*/
public function html(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page->html = $this->pageRepo->renderPage($page);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
$containedHtml = $this->exportService->pageToContainedHtml($page);
return $this->downloadResponse($containedHtml, $pageSlug . '.html');
}
/**
* Export a page to a simple plaintext .txt file.
* @param string $bookSlug
* @param string $pageSlug
* @return Response
* @throws NotFoundException
*/
public function plainText(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($pageSlug, $bookSlug);
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$pageText = $this->exportService->pageToPlainText($page);
return $this->downloadResponse($pageText, $pageSlug . '.txt');
}

View File

@ -0,0 +1,128 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use GatherContent\Htmldiff\Htmldiff;
class PageRevisionController extends Controller
{
protected $pageRepo;
/**
* PageRevisionController constructor.
*/
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Shows the last revisions for this page.
* @throws NotFoundException
*/
public function index(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()]));
return view('pages.revisions', [
'page' => $page,
'current' => $page
]);
}
/**
* Shows a preview of a single revision.
* @throws NotFoundException
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
throw new NotFoundException();
}
$page->fill($revision->toArray());
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
'page' => $page,
'book' => $page->book,
'diff' => null,
'revision' => $revision
]);
}
/**
* Shows the changes of a single revision.
* @throws NotFoundException
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
if ($revision === null) {
throw new NotFoundException();
}
$prev = $revision->getPrevious();
$prevContent = $prev->html ?? '';
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages.revision', [
'page' => $page,
'book' => $page->book,
'diff' => $diff,
'revision' => $revision
]);
}
/**
* Restores a page using the content of the specified revision.
* @throws NotFoundException
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId);
Activity::add($page, 'page_restore', $page->book->id);
return redirect($page->getUrl());
}
/**
* Deletes a revision using the id of the specified revision.
* @throws NotFoundException
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
if ($revision === null) {
throw new NotFoundException("Revision #{$revId} not found");
}
// Get the current revision for the page
$currentRevision = $page->getCurrentRevision();
// Check if its the latest revision, cannot delete latest revision.
if (intval($currentRevision->id) === intval($revId)) {
$this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
return redirect($page->getUrl('/revisions'));
}
$revision->delete();
$this->showSuccessNotification(trans('entities.revision_delete_success'));
return redirect($page->getUrl('/revisions'));
}
}

View File

@ -11,8 +11,7 @@ class PageTemplateController extends Controller
protected $pageRepo;
/**
* PageTemplateController constructor.
* @param $pageRepo
* PageTemplateController constructor
*/
public function __construct(PageRepo $pageRepo)
{
@ -22,14 +21,12 @@ class PageTemplateController extends Controller
/**
* Fetch a list of templates from the system.
* @param Request $request
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function list(Request $request)
{
$page = $request->get('page', 1);
$search = $request->get('search', '');
$templates = $this->pageRepo->getPageTemplates(10, $page, $search);
$templates = $this->pageRepo->getTemplates(10, $page, $search);
if ($search) {
$templates->appends(['search' => $search]);
@ -42,13 +39,11 @@ class PageTemplateController extends Controller
/**
* Get the content of a template.
* @param $templateId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response
* @throws NotFoundException
*/
public function get($templateId)
public function get(int $templateId)
{
$page = $this->pageRepo->getById('page', $templateId);
$page = $this->pageRepo->getById($templateId);
if (!$page->template) {
throw new NotFoundException();

View File

@ -53,7 +53,7 @@ class PermissionController extends Controller
]);
$this->permissionsRepo->saveNewRole($request->all());
$this->showSuccessNotification( trans('settings.role_create_success'));
$this->showSuccessNotification(trans('settings.role_create_success'));
return redirect('/settings/roles');
}
@ -90,7 +90,7 @@ class PermissionController extends Controller
]);
$this->permissionsRepo->updateRole($id, $request->all());
$this->showSuccessNotification( trans('settings.role_update_success'));
$this->showSuccessNotification(trans('settings.role_update_success'));
return redirect('/settings/roles');
}
@ -124,11 +124,11 @@ class PermissionController extends Controller
try {
$this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id'));
} catch (PermissionsException $e) {
$this->showErrorNotification( $e->getMessage());
$this->showErrorNotification($e->getMessage());
return redirect()->back();
}
$this->showSuccessNotification( trans('settings.role_delete_success'));
$this->showSuccessNotification(trans('settings.role_delete_success'));
return redirect('/settings/roles');
}
}

View File

@ -1,35 +1,27 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService;
use BookStack\Entities\EntityContextManager;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\SearchService;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
protected $entityRepo;
protected $viewService;
protected $searchService;
protected $entityContextManager;
/**
* SearchController constructor.
* @param EntityRepo $entityRepo
* @param ViewService $viewService
* @param SearchService $searchService
* @param EntityContextManager $entityContextManager
*/
public function __construct(
EntityRepo $entityRepo,
ViewService $viewService,
SearchService $searchService,
EntityContextManager $entityContextManager
EntityContext $entityContextManager
) {
$this->entityRepo = $entityRepo;
$this->viewService = $viewService;
$this->searchService = $searchService;
$this->entityContextManager = $entityContextManager;
@ -38,9 +30,6 @@ class SearchController extends Controller
/**
* Searches all entities.
* @param Request $request
* @return View
* @internal param string $searchTerm
*/
public function search(Request $request)
{
@ -64,12 +53,8 @@ class SearchController extends Controller
/**
* Searches all entities within a book.
* @param Request $request
* @param integer $bookId
* @return View
* @internal param string $searchTerm
*/
public function searchBook(Request $request, $bookId)
public function searchBook(Request $request, int $bookId)
{
$term = $request->get('term', '');
$results = $this->searchService->searchBook($bookId, $term);
@ -78,12 +63,8 @@ class SearchController extends Controller
/**
* Searches all entities within a chapter.
* @param Request $request
* @param integer $chapterId
* @return View
* @internal param string $searchTerm
*/
public function searchChapter(Request $request, $chapterId)
public function searchChapter(Request $request, int $chapterId)
{
$term = $request->get('term', '');
$results = $this->searchService->searchChapter($chapterId, $term);
@ -93,8 +74,6 @@ class SearchController extends Controller
/**
* Search for a list of entities and return a partial HTML response of matching entities.
* Returns the most popular entities if no search is provided.
* @param Request $request
* @return mixed
*/
public function searchEntitiesAjax(Request $request)
{
@ -115,15 +94,13 @@ class SearchController extends Controller
/**
* Search siblings items in the system.
* @param Request $request
* @return Factory|View|mixed
*/
public function searchSiblings(Request $request)
{
$type = $request->get('entity_type', null);
$id = $request->get('entity_id', null);
$entity = $this->entityRepo->getById($type, $id);
$entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id);
if (!$entity) {
return $this->jsonError(trans('errors.entity_not_found'), 404);
}
@ -132,12 +109,12 @@ class SearchController extends Controller
// Page in chapter
if ($entity->isA('page') && $entity->chapter) {
$entities = $this->entityRepo->getChapterChildren($entity->chapter);
$entities = $entity->chapter->visiblePages();
}
// Page in book or chapter
if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
$entities = $this->entityRepo->getBookDirectChildren($entity->book);
$entities = $entity->book->getDirectChildren();
}
// Book
@ -145,15 +122,15 @@ class SearchController extends Controller
if ($entity->isA('book')) {
$contextShelf = $this->entityContextManager->getContextualShelfForBook($entity);
if ($contextShelf) {
$entities = $this->entityRepo->getBookshelfChildren($contextShelf);
$entities = $contextShelf->visibleBooks()->get();
} else {
$entities = $this->entityRepo->getAll('book');
$entities = Book::visible()->get();
}
}
// Shelve
if ($entity->isA('bookshelf')) {
$entities = $this->entityRepo->getAll('bookshelf');
$entities = Bookshelf::visible()->get();
}
return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);

View File

@ -76,7 +76,7 @@ class SettingController extends Controller
setting()->remove('app-logo');
}
$this->showSuccessNotification( trans('settings.settings_save_success'));
$this->showSuccessNotification(trans('settings.settings_save_success'));
return redirect('/settings');
}
@ -111,14 +111,14 @@ class SettingController extends Controller
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleteCount = count($imagesToDelete);
if ($deleteCount === 0) {
$this->showWarningNotification( trans('settings.maint_image_cleanup_nothing_found'));
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
return redirect('/settings/maintenance')->withInput();
}
if ($dryRun) {
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
} else {
$this->showSuccessNotification( trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
}
return redirect('/settings/maintenance#image-cleanup')->withInput();

View File

@ -202,7 +202,7 @@ class UserController extends Controller
}
$user->save();
$this->showSuccessNotification( trans('settings.users_edit_success'));
$this->showSuccessNotification(trans('settings.users_edit_success'));
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
return redirect($redirectUrl);
@ -236,17 +236,17 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
if ($this->userRepo->isOnlyAdmin($user)) {
$this->showErrorNotification( trans('errors.users_cannot_delete_only_admin'));
$this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
$this->showErrorNotification( trans('errors.users_cannot_delete_guest'));
$this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user);
$this->showSuccessNotification( trans('settings.users_delete_success'));
$this->showSuccessNotification(trans('settings.users_delete_success'));
return redirect('/settings/users');
}
@ -261,7 +261,7 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
$userActivity = $this->userRepo->getActivity($user);
$recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5, 0);
$recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5);
$assetCounts = $this->userRepo->getAssetCounts($user);
return view('users.profile', [

View File

@ -24,5 +24,4 @@ class GlobalViewData
return $next($request);
}
}
}

View File

@ -58,7 +58,7 @@
<h1 id="{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</h1>
@if($bookChild->isA('chapter'))
<p>{{ $bookChild->text }}</p>
<p>{{ $bookChild->description }}</p>
@if(count($bookChild->pages) > 0)
@foreach($bookChild->pages as $page)

View File

@ -22,7 +22,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.pages_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'page'), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
</div>
</div>
</div>
@ -30,7 +30,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.books_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'book'), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
</div>
</div>
</div>
@ -38,7 +38,7 @@
<div class="card mb-xl">
<h3 class="card-title">{{ trans('entities.chapters_popular') }}</h3>
<div class="px-m">
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'chapter'), 'style' => 'compact'])
@include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
</div>
</div>
</div>

View File

@ -19,7 +19,7 @@
<a href="#" permissions-table-toggle-all class="text-small ml-m text-primary">{{ trans('common.toggle_all') }}</a>
</th>
</tr>
@foreach($roles as $role)
@foreach(\BookStack\Auth\Role::restrictable() as $role)
<tr>
<td width="33%" class="pt-m">
{{ $role->display_name }}

View File

@ -14,10 +14,10 @@
<div class="form-group">
<label for="books">{{ trans('entities.shelves_books') }}</label>
<input type="hidden" id="books-input" name="books"
value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}">
value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
<div class="scroll-box" shelf-sort-assigned-books data-instruction="{{ trans('entities.shelves_drag_books') }}">
@if (isset($shelfBooks) && count($shelfBooks) > 0)
@foreach ($shelfBooks as $book)
@if (count($shelf->visibleBooks ?? []) > 0)
@foreach ($shelf->visibleBooks as $book)
<div data-id="{{ $book->id }}" class="scroll-box-item">
<a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
</div>

View File

@ -12,9 +12,9 @@
<h1 class="break-text">{{$shelf->name}}</h1>
<div class="book-content">
<p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
@if(count($books) > 0)
@if(count($shelf->visibleBooks) > 0)
<div class="entity-list">
@foreach($books as $book)
@foreach($shelf->visibleBooks as $book)
@include('books.list-item', ['book' => $book])
@endforeach
</div>

View File

@ -9,9 +9,7 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/uploads/images/{path}', 'Images\ImageController@showImage')
->where('path', '.*$');
Route::group(['prefix' => 'pages'], function() {
Route::get('/recently-updated', 'PageController@showRecentlyUpdated');
});
Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated');
// Shelves
Route::get('/create-shelf', 'BookshelfController@create');
@ -40,13 +38,13 @@ Route::group(['middleware' => 'auth'], function () {
Route::get('/{slug}/edit', 'BookController@edit');
Route::put('/{slug}', 'BookController@update');
Route::delete('/{id}', 'BookController@destroy');
Route::get('/{slug}/sort-item', 'BookController@sortItem');
Route::get('/{slug}/sort-item', 'BookSortController@showItem');
Route::get('/{slug}', 'BookController@show');
Route::get('/{bookSlug}/permissions', 'BookController@showPermissions');
Route::put('/{bookSlug}/permissions', 'BookController@permissions');
Route::get('/{slug}/delete', 'BookController@showDelete');
Route::get('/{bookSlug}/sort', 'BookController@sort');
Route::put('/{bookSlug}/sort', 'BookController@saveSort');
Route::get('/{bookSlug}/sort', 'BookSortController@show');
Route::put('/{bookSlug}/sort', 'BookSortController@update');
Route::get('/{bookSlug}/export/html', 'BookExportController@html');
Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf');
Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText');
@ -74,11 +72,11 @@ Route::group(['middleware' => 'auth'], function () {
Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft');
// Revisions
Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges');
Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision');
Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageController@destroyRevision');
Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageRevisionController@index');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageRevisionController@show');
Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageRevisionController@changes');
Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageRevisionController@restore');
Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageRevisionController@destroy');
// Chapters
Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create');

View File

@ -2,7 +2,6 @@
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Auth\User;
use BookStack\Entities\Repos\PageRepo;
@ -56,7 +55,7 @@ class CommandsTest extends TestCase
$this->asEditor();
$pageRepo = app(PageRepo::class);
$page = Page::first();
$pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
$this->assertDatabaseHas('page_revisions', [

View File

@ -3,7 +3,6 @@
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Repos\PageRepo;
use Carbon\Carbon;
@ -192,7 +191,7 @@ class EntityTest extends BrowserKitTest
$entities = $this->createEntityChainBelongingToUser($creator, $updater);
$this->actingAs($creator);
app(UserRepo::class)->destroy($creator);
app(PageRepo::class)->savePageRevision($entities['page']);
app(PageRepo::class)->update($entities['page'], ['html' => '<p>hello!</p>>']);
$this->checkEntitiesViewable($entities);
}
@ -205,7 +204,7 @@ class EntityTest extends BrowserKitTest
$entities = $this->createEntityChainBelongingToUser($creator, $updater);
$this->actingAs($updater);
app(UserRepo::class)->destroy($updater);
app(PageRepo::class)->savePageRevision($entities['page']);
app(PageRepo::class)->update($entities['page'], ['html' => '<p>Hello there!</p>']);
$this->checkEntitiesViewable($entities);
}
@ -273,8 +272,7 @@ class EntityTest extends BrowserKitTest
public function test_slug_multi_byte_lower_casing()
{
$entityRepo = app(EntityRepo::class);
$book = $entityRepo->createFromInput('book', [
$book = $this->newBook([
'name' => 'КНИГА'
]);
@ -284,8 +282,7 @@ class EntityTest extends BrowserKitTest
public function test_slug_format()
{
$entityRepo = app(EntityRepo::class);
$book = $entityRepo->createFromInput('book', [
$book = $this->newBook([
'name' => 'PartA / PartB / PartC'
]);

View File

@ -1,8 +1,7 @@
<?php namespace Tests;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Repos\PageRepo;
class PageContentTest extends TestCase
{
@ -242,4 +241,66 @@ class PageContentTest extends TestCase
$updatedPage = Page::where('id', '=', $page->id)->first();
$this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
}
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>';
$pageContent = new PageContent(new Page(['html' => $content]));
$navMap = $pageContent->getNavigation($content);
$this->assertCount(3, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h1',
'link' => '#testa',
'text' => 'Hello',
'level' => 1,
], $navMap[0]);
$this->assertArrayMapIncludes([
'nodeName' => 'h2',
'link' => '#testb',
'text' => 'There',
'level' => 2,
], $navMap[1]);
$this->assertArrayMapIncludes([
'nodeName' => 'h3',
'link' => '#testc',
'text' => 'Donkey',
'level' => 3,
], $navMap[2]);
}
public function test_get_page_nav_does_not_show_empty_titles()
{
$content = '<h1 id="testa">Hello</h1><h2 id="testb">&nbsp;</h2><h3 id="testc"></h3>';
$pageContent = new PageContent(new Page(['html' => $content]));
$navMap = $pageContent->getNavigation($content);
$this->assertCount(1, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h1',
'link' => '#testa',
'text' => 'Hello'
], $navMap[0]);
}
public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used()
{
$content = '<h4 id="testa">Hello</h4><h5 id="testb">There</h5><h6 id="testc">Donkey</h6>';
$pageContent = new PageContent(new Page(['html' => $content]));
$navMap = $pageContent->getNavigation($content);
$this->assertCount(3, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h4',
'level' => 1,
], $navMap[0]);
$this->assertArrayMapIncludes([
'nodeName' => 'h5',
'level' => 2,
], $navMap[1]);
$this->assertArrayMapIncludes([
'nodeName' => 'h6',
'level' => 3,
], $navMap[2]);
}
}

View File

@ -1,11 +1,14 @@
<?php namespace Tests;
use BookStack\Entities\Repos\PageRepo;
class PageDraftTest extends BrowserKitTest
{
protected $page;
/**
* @var PageRepo
*/
protected $pageRepo;
public function setUp(): void
@ -85,11 +88,11 @@ class PageDraftTest extends BrowserKitTest
$newUser = $this->getEditor();
$this->actingAs($newUser)->visit('/')
->visit($book->getUrl() . '/create-page')
->visit($chapter->getUrl() . '/create-page')
->visit($book->getUrl('/create-page'))
->visit($chapter->getUrl('/create-page'))
->visit($book->getUrl())
->seeInElement('.book-contents', 'New Page');
$this->asAdmin()
->visit($book->getUrl())
->dontSeeInElement('.book-contents', 'New Page')

View File

@ -12,7 +12,7 @@ class PageRevisionTest extends TestCase
$pageRepo = app(PageRepo::class);
$page = Page::first();
$pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$pageRevision = $page->revisions->last();
$revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id);
@ -30,8 +30,8 @@ class PageRevisionTest extends TestCase
$pageRepo = app(PageRepo::class);
$page = Page::first();
$pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
$pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$pageRepo->update($page, ['name' => 'updated page abc123', 'html' => '<p>new contente def456</p>', 'summary' => 'initial page revision testing']);
$pageRepo->update($page, ['name' => 'updated page again', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
$page = Page::find($page->id);
@ -98,7 +98,7 @@ class PageRevisionTest extends TestCase
$beforeRevisionCount = $page->revisions->count();
$currentRevision = $page->getCurrentRevision();
$resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/'));
$resp->assertStatus(400);
$resp->assertRedirect($page->getUrl('/revisions'));
$page = Page::find($page->id);
$afterRevisionCount = $page->revisions->count();

View File

@ -1,6 +1,5 @@
<?php namespace Tests;
use BookStack\Auth\Role;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Page;
@ -20,7 +19,7 @@ class SortTest extends TestCase
{
$this->asAdmin();
$pageRepo = app(PageRepo::class);
$draft = $pageRepo->getDraftPage($this->book);
$draft = $pageRepo->getNewDraftPage($this->book);
$resp = $this->get($this->book->getUrl());
$resp->assertSee($draft->name);
@ -214,7 +213,6 @@ class SortTest extends TestCase
'entity_selection' => 'book:' . $newBook->id,
'name' => 'My copied test page'
]);
$pageCopy = Page::where('name', '=', 'My copied test page')->first();
$movePageResp->assertRedirect($pageCopy->getUrl());

View File

@ -5,14 +5,13 @@ use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Auth\User;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Page;
class RestrictionsTest extends BrowserKitTest
{
/**
* @var \BookStack\Auth\User
* @var User
*/
protected $user;
@ -327,7 +326,7 @@ class RestrictionsTest extends BrowserKitTest
public function test_page_view_restriction()
{
$page = \BookStack\Entities\Page::first();
$page = Page::first();
$pageUrl = $page->getUrl();
$this->actingAs($this->user)
@ -367,7 +366,7 @@ class RestrictionsTest extends BrowserKitTest
public function test_page_delete_restriction()
{
$page = \BookStack\Entities\Page::first();
$page = Page::first();
$pageUrl = $page->getUrl();
$this->actingAs($this->user)
@ -438,7 +437,7 @@ class RestrictionsTest extends BrowserKitTest
public function test_page_restriction_form()
{
$page = \BookStack\Entities\Page::first();
$page = Page::first();
$this->asAdmin()->visit($page->getUrl() . '/permissions')
->see('Page Permissions')
->check('restricted')
@ -665,10 +664,8 @@ class RestrictionsTest extends BrowserKitTest
$this->setEntityRestrictions($firstBook, ['view', 'update']);
$this->setEntityRestrictions($secondBook, ['view']);
$firstBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
['name' => 'first book chapter'], $firstBook);
$secondBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter',
['name' => 'second book chapter'], $secondBook);
$firstBookChapter = $this->newChapter(['name' => 'first book chapter'], $firstBook);
$secondBookChapter = $this->newChapter(['name' => 'second book chapter'], $secondBook);
// Create request data
$reqData = [

View File

@ -1,9 +1,14 @@
<?php namespace Tests;
use BookStack\Auth\User;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Page;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Auth\Role;
use BookStack\Auth\Permissions\PermissionService;
@ -11,6 +16,8 @@ use BookStack\Entities\Repos\PageRepo;
use BookStack\Settings\SettingService;
use BookStack\Uploads\HttpFetcher;
use Illuminate\Support\Env;
use Mockery;
use Throwable;
trait SharedTestHelpers
{
@ -68,7 +75,7 @@ trait SharedTestHelpers
*/
protected function getViewer($attributes = [])
{
$user = \BookStack\Auth\Role::getRole('viewer')->users()->first();
$user = Role::getRole('viewer')->users()->first();
if (!empty($attributes)) $user->forceFill($attributes)->save();
return $user;
}
@ -76,7 +83,7 @@ trait SharedTestHelpers
/**
* Regenerate the permission for an entity.
* @param Entity $entity
* @throws \Throwable
* @throws Throwable
*/
protected function regenEntityPermissions(Entity $entity)
{
@ -87,10 +94,10 @@ trait SharedTestHelpers
/**
* Create and return a new bookshelf.
* @param array $input
* @return \BookStack\Entities\Bookshelf
* @return Bookshelf
*/
public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
return app(EntityRepo::class)->createFromInput('bookshelf', $input);
return app(BookshelfRepo::class)->create($input, []);
}
/**
@ -99,30 +106,30 @@ trait SharedTestHelpers
* @return Book
*/
public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) {
return app(EntityRepo::class)->createFromInput('book', $input);
return app(BookRepo::class)->create($input);
}
/**
* Create and return a new test chapter
* @param array $input
* @param Book $book
* @return \BookStack\Entities\Chapter
* @return Chapter
*/
public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) {
return app(EntityRepo::class)->createFromInput('chapter', $input, $book);
return app(ChapterRepo::class)->create($input, $book);
}
/**
* Create and return a new test page
* @param array $input
* @return Page
* @throws \Throwable
* @throws Throwable
*/
public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) {
$book = Book::first();
$pageRepo = app(PageRepo::class);
$draftPage = $pageRepo->getDraftPage($book);
return $pageRepo->publishPageDraft($draftPage, $input);
$draftPage = $pageRepo->getNewDraftPage($book);
return $pageRepo->publishDraft($draftPage, $input);
}
/**
@ -167,10 +174,10 @@ trait SharedTestHelpers
/**
* Give the given user some permissions.
* @param \BookStack\Auth\User $user
* @param User $user
* @param array $permissions
*/
protected function giveUserPermissions(\BookStack\Auth\User $user, $permissions = [])
protected function giveUserPermissions(User $user, $permissions = [])
{
$newRole = $this->createNewRole($permissions);
$user->attachRole($newRole);
@ -198,7 +205,7 @@ trait SharedTestHelpers
*/
protected function mockHttpFetch($returnData, int $times = 1)
{
$mockHttp = \Mockery::mock(HttpFetcher::class);
$mockHttp = Mockery::mock(HttpFetcher::class);
$this->app[HttpFetcher::class] = $mockHttp;
$mockHttp->shouldReceive('fetch')
->times($times)

View File

@ -1,78 +0,0 @@
<?php
namespace Tests;
use BookStack\Entities\Repos\PageRepo;
class PageRepoTest extends TestCase
{
/**
* @var PageRepo $pageRepo
*/
protected $pageRepo;
protected function setUp(): void
{
parent::setUp();
$this->pageRepo = app()->make(PageRepo::class);
}
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>';
$navMap = $this->pageRepo->getPageNav($content);
$this->assertCount(3, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h1',
'link' => '#testa',
'text' => 'Hello',
'level' => 1,
], $navMap[0]);
$this->assertArrayMapIncludes([
'nodeName' => 'h2',
'link' => '#testb',
'text' => 'There',
'level' => 2,
], $navMap[1]);
$this->assertArrayMapIncludes([
'nodeName' => 'h3',
'link' => '#testc',
'text' => 'Donkey',
'level' => 3,
], $navMap[2]);
}
public function test_get_page_nav_does_not_show_empty_titles()
{
$content = '<h1 id="testa">Hello</h1><h2 id="testb">&nbsp;</h2><h3 id="testc"></h3>';
$navMap = $this->pageRepo->getPageNav($content);
$this->assertCount(1, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h1',
'link' => '#testa',
'text' => 'Hello'
], $navMap[0]);
}
public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used()
{
$content = '<h4 id="testa">Hello</h4><h5 id="testb">There</h5><h6 id="testc">Donkey</h6>';
$navMap = $this->pageRepo->getPageNav($content);
$this->assertCount(3, $navMap);
$this->assertArrayMapIncludes([
'nodeName' => 'h4',
'level' => 1,
], $navMap[0]);
$this->assertArrayMapIncludes([
'nodeName' => 'h5',
'level' => 2,
], $navMap[1]);
$this->assertArrayMapIncludes([
'nodeName' => 'h6',
'level' => 3,
], $navMap[2]);
}
}

View File

@ -223,7 +223,7 @@ class AttachmentTest extends TestCase
{
$admin = $this->getAdmin();
$viewer = $this->getViewer();
$page = Page::first();
$page = Page::first(); /** @var Page $page */
$this->actingAs($admin);
$fileName = 'permission_test.txt';
@ -233,7 +233,7 @@ class AttachmentTest extends TestCase
$page->restricted = true;
$page->permissions()->delete();
$page->save();
$this->app[PermissionService::class]->buildJointPermissionsForEntity($page);
$page->rebuildPermissions();
$page->load('jointPermissions');
$this->actingAs($viewer);

View File

@ -367,7 +367,7 @@ class ImageTest extends TestCase
$image = Image::where('type', '=', 'gallery')->first();
$pageRepo = app(PageRepo::class);
$pageRepo->updatePage($page, $page->book_id, [
$pageRepo->update($page, [
'name' => $page->name,
'html' => $page->html . "<img src=\"{$image->url}\">",
'summary' => ''
@ -379,7 +379,7 @@ class ImageTest extends TestCase
$this->assertCount(0, $toDelete);
// Save a revision of our page without the image;
$pageRepo->updatePage($page, $page->book_id, [
$pageRepo->update($page, [
'name' => $page->name,
'html' => "<p>Hello</p>",
'summary' => ''