# If set to 'false' a limit will not be enforced.
REVISION_LIMIT=50
+# Recycle Bin Lifetime
+# The number of days that content will remain in the recycle bin before
+# being considered for auto-removal. It is not a guarantee that content will
+# be removed after this time.
+# Set to 0 for no recycle bin functionality.
+# Set to -1 for unlimited recycle bin lifetime.
+RECYCLE_BIN_LIFETIME=30
+
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
namespace BookStack\Actions;
use BookStack\Auth\User;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use BookStack\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Support\Str;
/**
- * @property string $key
+ * @property string $type
* @property User $user
* @property Entity $entity
- * @property string $extra
+ * @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
- * @property int $book_id
*/
class Activity extends Model
{
/**
* Get the user this activity relates to.
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
- public function user()
+ public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
- * Returns text from the language files, Looks up by using the
- * activity key.
+ * Returns text from the language files, Looks up by using the activity key.
*/
- public function getText()
+ public function getText(): string
{
- return trans('activities.' . $this->key);
+ return trans('activities.' . $this->type);
+ }
+
+ /**
+ * Check if this activity is intended to be for an entity.
+ */
+ public function isForEntity(): bool
+ {
+ return Str::startsWith($this->type, [
+ 'page_', 'chapter_', 'book_', 'bookshelf_'
+ ]);
}
/**
*/
public function isSimilarTo(Activity $activityB): bool
{
- return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
+ return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
}
}
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
-use BookStack\Entities\Entity;
-use Illuminate\Support\Collection;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Log;
class ActivityService
{
protected $activity;
- protected $user;
protected $permissionService;
- /**
- * ActivityService constructor.
- */
public function __construct(Activity $activity, PermissionService $permissionService)
{
$this->activity = $activity;
$this->permissionService = $permissionService;
- $this->user = user();
}
/**
- * Add activity data to database.
+ * Add activity data to database for an entity.
*/
- public function add(Entity $entity, string $activityKey, ?int $bookId = null)
+ public function addForEntity(Entity $entity, string $type)
{
- $activity = $this->newActivityForUser($activityKey, $bookId);
+ $activity = $this->newActivityForUser($type);
$entity->activity()->save($activity);
- $this->setNotification($activityKey);
+ $this->setNotification($type);
}
/**
- * Adds a activity history with a message, without binding to a entity.
+ * Add a generic activity event to the database.
+ * @param string|Loggable $detail
*/
- public function addMessage(string $activityKey, string $message, ?int $bookId = null)
+ public function add(string $type, $detail = '')
{
- $this->newActivityForUser($activityKey, $bookId)->forceFill([
- 'extra' => $message
- ])->save();
+ if ($detail instanceof Loggable) {
+ $detail = $detail->logDescriptor();
+ }
- $this->setNotification($activityKey);
+ $activity = $this->newActivityForUser($type);
+ $activity->detail = $detail;
+ $activity->save();
+ $this->setNotification($type);
}
/**
* Get a new activity instance for the current user.
*/
- protected function newActivityForUser(string $key, ?int $bookId = null): Activity
+ protected function newActivityForUser(string $type): Activity
{
return $this->activity->newInstance()->forceFill([
- 'key' => strtolower($key),
- 'user_id' => $this->user->id,
- 'book_id' => $bookId ?? 0,
+ 'type' => strtolower($type),
+ 'user_id' => user()->id,
]);
}
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
*/
- public function removeEntity(Entity $entity): Collection
+ public function removeEntity(Entity $entity)
{
- $activities = $entity->activity()->get();
$entity->activity()->update([
- 'extra' => $entity->name,
- 'entity_id' => 0,
- 'entity_type' => '',
+ 'detail' => $entity->name,
+ 'entity_id' => null,
+ 'entity_type' => null,
]);
- return $activities;
}
/**
*/
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
+ /** @var [string => int[]] $queryIds */
+ $queryIds = [$entity->getMorphClass() => [$entity->id]];
+
if ($entity->isA('book')) {
- $query = $this->activity->newQuery()->where('book_id', '=', $entity->id);
- } else {
- $query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
- ->where('entity_id', '=', $entity->id);
+ $queryIds[(new Chapter)->getMorphClass()] = $entity->chapters()->visible()->pluck('id');
+ }
+ if ($entity->isA('book') || $entity->isA('chapter')) {
+ $queryIds[(new Page)->getMorphClass()] = $entity->pages()->visible()->pluck('id');
}
- $activity = $this->permissionService
- ->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
- ->orderBy('created_at', 'desc')
- ->with(['entity', 'user.avatar'])
+ $query = $this->activity->newQuery();
+ $query->where(function (Builder $query) use ($queryIds) {
+ foreach ($queryIds as $morphClass => $idArr) {
+ $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
+ $innerQuery->where('entity_type', '=', $morphClass)
+ ->whereIn('entity_id', $idArr);
+ });
+ }
+ });
+
+ $activity = $query->orderBy('created_at', 'desc')
+ ->with(['entity' => function (Relation $query) {
+ $query->withTrashed();
+ }, 'user.avatar'])
->skip($count * ($page - 1))
->take($count)
->get();
/**
* Flashes a notification message to the session if an appropriate message is available.
*/
- protected function setNotification(string $activityKey)
+ protected function setNotification(string $type)
{
- $notificationTextKey = 'activities.' . $activityKey . '_notification';
+ $notificationTextKey = 'activities.' . $type . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
session()->flash('success', $message);
--- /dev/null
+<?php namespace BookStack\Actions;
+
+class ActivityType
+{
+ const PAGE_CREATE = 'page_create';
+ const PAGE_UPDATE = 'page_update';
+ const PAGE_DELETE = 'page_delete';
+ const PAGE_RESTORE = 'page_restore';
+ const PAGE_MOVE = 'page_move';
+
+ const CHAPTER_CREATE = 'chapter_create';
+ const CHAPTER_UPDATE = 'chapter_update';
+ const CHAPTER_DELETE = 'chapter_delete';
+ const CHAPTER_MOVE = 'chapter_move';
+
+ const BOOK_CREATE = 'book_create';
+ const BOOK_UPDATE = 'book_update';
+ const BOOK_DELETE = 'book_delete';
+ const BOOK_SORT = 'book_sort';
+
+ const BOOKSHELF_CREATE = 'bookshelf_create';
+ const BOOKSHELF_UPDATE = 'bookshelf_update';
+ const BOOKSHELF_DELETE = 'bookshelf_delete';
+
+ const COMMENTED_ON = 'commented_on';
+ const PERMISSIONS_UPDATE = 'permissions_update';
+
+ const SETTINGS_UPDATE = 'settings_update';
+ const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
+
+ const RECYCLE_BIN_EMPTY = 'recycle_bin_empty';
+ const RECYCLE_BIN_RESTORE = 'recycle_bin_restore';
+ const RECYCLE_BIN_DESTROY = 'recycle_bin_destroy';
+
+ const USER_CREATE = 'user_create';
+ const USER_UPDATE = 'user_update';
+ const USER_DELETE = 'user_delete';
+
+ const API_TOKEN_CREATE = 'api_token_create';
+ const API_TOKEN_UPDATE = 'api_token_update';
+ const API_TOKEN_DELETE = 'api_token_delete';
+
+ const ROLE_CREATE = 'role_create';
+ const ROLE_UPDATE = 'role_update';
+ const ROLE_DELETE = 'role_delete';
+
+ const AUTH_PASSWORD_RESET = 'auth_password_reset_request';
+ const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
+ const AUTH_LOGIN = 'auth_login';
+ const AUTH_REGISTER = 'auth_register';
+}
\ No newline at end of file
<?php namespace BookStack\Actions;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use League\CommonMark\CommonMarkConverter;
+use BookStack\Facades\Activity as ActivityService;
/**
* Class CommentRepo
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
+ ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON);
return $comment;
}
use BookStack\Model;
-/**
- * Class Attribute
- * @package BookStack
- */
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
- protected $hidden = ['id', 'entity_id', 'entity_type'];
+ protected $hidden = ['id', 'entity_id', 'entity_type', 'created_at', 'updated_at'];
/**
* Get the entity that this tag belongs to
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use DB;
use Illuminate\Support\Collection;
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Book;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Entity;
use BookStack\Entities\EntityProvider;
use DB;
use Illuminate\Support\Collection;
/**
* Add a view to the given entity.
- * @param \BookStack\Entities\Entity $entity
+ * @param \BookStack\Entities\Models\Entity $entity
* @return int
*/
public function add(Entity $entity)
/**
* Get all recently viewed entities for the current user.
- * @param int $count
- * @param int $page
- * @param Entity|bool $filterModel
- * @return mixed
*/
- public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false)
+ public function getUserRecentlyViewed(int $count = 10, int $page = 1)
{
$user = user();
if ($user === null || $user->isDefault()) {
return collect();
}
- $query = $this->permissionService
- ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type');
-
- if ($filterModel) {
- $query = $query->where('viewable_type', '=', $filterModel->getMorphClass());
+ $all = collect();
+ /** @var Entity $instance */
+ foreach ($this->entityProvider->all() as $name => $instance) {
+ $items = $instance::visible()->withLastView()
+ ->orderBy('last_viewed_at', 'desc')
+ ->skip($count * ($page - 1))
+ ->take($count)
+ ->get();
+ $all = $all->concat($items);
}
- $query = $query->where('user_id', '=', $user->id);
- $viewables = $query->with('viewable')->orderBy('updated_at', 'desc')
- ->skip($count * $page)->take($count)->get()->pluck('viewable');
- return $viewables;
+ return $all->sortByDesc('last_viewed_at')->slice(0, $count);
}
/**
<?php namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
+use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use ReflectionClass;
protected $reflectionClasses = [];
protected $controllerClasses = [];
+ /**
+ * Load the docs form the cache if existing
+ * otherwise generate and store in the cache.
+ */
+ public static function generateConsideringCache(): Collection
+ {
+ $appVersion = trim(file_get_contents(base_path('version')));
+ $cacheKey = 'api-docs::' . $appVersion;
+ if (Cache::has($cacheKey) && config('app.env') === 'production') {
+ $docs = Cache::get($cacheKey);
+ } else {
+ $docs = (new static())->generate();
+ Cache::put($cacheKey, $docs, 60 * 24);
+ }
+ return $docs;
+ }
+
/**
* Generate API documentation.
*/
- public function generate(): Collection
+ protected function generate(): Collection
{
$apiRoutes = $this->getFlatApiRoutes();
$apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
/**
* Load body params and their rules by inspecting the given class and method name.
- * @throws \Illuminate\Contracts\Container\BindingResolutionException
+ * @throws BindingResolutionException
*/
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
{
<?php namespace BookStack\Api;
use BookStack\Auth\User;
+use BookStack\Interfaces\Loggable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
-class ApiToken extends Model
+/**
+ * Class ApiToken
+ * @property int $id
+ * @property string $token_id
+ * @property string $secret
+ * @property string $name
+ * @property Carbon $expires_at
+ * @property User $user
+ */
+class ApiToken extends Model implements Loggable
{
protected $fillable = ['name', 'expires_at'];
protected $casts = [
{
return Carbon::now()->addYears(100)->format('Y-m-d');
}
+
+ /**
+ * @inheritdoc
+ */
+ public function logDescriptor(): string
+ {
+ return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
+ }
}
* guard with 'remember' functionality removed. Basic auth and event emission
* has also been removed to keep this simple. Designed to be extended by external
* Auth Guards.
- *
- * @package Illuminate\Auth
*/
class ExternalBaseSessionGuard implements StatefulGuard
{
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
- *
- * @package BookStack\Auth\Access\Guards
*/
class Saml2SessionGuard extends ExternalBaseSessionGuard
{
* Class Ldap
* An object-orientated thin abstraction wrapper for common PHP LDAP functions.
* Allows the standard LDAP functions to be mocked for testing.
- * @package BookStack\Services
*/
class Ldap
{
<?php namespace BookStack\Auth\Access;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
use Exception;
class RegistrationService
$newUser->socialAccounts()->save($socialAccount);
}
+ Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
+
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
<?php namespace BookStack\Auth\Access;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
use Exception;
use Illuminate\Support\Str;
use OneLogin\Saml2\Auth;
}
auth()->login($user);
+ Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
return $user;
}
}
<?php namespace BookStack\Auth\Access;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
+use BookStack\Facades\Activity;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
// Simply log the user into the application.
if (!$isLoggedIn && $socialAccount !== null) {
auth()->login($socialAccount->user);
+ Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
return redirect()->intended('/');
}
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Role;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Entity;
use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
use BookStack\Ownable;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
/**
* PermissionService constructor.
- * @param JointPermission $jointPermission
- * @param EntityPermission $entityPermission
- * @param Role $role
- * @param Connection $db
- * @param EntityProvider $entityProvider
*/
public function __construct(
JointPermission $jointPermission,
/**
* Prepare the local entity cache and ensure it's empty
- * @param \BookStack\Entities\Entity[] $entities
+ * @param \BookStack\Entities\Models\Entity[] $entities
*/
protected function readyEntityCache($entities = [])
{
/**
* Get a chapter via ID, Checks local cache
* @param $chapterId
- * @return \BookStack\Entities\Book
+ * @return \BookStack\Entities\Models\Book
*/
protected function getChapter($chapterId)
{
});
// Chunk through all bookshelves
- $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+ $this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'created_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
*/
protected function bookFetchQuery()
{
- return $this->entityProvider->book->newQuery()
+ return $this->entityProvider->book->withTrashed()->newQuery()
->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) {
- $query->select(['id', 'restricted', 'created_by', 'book_id']);
+ $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id']);
}, 'pages' => function ($query) {
- $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
+ $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']);
}]);
}
/**
* Rebuild the entity jointPermissions for a particular entity.
- * @param \BookStack\Entities\Entity $entity
+ * @param \BookStack\Entities\Models\Entity $entity
* @throws \Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
/**
* Delete all of the entity jointPermissions for a list of entities.
- * @param \BookStack\Entities\Entity[] $entities
+ * @param \BookStack\Entities\Models\Entity[] $entities
* @throws \Throwable
*/
protected function deleteManyJointPermissionsForEntities($entities)
/**
* Get the actions related to an entity.
- * @param \BookStack\Entities\Entity $entity
+ * @param \BookStack\Entities\Models\Entity $entity
* @return array
*/
protected function getActions(Entity $entity)
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
- * @param \BookStack\Entities\Entity $entity
+ * @param \BookStack\Entities\Models\Entity $entity
* @param Role $role
* @param $action
* @param $permissionAll
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
- * @param \BookStack\Entities\Entity $entity
+ * @param \BookStack\Entities\Models\Entity $entity
* @param $action
* @return bool|mixed
*/
/**
* Add restrictions for a generic entity
* @param string $entityType
- * @param Builder|\BookStack\Entities\Entity $query
+ * @param Builder|\BookStack\Entities\Models\Entity $query
* @param string $action
* @return Builder
*/
<?php namespace BookStack\Auth\Permissions;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
+use BookStack\Facades\Activity;
use Exception;
use Illuminate\Database\Eloquent\Collection;
-use Illuminate\Support\Str;
class PermissionsRepo
{
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
$this->assignRolePermissions($role, $permissions);
$this->permissionService->buildJointPermissionForRole($role);
+ Activity::add(ActivityType::ROLE_CREATE, $role);
return $role;
}
$role->fill($roleData);
$role->save();
$this->permissionService->buildJointPermissionForRole($role);
+ Activity::add(ActivityType::ROLE_UPDATE, $role);
}
/**
* Assign an list of permission names to an role.
*/
- public function assignRolePermissions(Role $role, array $permissionNameArray = [])
+ protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
}
$this->permissionService->deleteJointPermissionsForRole($role);
+ Activity::add(ActivityType::ROLE_DELETE, $role);
$role->delete();
}
}
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
+use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property string $external_auth_id
* @property string $system_name
*/
-class Role extends Model
+class Role extends Model implements Loggable
{
protected $fillable = ['display_name', 'description', 'external_auth_id'];
/**
* The roles that belong to the role.
*/
- public function users()
+ public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
}
/**
* The RolePermissions that belong to the role.
*/
- public function permissions()
+ public function permissions(): BelongsToMany
{
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
}
{
return static::query()->where('system_name', '!=', 'admin')->get();
}
+
+ /**
+ * @inheritdoc
+ */
+ public function logDescriptor(): string
+ {
+ return "({$this->id}) {$this->display_name}";
+ }
}
<?php namespace BookStack\Auth;
+use BookStack\Interfaces\Loggable;
use BookStack\Model;
-class SocialAccount extends Model
+/**
+ * Class SocialAccount
+ * @property string $driver
+ * @property User $user
+ */
+class SocialAccount extends Model implements Loggable
{
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
{
return $this->belongsTo(User::class);
}
+
+ /**
+ * @inheritDoc
+ */
+ public function logDescriptor(): string
+ {
+ return "{$this->driver}; {$this->user->logDescriptor()}";
+ }
}
<?php namespace BookStack\Auth;
+use BookStack\Actions\Activity;
use BookStack\Api\ApiToken;
+use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Notifications\Notifiable;
/**
* Class User
- * @package BookStack\Auth
* @property string $id
* @property string $name
* @property string $email
* @property string $external_auth_id
* @property string $system_name
*/
-class User extends Model implements AuthenticatableContract, CanResetPasswordContract
+class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable
{
use Authenticatable, CanResetPassword, Notifiable;
return $this->hasMany(ApiToken::class);
}
+ /**
+ * Get the latest activity instance for this user.
+ */
+ public function latestActivity(): HasOne
+ {
+ return $this->hasOne(Activity::class)->latest();
+ }
+
/**
* Get the url for editing this user.
*/
{
$this->notify(new ResetPassword($token));
}
+
+ /**
+ * @inheritdoc
+ */
+ public function logDescriptor(): string
+ {
+ return "({$this->id}) {$this->name}";
+ }
}
<?php namespace BookStack\Auth;
use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\Image;
+use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Pagination\LengthAwarePaginator;
use Images;
use Log;
class UserRepo
{
-
- protected $user;
- protected $role;
+ protected $userAvatar;
/**
* UserRepo constructor.
*/
- public function __construct(User $user, Role $role)
+ public function __construct(UserAvatars $userAvatar)
{
- $this->user = $user;
- $this->role = $role;
+ $this->userAvatar = $userAvatar;
}
/**
*/
public function getByEmail(string $email): ?User
{
- return $this->user->where('email', '=', $email)->first();
+ return User::query()->where('email', '=', $email)->first();
}
/**
- * @param int $id
- * @return User
+ * Get a user by their ID.
*/
- public function getById($id)
+ public function getById(int $id): User
{
- return $this->user->newQuery()->findOrFail($id);
+ return User::query()->findOrFail($id);
}
/**
* Get all the users with their permissions.
- * @return Builder|static
*/
- public function getAllUsers()
+ public function getAllUsers(): Collection
{
- return $this->user->with('roles', 'avatar')->orderBy('name', 'asc')->get();
+ return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
/**
* Get all the users with their permissions in a paginated format.
- * @param int $count
- * @param $sortData
- * @return Builder|static
*/
- public function getAllUsersPaginatedAndSorted($count, $sortData)
+ public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
{
- $query = $this->user->with('roles', 'avatar')->orderBy($sortData['sort'], $sortData['order']);
+ $sort = $sortData['sort'];
+ if ($sort === 'latest_activity') {
+ $sort = \BookStack\Actions\Activity::query()->select('created_at')
+ ->whereColumn('activities.user_id', 'users.id')
+ ->latest()
+ ->take(1);
+ }
+
+ $query = User::query()->with(['roles', 'avatar', 'latestActivity'])
+ ->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
/**
* Assign a user to a system-level role.
- * @param User $user
- * @param $systemRoleName
* @throws NotFoundException
*/
- public function attachSystemRole(User $user, $systemRoleName)
+ public function attachSystemRole(User $user, string $systemRoleName)
{
- $role = $this->role->newQuery()->where('system_name', '=', $systemRoleName)->first();
- if ($role === null) {
+ $role = Role::getSystemRole($systemRoleName);
+ if (is_null($role)) {
throw new NotFoundException("Role '{$systemRoleName}' not found");
}
$user->attachRole($role);
/**
* Checks if the give user is the only admin.
- * @param User $user
- * @return bool
*/
- public function isOnlyAdmin(User $user)
+ public function isOnlyAdmin(User $user): bool
{
if (!$user->hasSystemRole('admin')) {
return false;
}
- $adminRole = $this->role->getSystemRole('admin');
- if ($adminRole->users->count() > 1) {
+ $adminRole = Role::getSystemRole('admin');
+ if ($adminRole->users()->count() > 1) {
return false;
}
+
return true;
}
/**
* Set the assigned user roles via an array of role IDs.
- * @param User $user
- * @param array $roles
* @throws UserUpdateException
*/
public function setUserRoles(User $user, array $roles)
/**
* Check if the given user is the last admin and their new roles no longer
* contains the admin role.
- * @param User $user
- * @param array $newRoles
- * @return bool
*/
protected function demotingLastAdmin(User $user, array $newRoles) : bool
{
if ($this->isOnlyAdmin($user)) {
- $adminRole = $this->role->getSystemRole('admin');
+ $adminRole = Role::getSystemRole('admin');
if (!in_array(strval($adminRole->id), $newRoles)) {
return true;
}
*/
public function create(array $data, bool $emailConfirmed = false): User
{
- return $this->user->forceCreate([
+ $details = [
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
- ]);
+ ];
+ return User::query()->forceCreate($details);
}
/**
* Remove the given user from storage, Delete all related content.
- * @param User $user
* @throws Exception
*/
public function destroy(User $user)
$user->delete();
// Delete user profile images
- $profileImages = Image::where('type', '=', 'user')->where('uploaded_to', '=', $user->id)->get();
+ $profileImages = Image::query()->where('type', '=', 'user')
+ ->where('uploaded_to', '=', $user->id)
+ ->get();
+
foreach ($profileImages as $image) {
Images::destroy($image);
}
/**
* Get the latest activity for a user.
- * @param User $user
- * @param int $count
- * @param int $page
- * @return array
*/
- public function getActivity(User $user, $count = 20, $page = 0)
+ public function getActivity(User $user, int $count = 20, int $page = 0): array
{
return Activity::userActivity($user, $count, $page);
}
/**
* Get the roles in the system that are assignable to a user.
- * @return mixed
*/
- public function getAllRoles()
+ public function getAllRoles(): Collection
{
- return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
+ return Role::query()->orderBy('display_name', 'asc')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
- * @param User $user
- * @return bool
*/
- public function downloadAndAssignUserAvatar(User $user)
+ public function downloadAndAssignUserAvatar(User $user): void
{
- if (!Images::avatarFetchEnabled()) {
- return false;
- }
-
try {
- $avatar = Images::saveUserAvatar($user);
- $user->avatar()->associate($avatar);
- $user->save();
- return true;
+ $this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
- return false;
}
}
}
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50),
+ // The number of days that content will remain in the recycle bin before
+ // being considered for auto-removal. It is not a guarantee that content will
+ // be removed after this time.
+ // Set to 0 for no recycle bin functionality.
+ // Set to -1 for unlimited recycle bin lifetime.
+ 'recycle_bin_lifetime' => env('RECYCLE_BIN_LIFETIME', 30),
+
// Allow <script> tags to entered within page content.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.
BookStack\Providers\EventServiceProvider::class,
BookStack\Providers\RouteServiceProvider::class,
BookStack\Providers\CustomFacadeProvider::class,
+ BookStack\Providers\CustomValidationServiceProvider::class,
],
/*
namespace BookStack\Console\Commands;
-use BookStack\Entities\PageRevision;
+use BookStack\Entities\Models\PageRevision;
use Illuminate\Console\Command;
class ClearRevisions extends Command
namespace BookStack\Console\Commands;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Console\Command;
/**
* Create a new command instance.
- *
- * @param UserRepo $userRepo
*/
public function __construct(UserRepo $userRepo)
{
namespace BookStack\Console\Commands;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Tools\SearchIndex;
use DB;
use Illuminate\Console\Command;
*/
protected $description = 'Re-index all content for searching';
- protected $searchService;
+ protected $searchIndex;
/**
* Create a new command instance.
- *
- * @param SearchService $searchService
*/
- public function __construct(SearchService $searchService)
+ public function __construct(SearchIndex $searchIndex)
{
parent::__construct();
- $this->searchService = $searchService;
+ $this->searchIndex = $searchIndex;
}
/**
$connection = DB::getDefaultConnection();
if ($this->option('database') !== null) {
DB::setDefaultConnection($this->option('database'));
- $this->searchService->setConnection(DB::connection($this->option('database')));
}
- $this->searchService->indexAllEntities();
+ $this->searchIndex->indexAllEntities();
DB::setDefaultConnection($connection);
$this->comment('Search index regenerated');
}
<?php namespace BookStack\Entities;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ShelfContext;
use Illuminate\View\View;
class BreadcrumbsViewComposer
/**
* BreadcrumbsViewComposer constructor.
- * @param EntityContext $entityContextManager
+ * @param ShelfContext $entityContextManager
*/
- public function __construct(EntityContext $entityContextManager)
+ public function __construct(ShelfContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
}
+++ /dev/null
-<?php namespace BookStack\Entities;
-
-use Illuminate\Support\Collection;
-
-/**
- * Class Chapter
- * @property Collection<Page> $pages
- */
-class Chapter extends BookChild
-{
- public $searchFactor = 1.3;
-
- protected $fillable = ['name', 'description', 'priority', 'book_id'];
- protected $hidden = ['restricted', 'pivot'];
-
- /**
- * Get the pages that this chapter contains.
- * @param string $dir
- * @return mixed
- */
- public function pages($dir = 'ASC')
- {
- return $this->hasMany(Page::class)->orderBy('priority', $dir);
- }
-
- /**
- * Get the url of this chapter.
- * @param string|bool $path
- * @return string
- */
- public function getUrl($path = false)
- {
- $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
- $fullPath = '/books/' . urlencode($bookSlug) . '/chapter/' . urlencode($this->slug);
-
- if ($path !== false) {
- $fullPath .= '/' . trim($path, '/');
- }
-
- return url($fullPath);
- }
-
- /**
- * Get an excerpt of this chapter's description to the specified length or less.
- * @param int $length
- * @return string
- */
- public function getExcerpt(int $length = 100)
- {
- $description = $this->text ?? $this->description;
- return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
- }
-
- /**
- * Get the visible pages in this chapter.
- */
- public function getVisiblePages(): Collection
- {
- return $this->pages()->visible()
- ->orderBy('draft', 'desc')
- ->orderBy('priority', 'asc')
- ->get();
- }
-}
<?php namespace BookStack\Entities;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
+
/**
* Class EntityProvider
*
* Provides access to the core entity models.
* Wrapped up in this provider since they are often used together
* so this is a neater alternative to injecting all in individually.
- *
- * @package BookStack\Entities
*/
class EntityProvider
{
*/
public $pageRevision;
- /**
- * EntityProvider constructor.
- */
- public function __construct(
- Bookshelf $bookshelf,
- Book $book,
- Chapter $chapter,
- Page $page,
- PageRevision $pageRevision
- ) {
- $this->bookshelf = $bookshelf;
- $this->book = $book;
- $this->chapter = $chapter;
- $this->page = $page;
- $this->pageRevision = $pageRevision;
+
+ public function __construct()
+ {
+ $this->bookshelf = new Bookshelf();
+ $this->book = new Book();
+ $this->chapter = new Chapter();
+ $this->page = new Page();
+ $this->pageRevision = new PageRevision();
}
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
+ * @return [string => Entity]
*/
public function all(): array
{
+++ /dev/null
-<?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);
- }
- }
-}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Exception;
* @property string $description
* @property int $image_id
* @property Image|null $cover
- * @package BookStack\Entities
*/
class Book extends Entity implements HasCoverImage
{
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
- protected $hidden = ['restricted', 'pivot', 'image_id'];
+ protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
/**
* Get the url for this book.
- * @param string|bool $path
- * @return string
*/
- public function getUrl($path = false)
+ public function getUrl(string $path = ''): string
{
- if ($path !== false) {
- return url('/books/' . urlencode($this->slug) . '/' . trim($path, '/'));
- }
- return url('/books/' . urlencode($this->slug));
+ return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
}
/**
$chapters = $this->chapters()->visible()->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
-
- /**
- * Get an excerpt of this book's description to the specified length or less.
- * @param int $length
- * @return string
- */
- public function getExcerpt(int $length = 100)
- {
- $description = $this->description;
- return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
- }
}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Book;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property Book $book
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
-class BookChild extends Entity
+abstract class BookChild extends Entity
{
/**
$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) {
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
protected $fillable = ['name', 'description', 'image_id'];
- protected $hidden = ['restricted', 'image_id'];
+ protected $hidden = ['restricted', 'image_id', 'deleted_at'];
/**
* Get the books in this shelf.
/**
* Get the url for this bookshelf.
- * @param string|bool $path
- * @return string
*/
- public function getUrl($path = false)
+ public function getUrl(string $path = ''): string
{
- if ($path !== false) {
- return url('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
- }
- return url('/shelves/' . urlencode($this->slug));
+ return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
}
/**
return 'cover_shelf';
}
- /**
- * Get an excerpt of this book's description to the specified length or less.
- * @param int $length
- * @return string
- */
- public function getExcerpt(int $length = 100)
- {
- $description = $this->description;
- return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
- }
-
/**
* Check if this shelf contains the given book.
* @param Book $book
--- /dev/null
+<?php namespace BookStack\Entities\Models;
+
+use Illuminate\Support\Collection;
+
+/**
+ * Class Chapter
+ * @property Collection<Page> $pages
+ */
+class Chapter extends BookChild
+{
+ public $searchFactor = 1.3;
+
+ protected $fillable = ['name', 'description', 'priority', 'book_id'];
+ protected $hidden = ['restricted', 'pivot', 'deleted_at'];
+
+ /**
+ * Get the pages that this chapter contains.
+ * @param string $dir
+ * @return mixed
+ */
+ public function pages($dir = 'ASC')
+ {
+ return $this->hasMany(Page::class)->orderBy('priority', $dir);
+ }
+
+ /**
+ * Get the url of this chapter.
+ */
+ public function getUrl($path = ''): string
+ {
+ $parts = [
+ 'books',
+ urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+ 'chapter',
+ urlencode($this->slug),
+ trim($path, '/'),
+ ];
+
+ return url('/' . implode('/', $parts));
+ }
+
+ /**
+ * Get the visible pages in this chapter.
+ */
+ public function getVisiblePages(): Collection
+ {
+ return $this->pages()->visible()
+ ->orderBy('draft', 'desc')
+ ->orderBy('priority', 'asc')
+ ->get();
+ }
+}
--- /dev/null
+<?php namespace BookStack\Entities\Models;
+
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+class Deletion extends Model implements Loggable
+{
+
+ /**
+ * Get the related deletable record.
+ */
+ public function deletable(): MorphTo
+ {
+ return $this->morphTo('deletable')->withTrashed();
+ }
+
+ /**
+ * The the user that performed the deletion.
+ */
+ public function deleter(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'deleted_by');
+ }
+
+ /**
+ * Create a new deletion record for the provided entity.
+ */
+ public static function createForEntity(Entity $entity): Deletion
+ {
+ $record = (new self())->forceFill([
+ 'deleted_by' => user()->id,
+ 'deletable_type' => $entity->getMorphClass(),
+ 'deletable_id' => $entity->id,
+ ]);
+ $record->save();
+ return $record;
+ }
+
+ public function logDescriptor(): string
+ {
+ $deletable = $this->deletable()->first();
+ return "Deletion ({$this->id}) for {$deletable->getType()} ({$deletable->id}) {$deletable->name}";
+ }
+}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
use BookStack\Actions\Activity;
use BookStack\Actions\Comment;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Entities\Tools\SearchIndex;
+use BookStack\Entities\Tools\SlugGenerator;
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;
+use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Entity
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
- *
- * @package BookStack\Entities
*/
-class Entity extends Ownable
+abstract class Entity extends Ownable
{
+ use SoftDeletes;
/**
* @var string - Name of property where the main text content is found
/**
* Get the entities that are visible to the current user.
*/
- public function scopeVisible(Builder $query)
+ public function scopeVisible(Builder $query): Builder
{
return $this->scopeHasPermission($query, 'view');
}
/**
* Compares this entity to another given entity.
* Matches by comparing class and id.
- * @param $entity
- * @return bool
*/
- public function matches($entity)
+ public function matches(Entity $entity): bool
{
return [get_class($this), $this->id] === [get_class($entity), $entity->id];
}
/**
- * Checks if an entity matches or contains another given entity.
- * @param Entity $entity
- * @return bool
+ * Checks if the current entity matches or contains the given.
*/
- public function matchesOrContains(Entity $entity)
+ public function matchesOrContains(Entity $entity): bool
{
- $matches = [get_class($this), $this->id] === [get_class($entity), $entity->id];
-
- if ($matches) {
+ if ($this->matches($entity)) {
return true;
}
/**
* Gets the activity objects for this entity.
- * @return MorphMany
*/
- public function activity()
+ public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
->orderBy('created_at', 'desc');
/**
* Get View objects for this entity.
*/
- public function views()
+ public function views(): MorphMany
{
return $this->morphMany(View::class, 'viewable');
}
/**
* Get the Tag models that have been user assigned to this entity.
- * @return MorphMany
*/
- public function tags()
+ public function tags(): MorphMany
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
}
/**
* Get the comments for an entity
- * @param bool $orderByCreated
- * @return MorphMany
*/
- public function comments($orderByCreated = true)
+ public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
/**
* Get the related search terms.
- * @return MorphMany
*/
- public function searchTerms()
+ public function searchTerms(): MorphMany
{
return $this->morphMany(SearchTerm::class, 'entity');
}
/**
* Get this entities restrictions.
*/
- public function permissions()
+ public function permissions(): MorphMany
{
return $this->morphMany(EntityPermission::class, 'restrictable');
}
/**
* Check if this entity has a specific restriction set against it.
- * @param $role_id
- * @param $action
- * @return bool
*/
- public function hasRestriction($role_id, $action)
+ public function hasRestriction(int $role_id, string $action): bool
{
return $this->permissions()->where('role_id', '=', $role_id)
->where('action', '=', $action)->count() > 0;
/**
* Get the entity jointPermissions this is connected to.
- * @return MorphMany
*/
- public function jointPermissions()
+ public function jointPermissions(): MorphMany
{
return $this->morphMany(JointPermission::class, 'entity');
}
/**
- * Check if this instance or class is a certain type of entity.
- * Examples of $type are 'page', 'book', 'chapter'
+ * Get the related delete records for this entity.
*/
- public static function isA(string $type): bool
+ public function deletions(): MorphMany
{
- return static::getType() === strtolower($type);
+ return $this->morphMany(Deletion::class, 'deletable');
}
/**
- * Get entity type.
- * @return mixed
+ * Check if this instance or class is a certain type of entity.
+ * Examples of $type are 'page', 'book', 'chapter'
*/
- public static function getType()
+ public static function isA(string $type): bool
{
- return strtolower(static::getClassName());
+ return static::getType() === strtolower($type);
}
/**
- * Get an instance of an entity of the given type.
- * @param $type
- * @return Entity
+ * Get the entity type as a simple lowercase word.
*/
- public static function getEntityInstance($type)
+ public static function getType(): string
{
- $types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
- $className = str_replace([' ', '-', '_'], '', ucwords($type));
- if (!in_array($className, $types)) {
- return null;
- }
-
- return app('BookStack\\Entities\\' . $className);
+ $className = array_slice(explode('\\', static::class), -1, 1)[0];
+ return strtolower($className);
}
/**
/**
* Get the body text of this entity.
- * @return mixed
*/
- public function getText()
+ public function getText(): string
{
- return $this->{$this->textField};
+ return $this->{$this->textField} ?? '';
}
/**
* Get an excerpt of this entity's descriptive content to the specified length.
- * @param int $length
- * @return mixed
*/
- public function getExcerpt(int $length = 100)
+ public function getExcerpt(int $length = 100): string
{
$text = $this->getText();
+
if (mb_strlen($text) > $length) {
$text = mb_substr($text, 0, $length-3) . '...';
}
+
return trim($text);
}
/**
* Get the url of this entity
- * @param $path
- * @return string
*/
- public function getUrl($path = '/')
+ abstract public function getUrl(string $path = '/'): string;
+
+ /**
+ * Get the parent entity if existing.
+ * This is the "static" parent and does not include dynamic
+ * relations such as shelves to books.
+ */
+ public function getParent(): ?Entity
{
- return $path;
+ if ($this->isA('page')) {
+ return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
+ }
+ if ($this->isA('chapter')) {
+ return $this->book()->withTrashed()->first();
+ }
+ return null;
}
/**
*/
public function indexForSearch()
{
- $searchService = app()->make(SearchService::class);
- $searchService->indexEntity(clone $this);
+ app(SearchIndex::class)->indexEntity(clone $this);
}
/**
*/
public function refreshSlug(): string
{
- $generator = new SlugGenerator($this);
- $this->slug = $generator->generate();
+ $this->slug = (new SlugGenerator)->generate($this);
return $this->slug;
}
}
<?php
-namespace BookStack\Entities;
+namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
+use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
public $textField = 'text';
- protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
+ protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
+
+ protected $casts = [
+ 'draft' => 'boolean',
+ 'template' => 'boolean',
+ ];
/**
* Get the entities that are visible to the current user.
*/
- public function scopeVisible(Builder $query)
+ public function scopeVisible(Builder $query): Builder
{
$query = Permissions::enforceDraftVisiblityOnQuery($query);
return parent::scopeVisible($query);
return $array;
}
- /**
- * Get the parent item
- */
- public function parent(): Entity
- {
- return $this->chapter_id ? $this->chapter : $this->book;
- }
-
/**
* Get the chapter that this page is in, If applicable.
* @return BelongsTo
}
/**
- * Get the url for this page.
- * @param string|bool $path
- * @return string
+ * Get the url of this page.
*/
- public function getUrl($path = false)
+ public function getUrl($path = ''): string
{
- $bookSlug = $this->getAttribute('bookSlug') ? $this->getAttribute('bookSlug') : $this->book->slug;
- $midText = $this->draft ? '/draft/' : '/page/';
- $idComponent = $this->draft ? $this->id : urlencode($this->slug);
-
- $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
- if ($path !== false) {
- $url .= '/' . trim($path, '/');
- }
-
- return url($url);
+ $parts = [
+ 'books',
+ urlencode($this->getAttribute('bookSlug') ?? $this->book->slug),
+ $this->draft ? 'draft' : 'page',
+ $this->draft ? $this->id : urlencode($this->slug),
+ trim($path, '/'),
+ ];
+
+ return url('/' . implode('/', $parts));
}
/**
{
return $this->revisions()->first();
}
+
+ /**
+ * Get this page for JSON display.
+ */
+ public function forJsonDisplay(): Page
+ {
+ $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy']);
+ $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
+ $refreshed->html = (new PageContent($refreshed))->render();
+ return $refreshed;
+ }
}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
use BookStack\Model;
use Carbon\Carbon;
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Models;
use BookStack\Model;
namespace BookStack\Entities\Repos;
+use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Entity;
-use BookStack\Entities\HasCoverImage;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
+use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
protected $imageRepo;
- /**
- * BaseRepo constructor.
- * @param $tagRepo
- */
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->tagRepo = $tagRepo;
$entity->save();
$entity->rebuildPermissions();
+ Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE);
}
}
<?php namespace BookStack\Entities\Repos;
+use BookStack\Actions\ActivityType;
use BookStack\Actions\TagRepo;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
use BookStack\Uploads\ImageRepo;
use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
/**
* BookRepo constructor.
- * @param $tagRepo
*/
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
{
{
$book = new Book();
$this->baseRepo->create($book, $input);
+ Activity::addForEntity($book, ActivityType::BOOK_CREATE);
return $book;
}
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
+ Activity::addForEntity($book, ActivityType::BOOK_UPDATE);
return $book;
}
/**
* Remove a book from the system.
- * @throws NotifyException
- * @throws BindingResolutionException
+ * @throws Exception
*/
public function destroy(Book $book)
{
$trashCan = new TrashCan();
- $trashCan->destroyBook($book);
+ $trashCan->softDestroyBook($book);
+ Activity::addForEntity($book, ActivityType::BOOK_DELETE);
+
+ $trashCan->autoClearOld();
}
}
<?php namespace BookStack\Entities\Repos;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\TrashCan;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
+use BookStack\Facades\Activity;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
/**
* BookshelfRepo constructor.
- * @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
+ Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE);
return $shelf;
}
/**
- * Create a new shelf in the system.
+ * Update an existing shelf in the system using the given input.
*/
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{
$this->updateBooks($shelf, $bookIds);
}
+ Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE);
return $shelf;
}
public function destroy(Bookshelf $shelf)
{
$trashCan = new TrashCan();
- $trashCan->destroyShelf($shelf);
+ $trashCan->softDestroyShelf($shelf);
+ Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE);
+ $trashCan->autoClearOld();
}
}
<?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\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
use Exception;
-use Illuminate\Contracts\Container\BindingResolutionException;
-use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class ChapterRepo
/**
* ChapterRepo constructor.
- * @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
+ Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE);
return $chapter;
}
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
+ Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE);
return $chapter;
}
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
- $trashCan->destroyChapter($chapter);
+ $trashCan->softDestroyChapter($chapter);
+ Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE);
+ $trashCan->autoClearOld();
}
/**
throw new MoveOperationException('Chapters can only be moved into books');
}
+ /** @var Book $parent */
$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();
+ Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE);
+
return $parent;
}
}
<?php namespace BookStack\Entities\Repos;
-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 BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
+use BookStack\Facades\Activity;
+use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
* Get a page by ID.
* @throws NotFoundException
*/
- public function getById(int $id): Page
+ public function getById(int $id, array $relations = ['book']): Page
{
- $page = Page::visible()->with(['book'])->find($id);
+ $page = Page::visible()->with($relations)->find($id);
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
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');
- }
+ $this->updateTemplateStatusAndContentFromInput($draft, $input);
- $pageContent = new PageContent($draft);
- $pageContent->setNewHTML($input['html']);
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
- return $draft->refresh();
+ $draft->refresh();
+
+ Activity::addForEntity($draft, ActivityType::PAGE_CREATE);
+ return $draft;
}
/**
$oldHtml = $page->html;
$oldName = $page->name;
- if (isset($input['template']) && userCan('templates-manage')) {
- $page->template = ($input['template'] === 'true');
- }
-
- $pageContent = new PageContent($page);
- $pageContent->setNewHTML($input['html']);
+ $this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
// Update with new details
$this->savePageRevision($page, $summary);
}
+ Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
return $page;
}
+ protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
+ {
+ if (isset($input['template']) && userCan('templates-manage')) {
+ $page->template = ($input['template'] === 'true');
+ }
+
+ $pageContent = new PageContent($page);
+ if (isset($input['html'])) {
+ $pageContent->setNewHTML($input['html']);
+ } else {
+ $pageContent->setNewMarkdown($input['markdown']);
+ }
+ }
+
/**
* Saves a page revision into the system.
*/
{
// If the page itself is a draft simply update that
if ($page->draft) {
- $page->fill($input);
if (isset($input['html'])) {
- $content = new PageContent($page);
- $content->setNewHTML($input['html']);
+ (new PageContent($page))->setNewHTML($input['html']);
}
+ $page->fill($input);
$page->save();
return $page;
}
/**
* Destroy a page from the system.
- * @throws NotifyException
+ * @throws Exception
*/
public function destroy(Page $page)
{
$trashCan = new TrashCan();
- $trashCan->destroyPage($page);
+ $trashCan->softDestroyPage($page);
+ Activity::addForEntity($page, ActivityType::PAGE_DELETE);
+ $trashCan->autoClearOld();
}
/**
$page->save();
$page->indexForSearch();
+ Activity::addForEntity($page, ActivityType::PAGE_RESTORE);
return $page;
}
* @throws MoveOperationException
* @throws PermissionsException
*/
- public function move(Page $page, string $parentIdentifier): Book
+ public function move(Page $page, string $parentIdentifier): Entity
{
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
$page->rebuildPermissions();
- return ($parent instanceof Book ? $parent : $parent->book);
+ Activity::addForEntity($page, ActivityType::PAGE_MOVE);
+ return $parent;
}
/**
*/
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
- $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
+ $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
*/
protected function getNewPriority(Page $page): int
{
- if ($page->parent() instanceof Chapter) {
- $lastPage = $page->parent()->pages('desc')->first();
+ $parent = $page->getParent();
+ if ($parent instanceof Chapter) {
+ $lastPage = $parent->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;
}
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
-use BookStack\Entities\Book;
-use BookStack\Entities\BookChild;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
/**
* BookContents constructor.
- * @param $book
*/
public function __construct(Book $book)
{
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Uploads\ImageService;
use DomPDF;
use Exception;
use SnappyPDF;
use Throwable;
-class ExportService
+class ExportFormatter
{
protected $imageService;
protected function containHtml(string $htmlContent): string
{
$imageTagsOutput = [];
- preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
+ preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace image src with base64 encoded image strings
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use DOMDocument;
use DOMNodeList;
use DOMXPath;
+use League\CommonMark\CommonMarkConverter;
class PageContent
{
{
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
+ $this->page->markdown = '';
+ }
+
+ /**
+ * Update the content of the page with new provided Markdown content.
+ */
+ public function setNewMarkdown(string $markdown)
+ {
+ $this->page->markdown = $markdown;
+ $html = $this->markdownToHtml($markdown);
+ $this->page->html = $this->formatHtml($html);
+ $this->page->text = $this->toPlainText();
+ }
+
+ /**
+ * Convert the given Markdown content to a HTML string.
+ */
+ protected function markdownToHtml(string $markdown): string
+ {
+ $converter = new CommonMarkConverter();
+ return $converter->convertToHtml($markdown);
}
/**
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
-use BookStack\Entities\Page;
-use BookStack\Entities\PageRevision;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
--- /dev/null
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\SearchTerm;
+use Illuminate\Support\Collection;
+
+class SearchIndex
+{
+ /**
+ * @var SearchTerm
+ */
+ protected $searchTerm;
+
+ /**
+ * @var EntityProvider
+ */
+ protected $entityProvider;
+
+
+ public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider)
+ {
+ $this->searchTerm = $searchTerm;
+ $this->entityProvider = $entityProvider;
+ }
+
+
+ /**
+ * Index the given entity.
+ */
+ public function indexEntity(Entity $entity)
+ {
+ $this->deleteEntityTerms($entity);
+ $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
+ $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
+ $terms = array_merge($nameTerms, $bodyTerms);
+ foreach ($terms as $index => $term) {
+ $terms[$index]['entity_type'] = $entity->getMorphClass();
+ $terms[$index]['entity_id'] = $entity->id;
+ }
+ $this->searchTerm->newQuery()->insert($terms);
+ }
+
+ /**
+ * Index multiple Entities at once
+ * @param Entity[] $entities
+ */
+ protected function indexEntities(array $entities)
+ {
+ $terms = [];
+ foreach ($entities as $entity) {
+ $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
+ $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
+ foreach (array_merge($nameTerms, $bodyTerms) as $term) {
+ $term['entity_id'] = $entity->id;
+ $term['entity_type'] = $entity->getMorphClass();
+ $terms[] = $term;
+ }
+ }
+
+ $chunkedTerms = array_chunk($terms, 500);
+ foreach ($chunkedTerms as $termChunk) {
+ $this->searchTerm->newQuery()->insert($termChunk);
+ }
+ }
+
+ /**
+ * Delete and re-index the terms for all entities in the system.
+ */
+ public function indexAllEntities()
+ {
+ $this->searchTerm->newQuery()->truncate();
+
+ foreach ($this->entityProvider->all() as $entityModel) {
+ $selectFields = ['id', 'name', $entityModel->textField];
+ $entityModel->newQuery()
+ ->withTrashed()
+ ->select($selectFields)
+ ->chunk(1000, function (Collection $entities) {
+ $this->indexEntities($entities->all());
+ });
+ }
+ }
+
+ /**
+ * Delete related Entity search terms.
+ */
+ public function deleteEntityTerms(Entity $entity)
+ {
+ $entity->searchTerms()->delete();
+ }
+
+ /**
+ * Create a scored term array from the given text.
+ */
+ protected function generateTermArrayFromText(string $text, int $scoreAdjustment = 1): array
+ {
+ $tokenMap = []; // {TextToken => OccurrenceCount}
+ $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
+ $token = strtok($text, $splitChars);
+
+ while ($token !== false) {
+ if (!isset($tokenMap[$token])) {
+ $tokenMap[$token] = 0;
+ }
+ $tokenMap[$token]++;
+ $token = strtok($splitChars);
+ }
+
+ $terms = [];
+ foreach ($tokenMap as $token => $count) {
+ $terms[] = [
+ 'term' => $token,
+ 'score' => $count * $scoreAdjustment
+ ];
+ }
+
+ return $terms;
+ }
+}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
use Illuminate\Http\Request;
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
-class SearchService
+class SearchRunner
{
- /**
- * @var SearchTerm
- */
- protected $searchTerm;
/**
* @var EntityProvider
*/
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
- /**
- * SearchService constructor.
- */
- public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
+
+ public function __construct(EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{
- $this->searchTerm = $searchTerm;
$this->entityProvider = $entityProvider;
$this->db = $db;
$this->permissionService = $permissionService;
}
- /**
- * Set the database connection
- */
- public function setConnection(Connection $connection)
- {
- $this->db = $connection;
- }
-
/**
* Search all entities in the system.
* The provided count is for each entity to search,
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
+
return $results->sortByDesc('score')->take(20);
}
/**
- * Search a book for entities
+ * Search a chapter for entities
*/
public function searchChapter(int $chapterId, string $searchString): Collection
{
* matching instead of the items themselves.
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/
- public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
+ protected function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
{
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
if ($getCount) {
// Handle normal search terms
if (count($searchOpts->searches) > 0) {
- $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
+ $rawScoreSum = $this->db->raw('SUM(score) as score');
+ $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', $rawScoreSum);
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%');
}
})->groupBy('entity_type', 'entity_id');
- $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
+ $entitySelect->join($this->db->raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
$join->on('id', '=', 'entity_id');
})->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
$entitySelect->mergeBindings($subQuery);
}
// Handle exact term matching
- if (count($searchOpts->exacts) > 0) {
- $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
- foreach ($searchOpts->exacts as $inputTerm) {
- $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
- $query->where('name', 'like', '%'.$inputTerm .'%')
- ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
- });
- }
+ foreach ($searchOpts->exacts as $inputTerm) {
+ $entitySelect->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
+ $query->where('name', 'like', '%'.$inputTerm .'%')
+ ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
});
}
return $query;
}
- /**
- * Index the given entity.
- */
- public function indexEntity(Entity $entity)
- {
- $this->deleteEntityTerms($entity);
- $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
- $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
- $terms = array_merge($nameTerms, $bodyTerms);
- foreach ($terms as $index => $term) {
- $terms[$index]['entity_type'] = $entity->getMorphClass();
- $terms[$index]['entity_id'] = $entity->id;
- }
- $this->searchTerm->newQuery()->insert($terms);
- }
-
- /**
- * Index multiple Entities at once
- * @param \BookStack\Entities\Entity[] $entities
- */
- protected function indexEntities($entities)
- {
- $terms = [];
- foreach ($entities as $entity) {
- $nameTerms = $this->generateTermArrayFromText($entity->name, 5 * $entity->searchFactor);
- $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1 * $entity->searchFactor);
- foreach (array_merge($nameTerms, $bodyTerms) as $term) {
- $term['entity_id'] = $entity->id;
- $term['entity_type'] = $entity->getMorphClass();
- $terms[] = $term;
- }
- }
-
- $chunkedTerms = array_chunk($terms, 500);
- foreach ($chunkedTerms as $termChunk) {
- $this->searchTerm->newQuery()->insert($termChunk);
- }
- }
-
- /**
- * Delete and re-index the terms for all entities in the system.
- */
- public function indexAllEntities()
- {
- $this->searchTerm->truncate();
-
- foreach ($this->entityProvider->all() as $entityModel) {
- $selectFields = ['id', 'name', $entityModel->textField];
- $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) {
- $this->indexEntities($entities);
- });
- }
- }
-
- /**
- * Delete related Entity search terms.
- * @param Entity $entity
- */
- public function deleteEntityTerms(Entity $entity)
- {
- $entity->searchTerms()->delete();
- }
-
- /**
- * Create a scored term array from the given text.
- * @param $text
- * @param float|int $scoreAdjustment
- * @return array
- */
- protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
- {
- $tokenMap = []; // {TextToken => OccurrenceCount}
- $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
- $token = strtok($text, $splitChars);
-
- while ($token !== false) {
- if (!isset($tokenMap[$token])) {
- $tokenMap[$token] = 0;
- }
- $tokenMap[$token]++;
- $token = strtok($splitChars);
- }
-
- $terms = [];
- foreach ($tokenMap as $token => $count) {
- $terms[] = [
- 'term' => $token,
- 'score' => $count * $scoreAdjustment
- ];
- }
- return $terms;
- }
-
-
-
-
/**
* Custom entity search filters
*/
-<?php namespace BookStack\Entities\Managers;
+<?php namespace BookStack\Entities\Tools;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use Illuminate\Session\Store;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
-class EntityContext
+class ShelfContext
{
- protected $session;
-
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
- /**
- * EntityContextManager constructor.
- */
- public function __construct(Store $session)
- {
- $this->session = $session;
- }
-
/**
* Get the current bookshelf context for the given book.
*/
public function getContextualShelfForBook(Book $book): ?Bookshelf
{
- $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
+ $contextBookshelfId = session()->get($this->KEY_SHELF_CONTEXT_ID, null);
if (!is_int($contextBookshelfId)) {
return null;
/**
* Store the current contextual shelf ID.
- * @param int $shelfId
*/
public function setShelfContext(int $shelfId)
{
- $this->session->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
+ session()->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
}
/**
*/
public function clearShelfContext()
{
- $this->session->forget($this->KEY_SHELF_CONTEXT_ID);
+ session()->forget($this->KEY_SHELF_CONTEXT_ID);
}
}
--- /dev/null
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use Illuminate\Support\Collection;
+
+class SiblingFetcher
+{
+
+ /**
+ * Search among the siblings of the entity of given type and id.
+ */
+ public function fetch(string $entityType, int $entityId): Collection
+ {
+ $entity = (new EntityProvider)->get($entityType)->visible()->findOrFail($entityId);
+ $entities = [];
+
+ // Page in chapter
+ if ($entity->isA('page') && $entity->chapter) {
+ $entities = $entity->chapter->getVisiblePages();
+ }
+
+ // Page in book or chapter
+ if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
+ $entities = $entity->book->getDirectChildren();
+ }
+
+ // Book
+ // Gets just the books in a shelf if shelf is in context
+ if ($entity->isA('book')) {
+ $contextShelf = (new ShelfContext)->getContextualShelfForBook($entity);
+ if ($contextShelf) {
+ $entities = $contextShelf->visibleBooks()->get();
+ } else {
+ $entities = Book::visible()->get();
+ }
+ }
+
+ // Shelve
+ if ($entity->isA('bookshelf')) {
+ $entities = Bookshelf::visible()->get();
+ }
+
+ return $entities;
+ }
+}
-<?php namespace BookStack\Entities;
+<?php namespace BookStack\Entities\Tools;
+use BookStack\Entities\Models\Entity;
use Illuminate\Support\Str;
class SlugGenerator
{
- protected $entity;
-
- /**
- * SlugGenerator constructor.
- * @param $entity
- */
- public function __construct(Entity $entity)
- {
- $this->entity = $entity;
- }
-
/**
* Generate a fresh slug for the given entity.
* The slug will generated so it does not conflict within the same parent item.
*/
- public function generate(): string
+ public function generate(Entity $entity): string
{
- $slug = $this->formatNameAsSlug($this->entity->name);
- while ($this->slugInUse($slug)) {
+ $slug = $this->formatNameAsSlug($entity->name);
+ while ($this->slugInUse($slug, $entity)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
* Check if a slug is already in-use for this
* type of model within the same parent.
*/
- protected function slugInUse(string $slug): bool
+ protected function slugInUse(string $slug, Entity $entity): bool
{
- $query = $this->entity->newQuery()->where('slug', '=', $slug);
+ $query = $entity->newQuery()->where('slug', '=', $slug);
- if ($this->entity instanceof BookChild) {
- $query->where('book_id', '=', $this->entity->book_id);
+ if ($entity instanceof BookChild) {
+ $query->where('book_id', '=', $entity->book_id);
}
- if ($this->entity->id) {
- $query->where('id', '!=', $this->entity->id);
+ if ($entity->id) {
+ $query->where('id', '!=', $entity->id);
}
return $query->count() > 0;
--- /dev/null
+<?php namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\HasCoverImage;
+use BookStack\Entities\Models\Page;
+use BookStack\Exceptions\NotifyException;
+use BookStack\Facades\Activity;
+use BookStack\Uploads\AttachmentService;
+use BookStack\Uploads\ImageService;
+use Exception;
+use Illuminate\Support\Carbon;
+
+class TrashCan
+{
+
+ /**
+ * Send a shelf to the recycle bin.
+ */
+ public function softDestroyShelf(Bookshelf $shelf)
+ {
+ Deletion::createForEntity($shelf);
+ $shelf->delete();
+ }
+
+ /**
+ * Send a book to the recycle bin.
+ * @throws Exception
+ */
+ public function softDestroyBook(Book $book)
+ {
+ Deletion::createForEntity($book);
+
+ foreach ($book->pages as $page) {
+ $this->softDestroyPage($page, false);
+ }
+
+ foreach ($book->chapters as $chapter) {
+ $this->softDestroyChapter($chapter, false);
+ }
+
+ $book->delete();
+ }
+
+ /**
+ * Send a chapter to the recycle bin.
+ * @throws Exception
+ */
+ public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
+ {
+ if ($recordDelete) {
+ Deletion::createForEntity($chapter);
+ }
+
+ if (count($chapter->pages) > 0) {
+ foreach ($chapter->pages as $page) {
+ $this->softDestroyPage($page, false);
+ }
+ }
+
+ $chapter->delete();
+ }
+
+ /**
+ * Send a page to the recycle bin.
+ * @throws Exception
+ */
+ public function softDestroyPage(Page $page, bool $recordDelete = true)
+ {
+ if ($recordDelete) {
+ Deletion::createForEntity($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');
+ }
+
+ $page->delete();
+ }
+
+ /**
+ * Remove a bookshelf from the system.
+ * @throws Exception
+ */
+ protected function destroyShelf(Bookshelf $shelf): int
+ {
+ $this->destroyCommonRelations($shelf);
+ $shelf->forceDelete();
+ return 1;
+ }
+
+ /**
+ * Remove a book from the system.
+ * Destroys any child chapters and pages.
+ * @throws Exception
+ */
+ protected function destroyBook(Book $book): int
+ {
+ $count = 0;
+ $pages = $book->pages()->withTrashed()->get();
+ foreach ($pages as $page) {
+ $this->destroyPage($page);
+ $count++;
+ }
+
+ $chapters = $book->chapters()->withTrashed()->get();
+ foreach ($chapters as $chapter) {
+ $this->destroyChapter($chapter);
+ $count++;
+ }
+
+ $this->destroyCommonRelations($book);
+ $book->forceDelete();
+ return $count + 1;
+ }
+
+ /**
+ * Remove a chapter from the system.
+ * Destroys all pages within.
+ * @throws Exception
+ */
+ protected function destroyChapter(Chapter $chapter): int
+ {
+ $count = 0;
+ $pages = $chapter->pages()->withTrashed()->get();
+ if (count($pages)) {
+ foreach ($pages as $page) {
+ $this->destroyPage($page);
+ $count++;
+ }
+ }
+
+ $this->destroyCommonRelations($chapter);
+ $chapter->forceDelete();
+ return $count + 1;
+ }
+
+ /**
+ * Remove a page from the system.
+ * @throws Exception
+ */
+ protected function destroyPage(Page $page): int
+ {
+ $this->destroyCommonRelations($page);
+
+ // Delete Attached Files
+ $attachmentService = app(AttachmentService::class);
+ foreach ($page->attachments as $attachment) {
+ $attachmentService->deleteFile($attachment);
+ }
+
+ $page->forceDelete();
+ return 1;
+ }
+
+ /**
+ * Get the total counts of those that have been trashed
+ * but not yet fully deleted (In recycle bin).
+ */
+ public function getTrashedCounts(): array
+ {
+ $counts = [];
+
+ /** @var Entity $instance */
+ foreach ((new EntityProvider)->all() as $key => $instance) {
+ $counts[$key] = $instance->newQuery()->onlyTrashed()->count();
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Destroy all items that have pending deletions.
+ * @throws Exception
+ */
+ public function empty(): int
+ {
+ $deletions = Deletion::all();
+ $deleteCount = 0;
+ foreach ($deletions as $deletion) {
+ $deleteCount += $this->destroyFromDeletion($deletion);
+ }
+ return $deleteCount;
+ }
+
+ /**
+ * Destroy an element from the given deletion model.
+ * @throws Exception
+ */
+ public function destroyFromDeletion(Deletion $deletion): int
+ {
+ // We directly load the deletable element here just to ensure it still
+ // exists in the event it has already been destroyed during this request.
+ $entity = $deletion->deletable()->first();
+ $count = 0;
+ if ($entity) {
+ $count = $this->destroyEntity($deletion->deletable);
+ }
+ $deletion->delete();
+ return $count;
+ }
+
+ /**
+ * Restore the content within the given deletion.
+ * @throws Exception
+ */
+ public function restoreFromDeletion(Deletion $deletion): int
+ {
+ $shouldRestore = true;
+ $restoreCount = 0;
+ $parent = $deletion->deletable->getParent();
+
+ if ($parent && $parent->trashed()) {
+ $shouldRestore = false;
+ }
+
+ if ($shouldRestore) {
+ $restoreCount = $this->restoreEntity($deletion->deletable);
+ }
+
+ $deletion->delete();
+ return $restoreCount;
+ }
+
+ /**
+ * Automatically clear old content from the recycle bin
+ * depending on the configured lifetime.
+ * Returns the total number of deleted elements.
+ * @throws Exception
+ */
+ public function autoClearOld(): int
+ {
+ $lifetime = intval(config('app.recycle_bin_lifetime'));
+ if ($lifetime < 0) {
+ return 0;
+ }
+
+ $clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
+ $deleteCount = 0;
+
+ $deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
+ foreach ($deletionsToRemove as $deletion) {
+ $deleteCount += $this->destroyFromDeletion($deletion);
+ }
+
+ return $deleteCount;
+ }
+
+ /**
+ * Restore an entity so it is essentially un-deleted.
+ * Deletions on restored child elements will be removed during this restoration.
+ */
+ protected function restoreEntity(Entity $entity): int
+ {
+ $count = 1;
+ $entity->restore();
+
+ $restoreAction = function ($entity) use (&$count) {
+ if ($entity->deletions_count > 0) {
+ $entity->deletions()->delete();
+ }
+
+ $entity->restore();
+ $count++;
+ };
+
+ if ($entity->isA('chapter') || $entity->isA('book')) {
+ $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+ }
+
+ if ($entity->isA('book')) {
+ $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
+ }
+
+ return $count;
+ }
+
+ /**
+ * Destroy the given entity.
+ */
+ protected function destroyEntity(Entity $entity): int
+ {
+ if ($entity->isA('page')) {
+ return $this->destroyPage($entity);
+ }
+ if ($entity->isA('chapter')) {
+ return $this->destroyChapter($entity);
+ }
+ if ($entity->isA('book')) {
+ return $this->destroyBook($entity);
+ }
+ if ($entity->isA('shelf')) {
+ return $this->destroyShelf($entity);
+ }
+ }
+
+ /**
+ * 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();
+ $entity->deletions()->delete();
+
+ if ($entity instanceof HasCoverImage && $entity->cover) {
+ $imageService = app()->make(ImageService::class);
+ $imageService->destroy($entity->cover);
+ }
+ }
+}
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
-class ApiController extends Controller
+abstract class ApiController extends Controller
{
protected $rules = [];
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiDocsGenerator;
-use Cache;
-use Illuminate\Support\Collection;
class ApiDocsController extends ApiController
{
*/
public function display()
{
- $docs = $this->getDocs();
+ $docs = ApiDocsGenerator::generateConsideringCache();
+ $this->setPageTitle(trans('settings.users_api_tokens_docs'));
return view('api-docs.index', [
'docs' => $docs,
]);
/**
* Show a JSON view of the API docs data.
*/
- public function json() {
- $docs = $this->getDocs();
- return response()->json($docs);
- }
-
- /**
- * Get the base docs data.
- * Checks and uses the system cache for quick re-fetching.
- */
- protected function getDocs(): Collection
+ public function json()
{
- $appVersion = trim(file_get_contents(base_path('version')));
- $cacheKey = 'api-docs::' . $appVersion;
- if (Cache::has($cacheKey) && config('app.env') === 'production') {
- $docs = Cache::get($cacheKey);
- } else {
- $docs = (new ApiDocsGenerator())->generate();
- Cache::put($cacheKey, $docs, 60*24);
- }
-
- return $docs;
+ $docs = ApiDocsGenerator::generateConsideringCache();
+ return response()->json($docs);
}
}
<?php namespace BookStack\Http\Controllers\Api;
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\NotifyException;
-use BookStack\Facades\Activity;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
],
];
- /**
- * BooksApiController constructor.
- */
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
$requestData = $this->validate($request, $this->rules['create']);
$book = $this->bookRepo->create($requestData);
- Activity::add($book, 'book_create', $book->id);
-
return response()->json($book);
}
$requestData = $this->validate($request, $this->rules['update']);
$book = $this->bookRepo->update($book, $requestData);
- Activity::add($book, 'book_update', $book->id);
return response()->json($book);
}
/**
- * Delete a single book from the system.
- * @throws NotifyException
- * @throws BindingResolutionException
+ * Delete a single book.
+ * This will typically send the book to the recycle bin.
+ * @throws \Exception
*/
public function delete(string $id)
{
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
- Activity::addMessage('book_delete', $book->name);
-
return response('', 204);
}
}
\ No newline at end of file
<?php namespace BookStack\Http\Controllers\Api;
-use BookStack\Entities\Book;
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Repos\BookRepo;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ExportFormatter;
use Throwable;
class BookExportApiController extends ApiController
{
- protected $bookRepo;
- protected $exportService;
+ protected $exportFormatter;
- /**
- * BookExportController constructor.
- */
- public function __construct(BookRepo $bookRepo, ExportService $exportService)
+ public function __construct(ExportFormatter $exportFormatter)
{
- $this->bookRepo = $bookRepo;
- $this->exportService = $exportService;
- parent::__construct();
+ $this->exportFormatter = $exportFormatter;
}
/**
public function exportPdf(int $id)
{
$book = Book::visible()->findOrFail($id);
- $pdfContent = $this->exportService->bookToPdf($book);
+ $pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
}
public function exportHtml(int $id)
{
$book = Book::visible()->findOrFail($id);
- $htmlContent = $this->exportService->bookToContainedHtml($book);
+ $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->downloadResponse($htmlContent, $book->slug . '.html');
}
public function exportPlainText(int $id)
{
$book = Book::visible()->findOrFail($id);
- $textContent = $this->exportService->bookToPlainText($book);
+ $textContent = $this->exportFormatter->bookToPlainText($book);
return $this->downloadResponse($textContent, $book->slug . '.txt');
}
}
<?php namespace BookStack\Http\Controllers\Api;
-use BookStack\Facades\Activity;
use BookStack\Entities\Repos\BookshelfRepo;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
/**
* BookshelfApiController constructor.
- * @param BookshelfRepo $bookshelfRepo
*/
public function __construct(BookshelfRepo $bookshelfRepo)
{
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
- Activity::add($shelf, 'bookshelf_create', $shelf->id);
return response()->json($shelf);
}
$this->checkOwnablePermission('bookshelf-update', $shelf);
$requestData = $this->validate($request, $this->rules['update']);
-
$bookIds = $request->get('books', null);
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
- Activity::add($shelf, 'bookshelf_update', $shelf->id);
-
return response()->json($shelf);
}
/**
- * Delete a single shelf from the system.
+ * Delete a single shelf.
+ * This will typically send the shelf to the recycle bin.
* @throws Exception
*/
public function delete(string $id)
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->bookshelfRepo->destroy($shelf);
- Activity::addMessage('bookshelf_delete', $shelf->name);
-
return response('', 204);
}
}
\ No newline at end of file
<?php namespace BookStack\Http\Controllers\Api;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Facades\Activity;
use Illuminate\Database\Eloquent\Relations\HasMany;
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
- Activity::add($chapter, 'chapter_create', $book->id);
-
return response()->json($chapter->load(['tags']));
}
$this->checkOwnablePermission('chapter-update', $chapter);
$updatedChapter = $this->chapterRepo->update($chapter, $request->all());
- Activity::add($chapter, 'chapter_update', $chapter->book->id);
-
return response()->json($updatedChapter->load(['tags']));
}
/**
- * Delete a chapter from the system.
+ * Delete a chapter.
+ * This will typically send the chapter to the recycle bin.
*/
public function delete(string $id)
{
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
- Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
-
return response('', 204);
}
}
<?php namespace BookStack\Http\Controllers\Api;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class ChapterExportApiController extends ApiController
{
- protected $chapterRepo;
- protected $exportService;
+ protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
- public function __construct(BookRepo $chapterRepo, ExportService $exportService)
+ public function __construct(ExportFormatter $exportFormatter)
{
- $this->chapterRepo = $chapterRepo;
- $this->exportService = $exportService;
- parent::__construct();
+ $this->exportFormatter = $exportFormatter;
}
/**
public function exportPdf(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
- $pdfContent = $this->exportService->chapterToPdf($chapter);
+ $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
}
public function exportHtml(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
- $htmlContent = $this->exportService->chapterToContainedHtml($chapter);
+ $htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
}
public function exportPlainText(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
- $textContent = $this->exportService->chapterToPlainText($chapter);
+ $textContent = $this->exportFormatter->chapterToPlainText($chapter);
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
}
}
--- /dev/null
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Exceptions\PermissionsException;
+use Exception;
+use Illuminate\Http\Request;
+
+class PageApiController extends ApiController
+{
+ protected $pageRepo;
+
+ protected $rules = [
+ 'create' => [
+ 'book_id' => 'required_without:chapter_id|integer',
+ 'chapter_id' => 'required_without:book_id|integer',
+ 'name' => 'required|string|max:255',
+ 'html' => 'required_without:markdown|string',
+ 'markdown' => 'required_without:html|string',
+ 'tags' => 'array',
+ ],
+ 'update' => [
+ 'book_id' => 'required|integer',
+ 'chapter_id' => 'required|integer',
+ 'name' => 'string|min:1|max:255',
+ 'html' => 'string',
+ 'markdown' => 'string',
+ 'tags' => 'array',
+ ],
+ ];
+
+ public function __construct(PageRepo $pageRepo)
+ {
+ $this->pageRepo = $pageRepo;
+ }
+
+ /**
+ * Get a listing of pages visible to the user.
+ */
+ public function list()
+ {
+ $pages = Page::visible();
+ return $this->apiListingResponse($pages, [
+ 'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
+ 'draft', 'template',
+ 'created_at', 'updated_at', 'created_by', 'updated_by',
+ ]);
+ }
+
+ /**
+ * Create a new page in the system.
+ *
+ * The ID of a parent book or chapter is required to indicate
+ * where this page should be located.
+ *
+ * Any HTML content provided should be kept to a single-block depth of plain HTML
+ * elements to remain compatible with the BookStack front-end and editors.
+ */
+ public function create(Request $request)
+ {
+ $this->validate($request, $this->rules['create']);
+
+ if ($request->has('chapter_id')) {
+ $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
+ } else {
+ $parent = Book::visible()->findOrFail($request->get('book_id'));
+ }
+ $this->checkOwnablePermission('page-create', $parent);
+
+ $draft = $this->pageRepo->getNewDraftPage($parent);
+ $this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
+
+ return response()->json($draft->forJsonDisplay());
+ }
+
+ /**
+ * View the details of a single page.
+ *
+ * Pages will always have HTML content. They may have markdown content
+ * if the markdown editor was used to last update the page.
+ */
+ public function read(string $id)
+ {
+ $page = $this->pageRepo->getById($id, []);
+ return response()->json($page->forJsonDisplay());
+ }
+
+ /**
+ * Update the details of a single page.
+ *
+ * See the 'create' action for details on the provided HTML/Markdown.
+ * Providing a 'book_id' or 'chapter_id' property will essentially move
+ * the page into that parent element if you have permissions to do so.
+ */
+ public function update(Request $request, string $id)
+ {
+ $page = $this->pageRepo->getById($id, []);
+ $this->checkOwnablePermission('page-update', $page);
+
+ $parent = null;
+ if ($request->has('chapter_id')) {
+ $parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
+ } else if ($request->has('book_id')) {
+ $parent = Book::visible()->findOrFail($request->get('book_id'));
+ }
+
+ if ($parent && !$parent->matches($page->getParent())) {
+ $this->checkOwnablePermission('page-delete', $page);
+ try {
+ $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
+ } catch (Exception $exception) {
+ if ($exception instanceof PermissionsException) {
+ $this->showPermissionError();
+ }
+
+ return $this->jsonError(trans('errors.selected_book_chapter_not_found'));
+ }
+ }
+
+ $updatedPage = $this->pageRepo->update($page, $request->all());
+ return response()->json($updatedPage->forJsonDisplay());
+ }
+
+ /**
+ * Delete a page.
+ * This will typically send the page to the recycle bin.
+ */
+ public function delete(string $id)
+ {
+ $page = $this->pageRepo->getById($id, []);
+ $this->checkOwnablePermission('page-delete', $page);
+
+ $this->pageRepo->destroy($page);
+ return response('', 204);
+ }
+}
--- /dev/null
+<?php namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\ExportFormatter;
+use Throwable;
+
+class PageExportApiController extends ApiController
+{
+ protected $exportFormatter;
+
+ public function __construct(ExportFormatter $exportFormatter)
+ {
+ $this->exportFormatter = $exportFormatter;
+ }
+
+ /**
+ * Export a page as a PDF file.
+ * @throws Throwable
+ */
+ public function exportPdf(int $id)
+ {
+ $page = Page::visible()->findOrFail($id);
+ $pdfContent = $this->exportFormatter->pageToPdf($page);
+ return $this->downloadResponse($pdfContent, $page->slug . '.pdf');
+ }
+
+ /**
+ * Export a page as a contained HTML file.
+ * @throws Throwable
+ */
+ public function exportHtml(int $id)
+ {
+ $page = Page::visible()->findOrFail($id);
+ $htmlContent = $this->exportFormatter->pageToContainedHtml($page);
+ return $this->downloadResponse($htmlContent, $page->slug . '.html');
+ }
+
+ /**
+ * Export a page as a plain text file.
+ */
+ public function exportPlainText(int $id)
+ {
+ $page = Page::visible()->findOrFail($id);
+ $textContent = $this->exportFormatter->pageToPlainText($page);
+ return $this->downloadResponse($textContent, $page->slug . '.txt');
+ }
+}
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->pageRepo = $pageRepo;
- parent::__construct();
}
];
$query = Activity::query()
- ->with(['entity', 'user'])
+ ->with([
+ 'entity' => function ($query) {
+ $query->withTrashed();
+ },
+ 'user'
+ ])
->orderBy($listDetails['sort'], $listDetails['order']);
if ($listDetails['event']) {
- $query->where('key', '=', $listDetails['event']);
+ $query->where('type', '=', $listDetails['event']);
}
if ($listDetails['date_from']) {
$activities = $query->paginate(100);
$activities->appends($listDetails);
- $keys = DB::table('activities')->select('key')->distinct()->pluck('key');
+ $types = DB::table('activities')->select('type')->distinct()->pluck('type');
$this->setPageTitle(trans('settings.audit'));
return view('settings.audit', [
'activities' => $activities,
'listDetails' => $listDetails,
- 'activityKeys' => $keys,
+ 'activityTypes' => $types,
]);
}
}
/**
* Create a new controller instance.
- *
- * @param EmailConfirmationService $emailConfirmationService
- * @param UserRepo $userRepo
*/
public function __construct(EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
{
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
- parent::__construct();
}
namespace BookStack\Http\Controllers\Auth;
+use BookStack\Actions\ActivityType;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
use Illuminate\Http\Request;
{
$this->middleware('guest');
$this->middleware('guard:standard');
- parent::__construct();
}
$request->only('email')
);
+ if ($response === Password::RESET_LINK_SENT) {
+ $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
+ }
+
if ($response === Password::RESET_LINK_SENT || $response === Password::INVALID_USER) {
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message);
namespace BookStack\Http\Controllers\Auth;
use Activity;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
-use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
$this->socialAuthService = $socialAuthService;
$this->redirectPath = url('/');
$this->redirectAfterLogout = url('/login');
- parent::__construct();
}
public function username()
}
}
+ $this->logActivity(ActivityType::AUTH_LOGIN, $user);
return redirect()->intended($this->redirectPath());
}
$this->redirectTo = url('/');
$this->redirectPath = url('/');
- parent::__construct();
}
/**
namespace BookStack\Http\Controllers\Auth;
+use BookStack\Actions\ActivityType;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
{
$this->middleware('guest');
$this->middleware('guard:standard');
- parent::__construct();
}
/**
{
$message = trans('auth.reset_password_success');
$this->showSuccessNotification($message);
+ $this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
return redirect($this->redirectPath())
->with('status', trans($response));
}
*/
public function __construct(Saml2Service $samlService)
{
- parent::__construct();
$this->samlService = $samlService;
$this->middleware('guard:saml2');
}
$this->inviteService = $inviteService;
$this->userRepo = $userRepo;
-
- parent::__construct();
}
/**
<?php namespace BookStack\Http\Controllers;
use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\ImageUploadException;
-use BookStack\Exceptions\NotifyException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Throwable;
protected $bookRepo;
protected $entityContextManager;
- /**
- * BookController constructor.
- */
- public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
+ public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
$this->entityContextManager = $entityContextManager;
- parent::__construct();
}
/**
$book = $this->bookRepo->create($request->all());
$this->bookRepo->updateCoverImage($book, $request->file('image', null));
- Activity::add($book, 'book_create', $book->id);
if ($bookshelf) {
$bookshelf->appendBook($book);
- Activity::add($bookshelf, 'bookshelf_update');
+ Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE);
}
return redirect($book->getUrl());
$resetCover = $request->has('image_reset');
$this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
- Activity::add($book, 'book_update', $book->id);
-
return redirect($book->getUrl());
}
/**
* Remove the specified book from the system.
* @throws Throwable
- * @throws NotifyException
*/
public function destroy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
- Activity::addMessage('book_delete', $book->name);
$this->bookRepo->destroy($book);
return redirect('/books');
namespace BookStack\Http\Controllers;
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
{
protected $bookRepo;
- protected $exportService;
+ protected $exportFormatter;
/**
* BookExportController constructor.
*/
- public function __construct(BookRepo $bookRepo, ExportService $exportService)
+ public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
{
$this->bookRepo = $bookRepo;
- $this->exportService = $exportService;
- parent::__construct();
+ $this->exportFormatter = $exportFormatter;
}
/**
public function pdf(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
- $pdfContent = $this->exportService->bookToPdf($book);
+ $pdfContent = $this->exportFormatter->bookToPdf($book);
return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
}
public function html(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
- $htmlContent = $this->exportService->bookToContainedHtml($book);
+ $htmlContent = $this->exportFormatter->bookToContainedHtml($book);
return $this->downloadResponse($htmlContent, $bookSlug . '.html');
}
public function plainText(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
- $textContent = $this->exportService->bookToPlainText($book);
+ $textContent = $this->exportFormatter->bookToPlainText($book);
return $this->downloadResponse($textContent, $bookSlug . '.txt');
}
}
namespace BookStack\Http\Controllers;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\SortOperationException;
use BookStack\Facades\Activity;
protected $bookRepo;
- /**
- * BookSortController constructor.
- * @param $bookRepo
- */
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
- parent::__construct();
}
/**
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
- Activity::add($book, 'book_sort', $book->id);
+ Activity::addForEntity($book, ActivityType::BOOK_SORT);
});
return redirect($book->getUrl());
<?php namespace BookStack\Http\Controllers;
use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\EntityContext;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\ShelfContext;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
/**
* BookController constructor.
*/
- public function __construct(BookshelfRepo $bookshelfRepo, EntityContext $entityContextManager, ImageRepo $imageRepo)
+ public function __construct(BookshelfRepo $bookshelfRepo, ShelfContext $entityContextManager, ImageRepo $imageRepo)
{
$this->bookshelfRepo = $bookshelfRepo;
$this->entityContextManager = $entityContextManager;
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
$shelf = $this->bookshelfRepo->create($request->all(), $bookIds);
$this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null));
- Activity::add($shelf, 'bookshelf_create');
return redirect($shelf->getUrl());
}
$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());
}
$shelf = $this->bookshelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
- Activity::addMessage('bookshelf_delete', $shelf->name);
$this->bookshelfRepo->destroy($shelf);
return redirect('/shelves');
<?php namespace BookStack\Http\Controllers;
-use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\BookContents;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
public function __construct(ChapterRepo $chapterRepo)
{
$this->chapterRepo = $chapterRepo;
- parent::__construct();
}
/**
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
- Activity::add($chapter, 'chapter_create', $book->id);
return redirect($chapter->getUrl());
}
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $request->all());
- Activity::add($chapter, 'chapter_update', $chapter->book->id);
return redirect($chapter->getUrl());
}
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
- Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
$this->chapterRepo->destroy($chapter);
return redirect($chapter->book->getUrl());
return redirect()->back();
}
- Activity::add($chapter, 'chapter_move', $newBook->id);
-
$this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name]));
return redirect($chapter->getUrl());
}
<?php namespace BookStack\Http\Controllers;
-use BookStack\Entities\ExportService;
+use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\NotFoundException;
use Throwable;
{
protected $chapterRepo;
- protected $exportService;
+ protected $exportFormatter;
/**
* ChapterExportController constructor.
*/
- public function __construct(ChapterRepo $chapterRepo, ExportService $exportService)
+ public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
{
$this->chapterRepo = $chapterRepo;
- $this->exportService = $exportService;
- parent::__construct();
+ $this->exportFormatter = $exportFormatter;
}
/**
public function pdf(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
- $pdfContent = $this->exportService->chapterToPdf($chapter);
+ $pdfContent = $this->exportFormatter->chapterToPdf($chapter);
return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf');
}
public function html(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
- $containedHtml = $this->exportService->chapterToContainedHtml($chapter);
+ $containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
return $this->downloadResponse($containedHtml, $chapterSlug . '.html');
}
public function plainText(string $bookSlug, string $chapterSlug)
{
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
- $chapterText = $this->exportService->chapterToPlainText($chapter);
+ $chapterText = $this->exportFormatter->chapterToPlainText($chapter);
return $this->downloadResponse($chapterText, $chapterSlug . '.txt');
}
}
<?php namespace BookStack\Http\Controllers;
use Activity;
+use BookStack\Actions\ActivityType;
use BookStack\Actions\CommentRepo;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
public function __construct(CommentRepo $commentRepo)
{
$this->commentRepo = $commentRepo;
- parent::__construct();
}
/**
// Create a new comment.
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
- Activity::add($page, 'commented_on', $page->book->id);
return view('comments.comment', ['comment' => $comment]);
}
namespace BookStack\Http\Controllers;
+use BookStack\Facades\Activity;
+use BookStack\Interfaces\Loggable;
use BookStack\Ownable;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
-use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
-use Illuminate\Validation\ValidationException;
abstract class Controller extends BaseController
{
use DispatchesJobs, ValidatesRequests;
- /**
- * Controller constructor.
- */
- public function __construct()
- {
- //
- }
-
/**
* Check if the current user is signed in.
*/
/**
* Adds the page title into the view.
- * @param $title
*/
- public function setPageTitle($title)
+ public function setPageTitle(string $title)
{
view()->share('pageTitle', $title);
}
}
/**
- * Checks for a permission.
- * @param string $permissionName
- * @return bool|\Illuminate\Http\RedirectResponse
+ * Checks that the current user has the given permission otherwise throw an exception.
*/
- protected function checkPermission($permissionName)
+ protected function checkPermission(string $permission): void
{
- if (!user() || !user()->can($permissionName)) {
+ if (!user() || !user()->can($permission)) {
$this->showPermissionError();
}
- return true;
}
/**
- * Check the current user's permissions against an ownable item.
- * @param $permission
- * @param Ownable $ownable
- * @return bool
+ * Check the current user's permissions against an ownable item otherwise throw an exception.
*/
- protected function checkOwnablePermission($permission, Ownable $ownable)
+ protected function checkOwnablePermission(string $permission, Ownable $ownable): void
{
- if (userCan($permission, $ownable)) {
- return true;
+ if (!userCan($permission, $ownable)) {
+ $this->showPermissionError();
}
- return $this->showPermissionError();
}
/**
- * Check if a user has a permission or bypass if the callback is true.
- * @param $permissionName
- * @param $callback
- * @return bool
+ * Check if a user has a permission or bypass the permission
+ * check if the given callback resolves true.
*/
- protected function checkPermissionOr($permissionName, $callback)
+ protected function checkPermissionOr(string $permission, callable $callback): void
{
- $callbackResult = $callback();
- if ($callbackResult === false) {
- $this->checkPermission($permissionName);
+ if ($callback() !== true) {
+ $this->checkPermission($permission);
}
- return true;
}
/**
* Check if the current user has a permission or bypass if the provided user
* id matches the current user.
- * @param string $permissionName
- * @param int $userId
- * @return bool
*/
- protected function checkPermissionOrCurrentUser(string $permissionName, int $userId)
+ protected function checkPermissionOrCurrentUser(string $permission, int $userId): void
{
- return $this->checkPermissionOr($permissionName, function () use ($userId) {
+ $this->checkPermissionOr($permission, function () use ($userId) {
return $userId === user()->id;
});
}
/**
* Send back a json error message.
- * @param string $messageText
- * @param int $statusCode
- * @return mixed
*/
- protected function jsonError($messageText = "", $statusCode = 500)
+ protected function jsonError(string $messageText = "", int $statusCode = 500): JsonResponse
{
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
}
/**
* Create a response that forces a download in the browser.
- * @param string $content
- * @param string $fileName
- * @return \Illuminate\Http\Response
*/
- protected function downloadResponse(string $content, string $fileName)
+ protected function downloadResponse(string $content, string $fileName): Response
{
return response()->make($content, 200, [
'Content-Type' => 'application/octet-stream',
/**
* Show a positive, successful notification to the user on next view load.
- * @param string $message
*/
- protected function showSuccessNotification(string $message)
+ protected function showSuccessNotification(string $message): void
{
session()->flash('success', $message);
}
/**
* Show a warning notification to the user on next view load.
- * @param string $message
*/
- protected function showWarningNotification(string $message)
+ protected function showWarningNotification(string $message): void
{
session()->flash('warning', $message);
}
/**
* Show an error notification to the user on next view load.
- * @param string $message
*/
- protected function showErrorNotification(string $message)
+ protected function showErrorNotification(string $message): void
{
session()->flash('error', $message);
}
+ /**
+ * Log an activity in the system.
+ * @param string|Loggable
+ */
+ protected function logActivity(string $type, $detail = ''): void
+ {
+ Activity::add($type, $detail);
+ }
+
/**
* Get the validation rules for image files.
*/
<?php namespace BookStack\Http\Controllers;
use Activity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Http\Response;
/**
* Display the homepage.
- * @return Response
*/
public function index()
{
$draftPages = [];
if ($this->isSignedIn()) {
- $draftPages = Page::visible()->where('draft', '=', true)
+ $draftPages = Page::visible()
+ ->where('draft', '=', true)
->where('created_by', '=', user()->id)
- ->orderBy('updated_at', 'desc')->take(6)->get();
+ ->orderBy('updated_at', 'desc')
+ ->take(6)
+ ->get();
}
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
$recents = $this->isSignedIn() ?
- Views::getUserRecentlyViewed(12*$recentFactor, 0)
+ Views::getUserRecentlyViewed(12*$recentFactor, 1)
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
$recentlyUpdatedPages = Page::visible()->where('draft', false)
->orderBy('updated_at', 'desc')->take(12)->get();
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
<?php namespace BookStack\Http\Controllers\Images;
-use BookStack\Entities\Page;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controllers\Controller;
-use BookStack\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Filesystem\Filesystem as File;
-use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
$this->image = $image;
$this->file = $file;
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
namespace BookStack\Http\Controllers;
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Tools\TrashCan;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
// Get application version
$version = trim(file_get_contents(base_path('version')));
- return view('settings.maintenance', ['version' => $version]);
+ // Recycle bin details
+ $recycleStats = (new TrashCan())->getTrashedCounts();
+
+ return view('settings.maintenance', [
+ 'version' => $version,
+ 'recycleStats' => $recycleStats,
+ ]);
}
/**
public function cleanupImages(Request $request, ImageService $imageService)
{
$this->checkPermission('settings-manage');
+ $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
$dryRun = !($request->has('confirm'));
public function sendTestEmail()
{
$this->checkPermission('settings-manage');
+ $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notify(new TestEmail());
<?php namespace BookStack\Http\Controllers;
-use Activity;
-use BookStack\Entities\Managers\BookContents;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Managers\PageEditActivity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Tools\PageEditActivity;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
- parent::__construct();
}
/**
public function editDraft(string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById($pageId);
- $this->checkOwnablePermission('page-create', $draft->parent());
+ $this->checkOwnablePermission('page-create', $draft->getParent());
$this->setPageTitle(trans('entities.pages_edit_draft'));
$draftsEnabled = $this->isSignedIn();
'name' => 'required|string|max:255'
]);
$draftPage = $this->pageRepo->getById($pageId);
- $this->checkOwnablePermission('page-create', $draftPage->parent());
+ $this->checkOwnablePermission('page-create', $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
- Activity::add($page, 'page_create', $draftPage->book->id);
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-update', $page);
$this->pageRepo->update($page, $request->all());
- Activity::add($page, 'page_update', $page->book->id);
return redirect($page->getUrl());
}
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
+ $parent = $page->getParent();
- $book = $page->book;
- $parent = $page->chapter ?? $book;
$this->pageRepo->destroy($page);
- Activity::addMessage('page_delete', $page->name, $book->id);
- $this->showSuccessNotification(trans('entities.pages_delete_success'));
return redirect($parent->getUrl());
}
return redirect()->back();
}
- Activity::add($page, 'page_move', $page->book->id);
$this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name]));
return redirect($page->getUrl());
}
return redirect()->back();
}
- Activity::add($pageCopy, 'page_create', $pageCopy->book->id);
-
$this->showSuccessNotification(trans('entities.pages_copy_success'));
return redirect($pageCopy->getUrl());
}
namespace BookStack\Http\Controllers;
-use BookStack\Entities\ExportService;
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Tools\ExportFormatter;
+use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use Throwable;
{
protected $pageRepo;
- protected $exportService;
+ protected $exportFormatter;
/**
* PageExportController constructor.
- * @param PageRepo $pageRepo
- * @param ExportService $exportService
*/
- public function __construct(PageRepo $pageRepo, ExportService $exportService)
+ public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
{
$this->pageRepo = $pageRepo;
- $this->exportService = $exportService;
- parent::__construct();
+ $this->exportFormatter = $exportFormatter;
}
/**
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
- $pdfContent = $this->exportService->pageToPdf($page);
+ $pdfContent = $this->exportFormatter->pageToPdf($page);
return $this->downloadResponse($pdfContent, $pageSlug . '.pdf');
}
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$page->html = (new PageContent($page))->render();
- $containedHtml = $this->exportService->pageToContainedHtml($page);
+ $containedHtml = $this->exportFormatter->pageToContainedHtml($page);
return $this->downloadResponse($containedHtml, $pageSlug . '.html');
}
public function plainText(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
- $pageText = $this->exportService->pageToPlainText($page);
+ $pageText = $this->exportFormatter->pageToPlainText($page);
return $this->downloadResponse($pageText, $pageSlug . '.txt');
}
}
<?php namespace BookStack\Http\Controllers;
-use BookStack\Entities\Managers\PageContent;
+use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
-use BookStack\Facades\Activity;
-use GatherContent\Htmldiff\Htmldiff;
+use Ssddanbrown\HtmlDiff\Diff;
class PageRevisionController extends Controller
{
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
- parent::__construct();
}
/**
$prev = $revision->getPrevious();
$prevContent = $prev->html ?? '';
- $diff = (new Htmldiff)->diff($prevContent, $revision->html);
+ $diff = Diff::excecute($prevContent, $revision->html);
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page = $this->pageRepo->restoreRevision($page, $revisionId);
- Activity::add($page, 'page_restore', $page->book->id);
return redirect($page->getUrl());
}
public function __construct(PageRepo $pageRepo)
{
$this->pageRepo = $pageRepo;
- parent::__construct();
}
/**
--- /dev/null
+<?php namespace BookStack\Http\Controllers;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Tools\TrashCan;
+
+class RecycleBinController extends Controller
+{
+
+ protected $recycleBinBaseUrl = '/settings/recycle-bin';
+
+ /**
+ * On each request to a method of this controller check permissions
+ * using a middleware closure.
+ */
+ public function __construct()
+ {
+ $this->middleware(function ($request, $next) {
+ $this->checkPermission('settings-manage');
+ $this->checkPermission('restrictions-manage-all');
+ return $next($request);
+ });
+ }
+
+
+ /**
+ * Show the top-level listing for the recycle bin.
+ */
+ public function index()
+ {
+ $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
+
+ $this->setPageTitle(trans('settings.recycle_bin'));
+ return view('settings.recycle-bin.index', [
+ 'deletions' => $deletions,
+ ]);
+ }
+
+ /**
+ * Show the page to confirm a restore of the deletion of the given id.
+ */
+ public function showRestore(string $id)
+ {
+ /** @var Deletion $deletion */
+ $deletion = Deletion::query()->findOrFail($id);
+
+ return view('settings.recycle-bin.restore', [
+ 'deletion' => $deletion,
+ ]);
+ }
+
+ /**
+ * Restore the element attached to the given deletion.
+ * @throws \Exception
+ */
+ public function restore(string $id)
+ {
+ /** @var Deletion $deletion */
+ $deletion = Deletion::query()->findOrFail($id);
+ $this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
+ $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
+
+ $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
+ return redirect($this->recycleBinBaseUrl);
+ }
+
+ /**
+ * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id.
+ */
+ public function showDestroy(string $id)
+ {
+ /** @var Deletion $deletion */
+ $deletion = Deletion::query()->findOrFail($id);
+
+ return view('settings.recycle-bin.destroy', [
+ 'deletion' => $deletion,
+ ]);
+ }
+
+ /**
+ * Permanently delete the content associated with the given deletion.
+ * @throws \Exception
+ */
+ public function destroy(string $id)
+ {
+ /** @var Deletion $deletion */
+ $deletion = Deletion::query()->findOrFail($id);
+ $this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
+ $deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
+
+ $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+ return redirect($this->recycleBinBaseUrl);
+ }
+
+ /**
+ * Empty out the recycle bin.
+ * @throws \Exception
+ */
+ public function empty()
+ {
+ $deleteCount = (new TrashCan())->empty();
+
+ $this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
+ $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
+ return redirect($this->recycleBinBaseUrl);
+ }
+}
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
-class PermissionController extends Controller
+class RoleController extends Controller
{
protected $permissionsRepo;
public function __construct(PermissionsRepo $permissionsRepo)
{
$this->permissionsRepo = $permissionsRepo;
- parent::__construct();
}
/**
* Show a listing of the roles in the system.
*/
- public function listRoles()
+ public function list()
{
$this->checkPermission('user-roles-manage');
$roles = $this->permissionsRepo->getAllRoles();
/**
* Show the form to create a new role
*/
- public function createRole()
+ public function create()
{
$this->checkPermission('user-roles-manage');
return view('settings.roles.create');
/**
* Store a new role in the system.
*/
- public function storeRole(Request $request)
+ public function store(Request $request)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
* Show the form for editing a user role.
* @throws PermissionsException
*/
- public function editRole(string $id)
+ public function edit(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
* Updates a user role.
* @throws ValidationException
*/
- public function updateRole(Request $request, string $id)
+ public function update(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
* Show the view to delete a role.
* Offers the chance to migrate users.
*/
- public function showDeleteRole(string $id)
+ public function showDelete(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
* Migrate from a previous role if set.
* @throws Exception
*/
- public function deleteRole(Request $request, string $id)
+ public function delete(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Managers\EntityContext;
-use BookStack\Entities\SearchService;
-use BookStack\Entities\SearchOptions;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\SearchRunner;
+use BookStack\Entities\Tools\ShelfContext;
+use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SiblingFetcher;
use Illuminate\Http\Request;
class SearchController extends Controller
{
protected $viewService;
- protected $searchService;
+ protected $searchRunner;
protected $entityContextManager;
- /**
- * SearchController constructor.
- */
public function __construct(
ViewService $viewService,
- SearchService $searchService,
- EntityContext $entityContextManager
+ SearchRunner $searchRunner,
+ ShelfContext $entityContextManager
) {
$this->viewService = $viewService;
- $this->searchService = $searchService;
+ $this->searchRunner = $searchRunner;
$this->entityContextManager = $entityContextManager;
- parent::__construct();
}
/**
$page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
- $results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
+ $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
return view('search.all', [
'entities' => $results['results'],
]);
}
-
/**
* Searches all entities within a book.
*/
public function searchBook(Request $request, int $bookId)
{
$term = $request->get('term', '');
- $results = $this->searchService->searchBook($bookId, $term);
+ $results = $this->searchRunner->searchBook($bookId, $term);
return view('partials.entity-list', ['entities' => $results]);
}
public function searchChapter(Request $request, int $chapterId)
{
$term = $request->get('term', '');
- $results = $this->searchService->searchChapter($chapterId, $term);
+ $results = $this->searchRunner->searchChapter($chapterId, $term);
return view('partials.entity-list', ['entities' => $results]);
}
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
- $entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
+ $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
}
$type = $request->get('entity_type', null);
$id = $request->get('entity_id', null);
- $entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id);
- if (!$entity) {
- return $this->jsonError(trans('errors.entity_not_found'), 404);
- }
-
- $entities = [];
-
- // Page in chapter
- if ($entity->isA('page') && $entity->chapter) {
- $entities = $entity->chapter->getVisiblePages();
- }
-
- // Page in book or chapter
- if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) {
- $entities = $entity->book->getDirectChildren();
- }
-
- // Book
- // Gets just the books in a shelf if shelf is in context
- if ($entity->isA('book')) {
- $contextShelf = $this->entityContextManager->getContextualShelfForBook($entity);
- if ($contextShelf) {
- $entities = $contextShelf->visibleBooks()->get();
- } else {
- $entities = Book::visible()->get();
- }
- }
-
- // Shelve
- if ($entity->isA('bookshelf')) {
- $entities = Bookshelf::visible()->get();
- }
-
+ $entities = (new SiblingFetcher)->fetch($type, $id);
return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']);
}
}
<?php namespace BookStack\Http\Controllers;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
// Cycles through posted settings and update them
foreach ($request->all() as $name => $value) {
+ $key = str_replace('setting-', '', trim($name));
if (strpos($name, 'setting-') !== 0) {
continue;
}
- $key = str_replace('setting-', '', trim($name));
setting()->put($key, $value);
}
setting()->remove('app-logo');
}
+ $section = $request->get('section', '');
+ $this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
$this->showSuccessNotification(trans('settings.settings_save_success'));
- $redirectLocation = '/settings#' . $request->get('section', '');
+ $redirectLocation = '/settings#' . $section;
return redirect(rtrim($redirectLocation, '#'));
}
}
public function __construct(TagRepo $tagRepo)
{
$this->tagRepo = $tagRepo;
- parent::__construct();
}
/**
<?php namespace BookStack\Http\Controllers;
+use BookStack\Actions\ActivityType;
use BookStack\Api\ApiToken;
use BookStack\Auth\User;
use Illuminate\Http\Request;
-use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
session()->flash('api-token-secret:' . $token->id, $secret);
$this->showSuccessNotification(trans('settings.user_api_token_create_success'));
+ $this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
+
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
}
])->save();
$this->showSuccessNotification(trans('settings.user_api_token_update_success'));
+ $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
}
$token->delete();
$this->showSuccessNotification(trans('settings.user_api_token_delete_success'));
+ $this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
+
return redirect($user->getEditUrl('#api_tokens'));
}
<?php namespace BookStack\Http\Controllers;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
$this->userRepo = $userRepo;
$this->inviteService = $inviteService;
$this->imageRepo = $imageRepo;
- parent::__construct();
}
/**
$this->userRepo->downloadAndAssignUserAvatar($user);
+ $this->logActivity(ActivityType::USER_CREATE, $user);
return redirect('/settings/users');
}
$user->image_id = $image->id;
}
- // Delete the profile image if set to
+ // Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) {
$this->imageRepo->destroyImage($user->avatar);
}
$user->save();
$this->showSuccessNotification(trans('settings.users_edit_success'));
+ $this->logActivity(ActivityType::USER_UPDATE, $user);
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
return redirect($redirectUrl);
$this->userRepo->destroy($user);
$this->showSuccessNotification(trans('settings.users_delete_success'));
+ $this->logActivity(ActivityType::USER_DELETE, $user);
return redirect('/settings/users');
}
--- /dev/null
+<?php
+
+namespace BookStack\Interfaces;
+
+interface Loggable
+{
+ /**
+ * Get the string descriptor for this item.
+ */
+ public function logDescriptor(): string;
+}
\ No newline at end of file
return $this->belongsTo(User::class, 'updated_by');
}
- /**
- * Gets the class name.
- * @return string
- */
- public static function getClassName()
- {
- return strtolower(array_slice(explode('\\', static::class), -1, 1)[0]);
- }
}
<?php namespace BookStack\Providers;
use Blade;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\BreadcrumbsViewComposer;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Settings\Setting;
use BookStack\Settings\SettingService;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
use Schema;
use URL;
-use Validator;
class AppServiceProvider extends ServiceProvider
{
URL::forceScheme($isHttps ? 'https' : 'http');
}
- // Custom validation methods
- Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
- $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
- return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
- });
-
- Validator::extend('no_double_extension', function ($attribute, $value, $parameters, $validator) {
- $uploadName = $value->getClientOriginalName();
- return substr_count($uploadName, '.') < 2;
- });
-
- Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
- $cleanLinkName = strtolower(trim($value));
- $isJs = strpos($cleanLinkName, 'javascript:') === 0;
- $isData = strpos($cleanLinkName, 'data:') === 0;
- return !$isJs && !$isData;
- });
-
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>";
});
- Blade::directive('exposeTranslations', function ($expression) {
- return "<?php \$__env->startPush('translations'); ?>" .
- "<?php foreach({$expression} as \$key): ?>" .
- '<meta name="translation" key="<?php echo e($key); ?>" value="<?php echo e(trans($key)); ?>">' . "\n" .
- "<?php endforeach; ?>" .
- '<?php $__env->stopPush(); ?>';
- });
-
// Allow longer string lengths after upgrade to utf8mb4
Schema::defaultStringLength(191);
--- /dev/null
+<?php
+
+namespace BookStack\Providers;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\ServiceProvider;
+
+class CustomValidationServiceProvider extends ServiceProvider
+{
+
+ /**
+ * Register our custom validation rules when the application boots.
+ */
+ public function boot(): void
+ {
+ Validator::extend('image_extension', function ($attribute, $value, $parameters, $validator) {
+ $validImageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
+ return in_array(strtolower($value->getClientOriginalExtension()), $validImageExtensions);
+ });
+
+ Validator::extend('no_double_extension', function ($attribute, $value, $parameters, $validator) {
+ $uploadName = $value->getClientOriginalName();
+ return substr_count($uploadName, '.') < 2;
+ });
+
+ Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
+ $cleanLinkName = strtolower(trim($value));
+ $isJs = strpos($cleanLinkName, 'javascript:') === 0;
+ $isData = strpos($cleanLinkName, 'data:') === 0;
+ return !$isJs && !$isData;
+ });
+ }
+}
<?php namespace BookStack\Uploads;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Ownable;
/**
<?php namespace BookStack\Uploads;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Ownable;
use Images;
<?php namespace BookStack\Uploads;
use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ImageUploadException;
use Exception;
use Illuminate\Database\Eloquent\Builder;
<?php namespace BookStack\Uploads;
-use BookStack\Auth\User;
-use BookStack\Exceptions\HttpFetchException;
use BookStack\Exceptions\ImageUploadException;
use DB;
use ErrorException;
class ImageService
{
-
protected $imageTool;
protected $cache;
protected $storageUrl;
protected $image;
- protected $http;
protected $fileSystem;
/**
* ImageService constructor.
*/
- public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache, HttpFetcher $http)
+ public function __construct(Image $image, ImageManager $imageTool, FileSystem $fileSystem, Cache $cache)
{
$this->image = $image;
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
- $this->http = $http;
}
/**
/**
* Save a new image from a uri-encoded base64 string of data.
- * @param string $base64Uri
- * @param string $name
- * @param string $type
- * @param int $uploadedTo
- * @return Image
* @throws ImageUploadException
*/
- public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, $uploadedTo = 0)
+ public function saveNewFromBase64Uri(string $base64Uri, string $name, string $type, int $uploadedTo = 0): Image
{
$splitData = explode(';base64,', $base64Uri);
if (count($splitData) < 2) {
return $this->saveNew($name, $data, $type, $uploadedTo);
}
- /**
- * Gets an image from url and saves it to the database.
- * @param $url
- * @param string $type
- * @param bool|string $imageName
- * @return mixed
- * @throws Exception
- */
- private function saveNewFromUrl($url, $type, $imageName = false)
- {
- $imageName = $imageName ? $imageName : basename($url);
- try {
- $imageData = $this->http->fetch($url);
- } catch (HttpFetchException $exception) {
- throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
- }
- return $this->saveNew($imageName, $imageData, $type);
- }
-
/**
* Save a new image into storage.
* @throws ImageUploadException
*/
- private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
+ public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$storage = $this->getStorage($type);
$secureUploads = setting('app-secure-images');
return (count($files) === 0 && count($folders) === 0);
}
- /**
- * Save an avatar image from an external service.
- * @throws Exception
- */
- public function saveUserAvatar(User $user, int $size = 500): Image
- {
- $avatarUrl = $this->getAvatarUrl();
- $email = strtolower(trim($user->email));
-
- $replacements = [
- '${hash}' => md5($email),
- '${size}' => $size,
- '${email}' => urlencode($email),
- ];
-
- $userAvatarUrl = strtr($avatarUrl, $replacements);
- $imageName = str_replace(' ', '-', $user->name . '-avatar.png');
- $image = $this->saveNewFromUrl($userAvatarUrl, 'user', $imageName);
- $image->created_by = $user->id;
- $image->updated_by = $user->id;
- $image->uploaded_to = $user->id;
- $image->save();
-
- return $image;
- }
-
- /**
- * Check if fetching external avatars is enabled.
- */
- public function avatarFetchEnabled(): bool
- {
- $fetchUrl = $this->getAvatarUrl();
- return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
- }
-
- /**
- * Get the URL to fetch avatars from.
- * @return string|mixed
- */
- protected function getAvatarUrl()
- {
- $url = trim(config('services.avatar_url'));
-
- if (empty($url) && !config('services.disable_services')) {
- $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
- }
-
- return $url;
- }
-
/**
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
* Checks based off of only the image name.
--- /dev/null
+<?php namespace BookStack\Uploads;
+
+use BookStack\Auth\User;
+use BookStack\Exceptions\HttpFetchException;
+use Exception;
+
+class UserAvatars
+{
+ protected $imageService;
+ protected $http;
+
+ public function __construct(ImageService $imageService, HttpFetcher $http)
+ {
+ $this->imageService = $imageService;
+ $this->http = $http;
+ }
+
+ /**
+ * Fetch and assign an avatar image to the given user.
+ */
+ public function fetchAndAssignToUser(User $user): void
+ {
+ if (!$this->avatarFetchEnabled()) {
+ return;
+ }
+
+ try {
+ $avatar = $this->saveAvatarImage($user);
+ $user->avatar()->associate($avatar);
+ $user->save();
+ } catch (Exception $e) {
+ Log::error('Failed to save user avatar image');
+ }
+ }
+
+ /**
+ * Save an avatar image from an external service.
+ * @throws Exception
+ */
+ protected function saveAvatarImage(User $user, int $size = 500): Image
+ {
+ $avatarUrl = $this->getAvatarUrl();
+ $email = strtolower(trim($user->email));
+
+ $replacements = [
+ '${hash}' => md5($email),
+ '${size}' => $size,
+ '${email}' => urlencode($email),
+ ];
+
+ $userAvatarUrl = strtr($avatarUrl, $replacements);
+ $imageName = str_replace(' ', '-', $user->id . '-avatar.png');
+ $imageData = $this->getAvatarImageData($userAvatarUrl);
+
+ $image = $this->imageService->saveNew($imageName, $imageData, 'user', $user->id);
+ $image->created_by = $user->id;
+ $image->updated_by = $user->id;
+ $image->save();
+
+ return $image;
+ }
+
+ /**
+ * Gets an image from url and returns it as a string of image data.
+ * @throws Exception
+ */
+ protected function getAvatarImageData(string $url): string
+ {
+ try {
+ $imageData = $this->http->fetch($url);
+ } catch (HttpFetchException $exception) {
+ throw new Exception(trans('errors.cannot_get_image_from_url', ['url' => $url]));
+ }
+ return $imageData;
+ }
+
+ /**
+ * Check if fetching external avatars is enabled.
+ */
+ protected function avatarFetchEnabled(): bool
+ {
+ $fetchUrl = $this->getAvatarUrl();
+ return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
+ }
+
+ /**
+ * Get the URL to fetch avatars from.
+ */
+ protected function getAvatarUrl(): string
+ {
+ $url = trim(config('services.avatar_url'));
+
+ if (empty($url) && !config('services.disable_services')) {
+ $url = 'https://www.gravatar.com/avatar/${hash}?s=${size}&d=identicon';
+ }
+
+ return $url;
+ }
+
+}
\ No newline at end of file
/**
* Get the path to a versioned file.
- *
- * @param string $file
- * @return string
* @throws Exception
*/
function versioned_asset(string $file = ''): string
/**
* Helper method to get the current User.
* Defaults to public 'Guest' user if not logged in.
- * @return User
*/
function user(): User
{
}
/**
- * Check if the current user has a permission.
- * If an ownable element is passed in the jointPermissions are checked against
- * that particular item.
+ * Check if the current user has a permission. If an ownable element
+ * is passed in the jointPermissions are checked against that particular item.
*/
function userCan(string $permission, Ownable $ownable = null): bool
{
/**
* Check if the current user has the given permission
* on any item in the system.
- * @param string $permission
- * @param string|null $entityClass
- * @return bool
*/
function userCanOnAny(string $permission, string $entityClass = null): bool
{
/**
* Helper to access system settings.
- * @param string $key
- * @param $default
* @return bool|string|SettingService
*/
function setting(string $key = null, $default = false)
{
$settingService = resolve(SettingService::class);
+
if (is_null($key)) {
return $settingService;
}
+
return $settingService->get($key, $default);
}
/**
* Get a path to a theme resource.
- * @param string $path
- * @return string
*/
function theme_path(string $path = ''): string
{
$theme = config('view.theme');
+
if (!$theme) {
return '';
}
* to the 'resources/assets/icons' folder.
*
* Returns an empty string if icon file not found.
- * @param $name
- * @param array $attrs
- * @return mixed
*/
function icon(string $name, array $attrs = []): string
{
$iconPath = resource_path('icons/' . $name . '.svg');
$themeIconPath = theme_path('icons/' . $name . '.svg');
+
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} else if (!file_exists($iconPath)) {
/*
|--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
|--------------------------------------------------------------------------
|
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader
+| for our application. We just need to utilize it! We'll require it
+| into the script here so that we do not have to worry about the
+| loading of any our classes "manually". Feels great to relax.
|
*/
-require __DIR__.'/bootstrap/init.php';
+require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
+++ /dev/null
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Load Our Own Helpers
-|--------------------------------------------------------------------------
-|
-| This custom function loads any helpers, before the Laravel Framework
-| is built so we can override any helpers as we please.
-|
-*/
-require __DIR__.'/../app/helpers.php';
-
-/*
-|--------------------------------------------------------------------------
-| Register The Composer Auto Loader
-|--------------------------------------------------------------------------
-|
-| Composer provides a convenient, automatically generated class loader
-| for our application. We just need to utilize it! We'll require it
-| into the script here so that we do not have to worry about the
-| loading of any our classes "manually". Feels great to relax.
-|
-*/
-require __DIR__.'/../vendor/autoload.php';
\ No newline at end of file
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
- "ext-tidy": "*",
"ext-xml": "*",
"barryvdh/laravel-dompdf": "^0.8.6",
"barryvdh/laravel-snappy": "^0.4.7",
"doctrine/dbal": "^2.9",
"facade/ignition": "^1.4",
"fideloper/proxy": "^4.0",
- "gathercontent/htmldiff": "^0.2.1",
"intervention/image": "^2.5",
"laravel/framework": "^6.18",
"laravel/socialite": "^4.3.2",
"socialiteproviders/microsoft-azure": "^3.0",
"socialiteproviders/okta": "^1.0",
"socialiteproviders/slack": "^3.0",
- "socialiteproviders/twitch": "^5.0"
+ "socialiteproviders/twitch": "^5.0",
+ "ssddanbrown/htmldiff": "^1.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.2.8",
],
"psr-4": {
"BookStack\\": "app/"
- }
+ },
+ "files": [
+ "app/helpers.php"
+ ]
},
"autoload-dev": {
"psr-4": {
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "34390536dd685e0bc49b179babaa06ec",
+ "content-hash": "a61fd1c287bd93fa496202c6583c7bb1",
"packages": [
{
"name": "aws/aws-sdk-php",
],
"time": "2020-09-07T12:33:10+00:00"
},
- {
- "name": "cogpowered/finediff",
- "version": "0.3.1",
- "source": {
- "type": "git",
- "url": "https://github.com/cogpowered/FineDiff.git",
- "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/cogpowered/FineDiff/zipball/339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
- "reference": "339ddc8c3afb656efed4f2f0a80e5c3d026f8ea8",
- "shasum": ""
- },
- "require": {
- "php": ">=5.3.0"
- },
- "require-dev": {
- "mockery/mockery": "*",
- "phpunit/phpunit": "*"
- },
- "type": "library",
- "autoload": {
- "psr-0": {
- "cogpowered\\FineDiff": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Rob Crowe",
- },
- {
- "name": "Raymond Hill"
- }
- ],
- "description": "PHP implementation of a Fine granularity Diff engine",
- "homepage": "https://github.com/cogpowered/FineDiff",
- "keywords": [
- "diff",
- "finediff",
- "opcode",
- "string",
- "text"
- ],
- "time": "2014-05-19T10:25:02+00:00"
- },
{
"name": "doctrine/cache",
"version": "1.10.2",
],
"time": "2020-06-14T09:00:00+00:00"
},
- {
- "name": "gathercontent/htmldiff",
- "version": "0.2.1",
- "source": {
- "type": "git",
- "url": "https://github.com/gathercontent/htmldiff.git",
- "reference": "24674a62315f64330134b4a4c5b01a7b59193c93"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/gathercontent/htmldiff/zipball/24674a62315f64330134b4a4c5b01a7b59193c93",
- "reference": "24674a62315f64330134b4a4c5b01a7b59193c93",
- "shasum": ""
- },
- "require": {
- "cogpowered/finediff": "0.3.1",
- "ext-tidy": "*"
- },
- "require-dev": {
- "phpunit/phpunit": "4.*",
- "squizlabs/php_codesniffer": "1.*"
- },
- "type": "library",
- "autoload": {
- "psr-0": {
- "GatherContent\\Htmldiff": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Andrew Cairns",
- },
- {
- "name": "Mathew Chapman",
- },
- {
- "name": "Peter Legierski",
- }
- ],
- "description": "Compare two HTML strings",
- "time": "2015-04-15T15:39:46+00:00"
- },
{
"name": "guzzlehttp/guzzle",
"version": "6.5.5",
"description": "Twitch OAuth2 Provider for Laravel Socialite",
"time": "2020-05-06T22:51:30+00:00"
},
+ {
+ "name": "ssddanbrown/htmldiff",
+ "version": "v1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ssddanbrown/HtmlDiff.git",
+ "reference": "d1978c7d1c685800997f982a0ae9cff1e45df70c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ssddanbrown/HtmlDiff/zipball/d1978c7d1c685800997f982a0ae9cff1e45df70c",
+ "reference": "d1978c7d1c685800997f982a0ae9cff1e45df70c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=7.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5|^9.4.3"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Ssddanbrown\\HtmlDiff\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Dan Brown",
+ "role": "Developer"
+ }
+ ],
+ "description": "HTML Content Diff Generator",
+ "homepage": "https://github.com/ssddanbrown/htmldiff",
+ "support": {
+ "issues": "https://github.com/ssddanbrown/HtmlDiff/issues",
+ "source": "https://github.com/ssddanbrown/HtmlDiff/tree/v1.0.0"
+ },
+ "time": "2020-11-29T18:38:45+00:00"
+ },
{
"name": "swiftmailer/swiftmailer",
"version": "v6.2.3",
"ext-gd": "*",
"ext-json": "*",
"ext-mbstring": "*",
- "ext-tidy": "*",
"ext-xml": "*"
},
"platform-dev": [],
"platform-overrides": {
"php": "7.2.0"
},
- "plugin-api-version": "1.1.0"
+ "plugin-api-version": "2.0.0"
}
];
});
-$factory->define(\BookStack\Entities\Bookshelf::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Bookshelf::class, function ($faker) {
return [
'name' => $faker->sentence,
'slug' => Str::random(10),
];
});
-$factory->define(\BookStack\Entities\Book::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Book::class, function ($faker) {
return [
'name' => $faker->sentence,
'slug' => Str::random(10),
];
});
-$factory->define(\BookStack\Entities\Chapter::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Chapter::class, function ($faker) {
return [
'name' => $faker->sentence,
'slug' => Str::random(10),
];
});
-$factory->define(\BookStack\Entities\Page::class, function ($faker) {
+$factory->define(\BookStack\Entities\Models\Page::class, function ($faker) {
$html = '<p>' . implode('</p>', $faker->paragraphs(5)) . '</p>';
return [
'name' => $faker->sentence,
Schema::dropIfExists('bookshelves');
// Drop related polymorphic items
- DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
- DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
- DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Bookshelf')->delete();
- DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
- DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
- DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Bookshelf')->delete();
+ DB::table('activities')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+ DB::table('views')->where('viewable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+ DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+ DB::table('tags')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+ DB::table('search_terms')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
+ DB::table('comments')->where('entity_type', '=', 'BookStack\Entities\Models\Bookshelf')->delete();
}
}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddEntitySoftDeletes extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('bookshelves', function(Blueprint $table) {
+ $table->softDeletes();
+ });
+ Schema::table('books', function(Blueprint $table) {
+ $table->softDeletes();
+ });
+ Schema::table('chapters', function(Blueprint $table) {
+ $table->softDeletes();
+ });
+ Schema::table('pages', function(Blueprint $table) {
+ $table->softDeletes();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table('bookshelves', function(Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ Schema::table('books', function(Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ Schema::table('chapters', function(Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ Schema::table('pages', function(Blueprint $table) {
+ $table->dropSoftDeletes();
+ });
+ }
+}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateDeletionsTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('deletions', function (Blueprint $table) {
+ $table->increments('id');
+ $table->integer('deleted_by');
+ $table->string('deletable_type', 100);
+ $table->integer('deletable_id');
+ $table->timestamps();
+
+ $table->index('deleted_by');
+ $table->index('deletable_type');
+ $table->index('deletable_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('deletions');
+ }
+}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+class SimplifyActivitiesTable extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::table('activities', function (Blueprint $table) {
+ $table->renameColumn('key', 'type');
+ $table->renameColumn('extra', 'detail');
+ $table->dropColumn('book_id');
+ $table->integer('entity_id')->nullable()->change();
+ $table->string('entity_type', 191)->nullable()->change();
+ });
+
+ DB::table('activities')
+ ->where('entity_id', '=', 0)
+ ->update([
+ 'entity_id' => null,
+ 'entity_type' => null,
+ ]);
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ DB::table('activities')
+ ->whereNull('entity_id')
+ ->update([
+ 'entity_id' => 0,
+ 'entity_type' => '',
+ ]);
+
+ Schema::table('activities', function (Blueprint $table) {
+ $table->renameColumn('type', 'key');
+ $table->renameColumn('detail', 'extra');
+ $table->integer('book_id');
+
+ $table->integer('entity_id')->change();
+ $table->string('entity_type', 191)->change();
+
+ $table->index('book_id');
+ });
+ }
+}
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
$byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id];
- factory(\BookStack\Entities\Book::class, 5)->create($byData)
+ factory(\BookStack\Entities\Models\Book::class, 5)->create($byData)
->each(function($book) use ($editorUser, $byData) {
$chapters = factory(Chapter::class, 3)->create($byData)
->each(function($chapter) use ($editorUser, $book, $byData){
$book->pages()->saveMany($pages);
});
- $largeBook = factory(\BookStack\Entities\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
+ $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
$pages = factory(Page::class, 200)->make($byData);
$chapters = factory(Chapter::class, 50)->make($byData);
$largeBook->pages()->saveMany($pages);
$token->save();
app(PermissionService::class)->buildJointPermissions();
- app(SearchService::class)->indexAllEntities();
+ app(SearchIndex::class)->indexAllEntities();
}
}
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\Role;
use BookStack\Auth\User;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
-use BookStack\Entities\SearchService;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Tools\SearchIndex;
use Illuminate\Database\Seeder;
use Illuminate\Support\Str;
$editorRole = Role::getRole('editor');
$editorUser->attachRole($editorRole);
- $largeBook = factory(\BookStack\Entities\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+ $largeBook = factory(\BookStack\Entities\Models\Book::class)->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$pages = factory(Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$chapters = factory(Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$largeBook->pages()->saveMany($pages);
$largeBook->chapters()->saveMany($chapters);
app(PermissionService::class)->buildJointPermissions();
- app(SearchService::class)->indexAllEntities();
+ app(SearchIndex::class)->indexAllEntities();
}
}
--- /dev/null
+{
+ "book_id": 1,
+ "name": "My API Page",
+ "html": "<p>my new API page</p>",
+ "tags": [
+ {"name": "Category", "value": "Not Bad Content"},
+ {"name": "Rating", "value": "Average"}
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "chapter_id": 1,
+ "name": "My updated API Page",
+ "html": "<p>my new API page - Updated</p>",
+ "tags": [
+ {"name": "Category", "value": "API Examples"},
+ {"name": "Rating", "value": "Alright"}
+ ]
+}
\ No newline at end of file
"tags": [
{
"id": 13,
- "entity_id": 16,
- "entity_type": "BookStack\\Book",
"name": "Category",
"value": "Guide",
- "order": 0,
- "created_at": "2020-01-12 14:11:51",
- "updated_at": "2020-01-12 14:11:51"
+ "order": 0
}
],
"cover": {
{
"name": "Category",
"value": "Guide",
- "order": 0,
- "created_at": "2020-05-22 22:51:51",
- "updated_at": "2020-05-22 22:51:51"
+ "order": 0
}
],
"pages": [
"updated_at": "2019-08-26 14:32:59",
"created_by": 1,
"updated_by": 1,
- "draft": 0,
+ "draft": false,
"revision_count": 2,
- "template": 0
+ "template": false
},
{
"id": 7,
"updated_at": "2019-06-06 12:03:04",
"created_by": 3,
"updated_by": 3,
- "draft": 0,
+ "draft": false,
"revision_count": 1,
- "template": 0
+ "template": false
}
]
}
\ No newline at end of file
--- /dev/null
+{
+ "id": 358,
+ "book_id": 1,
+ "chapter_id": 0,
+ "name": "My API Page",
+ "slug": "my-api-page",
+ "html": "<p id=\"bkmrk-my-new-api-page\">my new API page</p>",
+ "priority": 14,
+ "created_at": "2020-11-28 15:01:39",
+ "updated_at": "2020-11-28 15:01:39",
+ "created_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "updated_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "draft": false,
+ "markdown": "",
+ "revision_count": 1,
+ "template": false,
+ "tags": [
+ {
+ "name": "Category",
+ "value": "Not Bad Content",
+ "order": 0
+ },
+ {
+ "name": "Rating",
+ "value": "Average",
+ "order": 1
+ }
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "data": [
+ {
+ "id": 1,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "How to create page content",
+ "slug": "how-to-create-page-content",
+ "priority": 0,
+ "draft": false,
+ "template": false,
+ "created_at": "2019-05-05 21:49:58",
+ "updated_at": "2020-07-04 15:50:58",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ {
+ "id": 2,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "How to use images",
+ "slug": "how-to-use-images",
+ "priority": 2,
+ "draft": false,
+ "template": false,
+ "created_at": "2019-05-05 21:53:30",
+ "updated_at": "2019-06-06 12:03:04",
+ "created_by": 1,
+ "updated_by": 1
+ },
+ {
+ "id": 3,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "Drawings via draw.io",
+ "slug": "drawings-via-drawio",
+ "priority": 3,
+ "draft": false,
+ "template": false,
+ "created_at": "2019-05-05 21:53:49",
+ "updated_at": "2019-12-18 21:56:52",
+ "created_by": 1,
+ "updated_by": 1
+ }
+ ],
+ "total": 322
+}
\ No newline at end of file
--- /dev/null
+{
+ "id": 306,
+ "book_id": 1,
+ "chapter_id": 0,
+ "name": "A page written in markdown",
+ "slug": "a-page-written-in-markdown",
+ "html": "<h1 id=\"bkmrk-how-this-is-built\">How this is built</h1>\r\n<p id=\"bkmrk-this-page-is-written\">This page is written in markdown. BookStack stores the page data in HTML.</p>\r\n<p id=\"bkmrk-here%27s-a-cute-pictur\">Here's a cute picture of my cat:</p>\r\n<p id=\"bkmrk-\"><a href=\"http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg\"><img src=\"http://example.com/uploads/images/gallery/2020-04/scaled-1680-/yXSrubes.jpg\" alt=\"yXSrubes.jpg\"></a></p>",
+ "priority": 13,
+ "created_at": "2020-02-02 21:40:38",
+ "updated_at": "2020-11-28 14:43:20",
+ "created_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "updated_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "draft": false,
+ "markdown": "# How this is built\r\n\r\nThis page is written in markdown. BookStack stores the page data in HTML.\r\n\r\nHere's a cute picture of my cat:\r\n\r\n[](http://example.com/uploads/images/gallery/2020-04/yXSrubes.jpg)",
+ "revision_count": 5,
+ "template": false,
+ "tags": [
+ {
+ "name": "Category",
+ "value": "Top Content",
+ "order": 0
+ },
+ {
+ "name": "Animal",
+ "value": "Cat",
+ "order": 1
+ }
+ ]
+}
\ No newline at end of file
--- /dev/null
+{
+ "id": 361,
+ "book_id": 1,
+ "chapter_id": 1,
+ "name": "My updated API Page",
+ "slug": "my-updated-api-page",
+ "html": "<p id=\"bkmrk-my-new-api-page---up\">my new API page - Updated</p>",
+ "priority": 16,
+ "created_at": "2020-11-28 15:10:54",
+ "updated_at": "2020-11-28 15:13:03",
+ "created_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "updated_by": {
+ "id": 1,
+ "name": "Admin"
+ },
+ "draft": false,
+ "markdown": "",
+ "revision_count": 5,
+ "template": false,
+ "tags": [
+ {
+ "name": "Category",
+ "value": "API Examples",
+ "order": 0
+ },
+ {
+ "name": "Rating",
+ "value": "Alright",
+ "order": 0
+ }
+ ]
+}
\ No newline at end of file
"tags": [
{
"id": 16,
- "entity_id": 14,
- "entity_type": "BookStack\\Bookshelf",
"name": "Category",
"value": "Guide",
- "order": 0,
- "created_at": "2020-04-10 13:31:04",
- "updated_at": "2020-04-10 13:31:04"
+ "order": 0
}
],
"cover": {
if [[ -n "$1" ]]; then
exec "$@"
else
+ composer install
wait-for-it db:3306 -t 45
php artisan migrate --database=mysql
chown -R www-data:www-data storage
exec apache2-foreground
-fi
\ No newline at end of file
+fi
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
- bootstrap="bootstrap/init.php"
+ bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
/*
|--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
|--------------------------------------------------------------------------
|
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader for
+| our application. We just need to utilize it! We'll simply require it
+| into the script here so that we don't have to worry about manual
+| loading any of our classes later on. It feels great to relax.
|
*/
-require __DIR__.'/../bootstrap/init.php';
+require __DIR__.'/../vendor/autoload.php';
/*
|--------------------------------------------------------------------------
If all the conditions are met, you can proceed with the following steps:
-1. Install PHP/Composer dependencies with **`docker-compose run app composer install`** (first time can take a while because the image has to be built).
-2. **Copy `.env.example` to `.env`** and change `APP_KEY` to a random 32 char string.
-3. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
-4. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
-5. **Run `docker-compose up`** and wait until all database migrations have been done.
-6. You can now login with `
[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
+1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
+2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
+3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
+4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
+5. You can now login with `
[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
If needed, You'll be able to run any artisan commands via docker-compose like so:
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
* [diagrams.net](https://github.com/jgraph/drawio)
* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
-* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
\ No newline at end of file
+* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
this.pageId = this.$opts.pageId;
this.textDirection = this.$opts.textDirection;
+ this.imageUploadErrorText = this.$opts.imageUploadErrorText;
this.markdown = new MarkdownIt({html: true});
this.markdown.use(mdTasksLists, {label: true});
const newContent = `[](${resp.data.url})`;
replaceContent(placeHolderText, newContent);
}).catch(err => {
- window.$events.emit('error', trans('errors.image_upload_error'));
+ window.$events.emit('error', context.imageUploadErrorText);
replaceContent(placeHolderText, selectedText);
console.log(err);
});
this.cm.focus();
DrawIO.close();
}).catch(err => {
- window.$events.emit('error', trans('errors.image_upload_error'));
+ window.$events.emit('error', this.imageUploadErrorText);
console.log(err);
});
});
editor.dom.replace(newEl, id);
}).catch(err => {
editor.dom.remove(id);
- window.$events.emit('error', trans('errors.image_upload_error'));
+ window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
console.log(err);
});
}, 10);
});
}
-function drawIoPlugin(drawioUrl, isDarkMode, pageId) {
+function drawIoPlugin(drawioUrl, isDarkMode, pageId, wysiwygComponent) {
let pageEditor = null;
let currentNode = null;
pageEditor.dom.setAttrib(imgElem, 'src', img.url);
pageEditor.dom.setAttrib(currentNode, 'drawio-diagram', img.id);
} catch (err) {
- window.$events.emit('error', trans('errors.image_upload_error'));
+ window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
console.log(err);
}
return;
pageEditor.dom.get(id).parentNode.setAttribute('drawio-diagram', img.id);
} catch (err) {
pageEditor.dom.remove(id);
- window.$events.emit('error', trans('errors.image_upload_error'));
+ window.$events.emit('error', wysiwygComponent.imageUploadErrorText);
console.log(err);
}
}, 5);
class WysiwygEditor {
-
setup() {
this.elem = this.$el;
this.pageId = this.$opts.pageId;
this.textDirection = this.$opts.textDirection;
+ this.imageUploadErrorText = this.$opts.imageUploadErrorText;
this.isDarkMode = document.documentElement.classList.contains('dark-mode');
this.plugins = "image table textcolor paste link autolink fullscreen code customhr autosave lists codeeditor media";
const drawioUrlElem = document.querySelector('[drawio-url]');
if (drawioUrlElem) {
const url = drawioUrlElem.getAttribute('drawio-url');
- drawIoPlugin(url, this.isDarkMode, this.pageId);
+ drawIoPlugin(url, this.isDarkMode, this.pageId, this);
this.plugins += ' drawio';
}
// Other
'commented_on' => 'commented on',
+ 'permissions_update' => 'updated permissions',
];
'chapters_create' => 'Create New Chapter',
'chapters_delete' => 'Delete Chapter',
'chapters_delete_named' => 'Delete Chapter :chapterName',
- 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages will be removed and added directly to the parent book.',
+ 'chapters_delete_explain' => 'This will delete the chapter with the name \':chapterName\'. All pages that exist within this chapter will also be deleted.',
'chapters_delete_confirm' => 'Are you sure you want to delete this chapter?',
'chapters_edit' => 'Edit Chapter',
'chapters_edit_named' => 'Edit Chapter :chapterName',
'maint_send_test_email_mail_subject' => 'Test Email',
'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!',
'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.',
+ 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
+ 'maint_recycle_bin_open' => 'Open Recycle Bin',
+
+ // Recycle Bin
+ 'recycle_bin' => 'Recycle Bin',
+ 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.',
+ 'recycle_bin_deleted_item' => 'Deleted Item',
+ 'recycle_bin_deleted_by' => 'Deleted By',
+ 'recycle_bin_deleted_at' => 'Deletion Time',
+ 'recycle_bin_permanently_delete' => 'Permanently Delete',
+ 'recycle_bin_restore' => 'Restore',
+ 'recycle_bin_contents_empty' => 'The recycle bin is currently empty',
+ 'recycle_bin_empty' => 'Empty Recycle Bin',
+ 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?',
+ 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?',
+ 'recycle_bin_destroy_list' => 'Items to be Destroyed',
+ 'recycle_bin_restore_list' => 'Items to be Restored',
+ 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.',
+ 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.',
+ 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.',
+ 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.',
// Audit Log
'audit' => 'Audit Log',
'audit_deleted_item_name' => 'Name: :name',
'audit_table_user' => 'User',
'audit_table_event' => 'Event',
- 'audit_table_item' => 'Related Item',
+ 'audit_table_related' => 'Related Item or Detail',
'audit_table_date' => 'Activity Date',
'audit_date_from' => 'Date Range From',
'audit_date_to' => 'Date Range To',
'user_profile' => 'User Profile',
'users_add_new' => 'Add New User',
'users_search' => 'Search Users',
+ 'users_latest_activity' => 'Latest Activity',
'users_details' => 'User Details',
'users_details_desc' => 'Set a display name and an email address for this user. The email address will be used for logging into the application.',
'users_details_desc_no_email' => 'Set a display name for this user so others can recognise them.',
.sticky-sidebar {
position: sticky;
top: $-m;
+ max-height: calc(100vh - #{$-m});
+ overflow-y: auto;
}
.justify-flex-end {
justify-content: flex-end;
}
+.justify-center {
+ justify-content: center;
+}
/**
* Display and float utilities
*/
.block {
- display: block;
+ display: block !important;
position: relative;
}
.inline {
- display: inline;
+ display: inline !important;
}
.block.inline {
- display: inline-block;
+ display: inline-block !important;
}
.hidden {
}
}
-table a.audit-log-user {
+table.table .table-user-item {
display: grid;
grid-template-columns: 42px 1fr;
align-items: center;
}
-table a.icon-list-item {
+table.table .table-entity-item {
display: grid;
grid-template-columns: 36px 1fr;
align-items: center;
<ul class="sortable-page-list sort-list">
@foreach($bookChildren as $bookChild)
- <li class="text-{{ $bookChild->getClassName() }}"
- data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getClassName() }}"
+ <li class="text-{{ $bookChild->getType() }}"
+ data-id="{{$bookChild->id}}" data-type="{{ $bookChild->getType() }}"
data-name="{{ $bookChild->name }}" data-created="{{ $bookChild->created_at->timestamp }}"
data-updated="{{ $bookChild->updated_at->timestamp }}">
<div class="entity-list-item">
<div class="links text-center">
@if (hasAppAccess())
<a class="hide-over-l" href="{{ url('/search') }}">@icon('search'){{ trans('common.search') }}</a>
- @if(userCanOnAny('view', \BookStack\Entities\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+ @if(userCanOnAny('view', \BookStack\Entities\Models\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
<a href="{{ url('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
@endif
<a href="{{ url('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
<div page-picker>
<div class="input-base">
<span @if($value) style="display: none" @endif page-picker-default class="text-muted italic">{{ $placeholder }}</span>
- <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Page::find($value)->name : '' }}</a>
+ <a @if(!$value) style="display: none" @endif href="{{ url('/link/' . $value) }}" target="_blank" class="text-page" page-picker-display>#{{$value}}, {{$value ? \BookStack\Entities\Models\Page::find($value)->name : '' }}</a>
</div>
<br>
<input type="hidden" value="{{$value}}" name="{{$name}}" id="{{$name}}">
<div id="markdown-editor" component="markdown-editor"
option:markdown-editor:page-id="{{ $model->id ?? 0 }}"
option:markdown-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+ option:markdown-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
class="flex-fill flex code-fill">
- @exposeTranslations([
- 'errors.image_upload_error',
- ])
<div class="markdown-editor-wrap active">
<div class="editor-toolbar">
<div component="wysiwyg-editor"
option:wysiwyg-editor:page-id="{{ $model->id ?? 0 }}"
option:wysiwyg-editor:text-direction="{{ config('app.rtl') ? 'rtl' : 'ltr' }}"
+ option:wysiwyg-editor:image-upload-error-text="{{ trans('errors.image_upload_error') }}"
class="flex-fill flex">
- @exposeTranslations([
- 'errors.image_upload_error',
- ])
-
<textarea id="html-editor" name="html" rows="5"
@if($errors->has('html')) class="text-neg" @endif>@if(isset($model) || old('html')){{ old('html') ? old('html') : $model->html }}@endif</textarea>
</div>
{{ $activity->getText() }}
- @if($activity->entity)
+ @if($activity->entity && is_null($activity->entity->deleted_at))
<a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
@endif
+ @if($activity->entity && !is_null($activity->entity->deleted_at))
+ "{{ $activity->entity->name }}"
+ @endif
+
@if($activity->extra) "{{ $activity->extra }}" @endif
<br>
@endif
@foreach($sidebarTree as $bookChild)
- <li class="list-item-{{ $bookChild->getClassName() }} {{ $bookChild->getClassName() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
+ <li class="list-item-{{ $bookChild->getType() }} {{ $bookChild->getType() }} {{ $bookChild->isA('page') && $bookChild->draft ? 'draft' : '' }}">
@include('partials.entity-list-item-basic', ['entity' => $bookChild, 'classes' => $current->matches($bookChild)? 'selected' : ''])
@if($bookChild->isA('chapter') && count($bookChild->visible_pages) > 0)
<?php $breadcrumbCount = 0; ?>
{{-- Show top level books item --}}
- @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof \BookStack\Entities\Book)
+ @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof \BookStack\Entities\Models\Book)
<a href="{{ url('/books') }}" class="text-book icon-list-item outline-hover">
<span>@icon('books')</span>
<span>{{ trans('entities.books') }}</span>
@endif
{{-- Show top level shelves item --}}
- @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof \BookStack\Entities\Bookshelf)
+ @if (count($crumbs) > 0 && ($crumbs[0] ?? null) instanceof \BookStack\Entities\Models\Bookshelf)
<a href="{{ url('/shelves') }}" class="text-bookshelf icon-list-item outline-hover">
<span>@icon('bookshelf')</span>
<span>{{ trans('entities.shelves') }}</span>
@endif
@foreach($crumbs as $key => $crumb)
- <?php $isEntity = ($crumb instanceof \BookStack\Entities\Entity); ?>
+ <?php $isEntity = ($crumb instanceof \BookStack\Entities\Models\Entity); ?>
@if (is_null($crumb))
<?php continue; ?>
--- /dev/null
+<?php $type = $entity->getType(); ?>
+<div class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item no-hover">
+ <span role="presentation" class="icon text-{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}}">@icon($type)</span>
+ <div class="content">
+ <div class="entity-list-item-name break-text">{{ $entity->name }}</div>
+ </div>
+</div>
\ No newline at end of file
--- /dev/null
+{{--
+$user - User mode to display, Can be null.
+$user_id - Id of user to show. Must be provided.
+--}}
+@if($user)
+ <a href="{{ $user->getEditUrl() }}" class="table-user-item">
+ <div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+ <div>{{ $user->name }}</div>
+ </a>
+@else
+ [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
+@endif
\ No newline at end of file
<button refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
<ul refs="dropdown@menu" class="dropdown-menu">
<li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
- @foreach($activityKeys as $key)
- <li @if($key === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $key]) }}">{{ $key }}</a></li>
+ @foreach($activityTypes as $type)
+ <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}">{{ $type }}</a></li>
@endforeach
</ul>
</div>
<th>
<a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
</th>
- <th>{{ trans('settings.audit_table_item') }}</th>
+ <th>{{ trans('settings.audit_table_related') }}</th>
<th>
<a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
</tr>
@foreach($activities as $activity)
<tr>
<td>
- @if($activity->user)
- <a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
- <div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
- <div>{{ $activity->user->name }}</div>
- </a>
- @else
- [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
- @endif
+ @include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
</td>
- <td>{{ $activity->key }}</td>
+ <td>{{ $activity->type }}</td>
<td>
@if($activity->entity)
- <a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
+ <a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
<span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
<div class="text-{{ $activity->entity->getType() }}">
{{ $activity->entity->name }}
</div>
</a>
- @elseif($activity->extra)
+ @elseif($activity->detail && $activity->isForEntity())
<div class="px-m">
{{ trans('settings.audit_deleted_item') }} <br>
- {{ trans('settings.audit_deleted_item_name', ['name' => $activity->extra]) }}
+ {{ trans('settings.audit_deleted_item_name', ['name' => $activity->detail]) }}
</div>
+ @elseif($activity->detail)
+ <div class="px-m">{{ $activity->detail }}</div>
@endif
</td>
<td>{{ $activity->created_at }}</td>
@include('settings.navbar-with-version', ['selected' => 'maintenance'])
+ <div class="card content-wrap auto-height pb-xl">
+ <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+ <div class="grid half gap-xl">
+ <div>
+ <p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
+ </div>
+ <div>
+ <div class="grid half no-gap mb-m">
+ <p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
+ <p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
+ <p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
+ <p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
+ </div>
+ <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
+ </div>
+ </div>
+ </div>
+
<div id="image-cleanup" class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
<div class="grid half gap-xl">
<form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE">
- <div>
+ <div class="mb-s">
@if(session()->has('cleanup-images-warning'))
<p class="text-neg">
{{ session()->get('cleanup-images-warning') }}
--- /dev/null
+@include('partials.entity-display-item', ['entity' => $entity])
+@if($entity->isA('book'))
+ @foreach($entity->chapters()->withTrashed()->get() as $chapter)
+ @include('partials.entity-display-item', ['entity' => $chapter])
+ @endforeach
+@endif
+@if($entity->isA('book') || $entity->isA('chapter'))
+ @foreach($entity->pages()->withTrashed()->get() as $page)
+ @include('partials.entity-display-item', ['entity' => $page])
+ @endforeach
+@endif
\ No newline at end of file
--- /dev/null
+@extends('simple-layout')
+
+@section('body')
+ <div class="container small">
+
+ <div class="grid left-focus v-center no-row-gap">
+ <div class="py-m">
+ @include('settings.navbar', ['selected' => 'maintenance'])
+ </div>
+ </div>
+
+ <div class="card content-wrap auto-height">
+ <h2 class="list-heading">{{ trans('settings.recycle_bin_permanently_delete') }}</h2>
+ <p class="text-muted">{{ trans('settings.recycle_bin_destroy_confirm') }}</p>
+ <form action="{{ url('/settings/recycle-bin/' . $deletion->id) }}" method="post">
+ {!! method_field('DELETE') !!}
+ {!! csrf_field() !!}
+ <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+ <button type="submit" class="button">{{ trans('common.delete_confirm') }}</button>
+ </form>
+
+ @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+ <hr class="mt-m">
+ <h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
+ @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+ @endif
+
+ </div>
+
+ </div>
+@stop
--- /dev/null
+@extends('simple-layout')
+
+@section('body')
+ <div class="container">
+
+ <div class="grid left-focus v-center no-row-gap">
+ <div class="py-m">
+ @include('settings.navbar', ['selected' => 'maintenance'])
+ </div>
+ </div>
+
+ <div class="card content-wrap auto-height">
+ <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
+
+ <div class="grid half left-focus">
+ <div>
+ <p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
+ </div>
+ <div class="text-right">
+ <div component="dropdown" class="dropdown-container">
+ <button refs="dropdown@toggle"
+ type="button"
+ class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
+ <div refs="dropdown@menu" class="dropdown-menu">
+ <p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
+
+ <form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
+ {!! csrf_field() !!}
+ <button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
+ </form>
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+
+ <hr class="mt-l mb-s">
+
+ {!! $deletions->links() !!}
+
+ <table class="table">
+ <tr>
+ <th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
+ <th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
+ <th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
+ <th></th>
+ </tr>
+ @if(count($deletions) === 0)
+ <tr>
+ <td colspan="4">
+ <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
+ </td>
+ </tr>
+ @endif
+ @foreach($deletions as $deletion)
+ <tr>
+ <td>
+ <div class="table-entity-item">
+ <span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
+ <div class="text-{{ $deletion->deletable->getType() }}">
+ {{ $deletion->deletable->name }}
+ </div>
+ </div>
+ @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+ <div class="mb-m"></div>
+ @endif
+ @if($deletion->deletable instanceof \BookStack\Entities\Models\Book)
+ <div class="pl-xl block inline">
+ <div class="text-chapter">
+ @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
+ </div>
+ </div>
+ @endif
+ @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+ <div class="pl-xl block inline">
+ <div class="text-page">
+ @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
+ </div>
+ </div>
+ @endif
+ </td>
+ <td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
+ <td width="200">{{ $deletion->created_at }}</td>
+ <td width="150" class="text-right">
+ <div component="dropdown" class="dropdown-container">
+ <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
+ <ul refs="dropdown@menu" class="dropdown-menu">
+ <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+ <li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+ </ul>
+ </div>
+ </td>
+ </tr>
+ @endforeach
+ </table>
+
+ {!! $deletions->links() !!}
+
+ </div>
+
+ </div>
+@stop
--- /dev/null
+@extends('simple-layout')
+
+@section('body')
+ <div class="container small">
+
+ <div class="grid left-focus v-center no-row-gap">
+ <div class="py-m">
+ @include('settings.navbar', ['selected' => 'maintenance'])
+ </div>
+ </div>
+
+ <div class="card content-wrap auto-height">
+ <h2 class="list-heading">{{ trans('settings.recycle_bin_restore') }}</h2>
+ <p class="text-muted">{{ trans('settings.recycle_bin_restore_confirm') }}</p>
+ <form action="{{ url('/settings/recycle-bin/' . $deletion->id . '/restore') }}" method="post">
+ {!! csrf_field() !!}
+ <a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
+ <button type="submit" class="button">{{ trans('settings.recycle_bin_restore') }}</button>
+ </form>
+
+ @if($deletion->deletable instanceof \BookStack\Entities\Models\Entity)
+ <hr class="mt-m">
+ <h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
+ @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
+ <p class="text-neg">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</p>
+ @endif
+ @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
+ @endif
+
+ </div>
+
+ </div>
+@stop
</div>
</div>
- {{--TODO - Add last login--}}
<table class="table">
<tr>
<th></th>
<a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'email']) }}">{{ trans('auth.email') }}</a>
</th>
<th>{{ trans('settings.role_user_roles') }}</th>
+ <th class="text-right">
+ <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'latest_activity']) }}">{{ trans('settings.users_latest_activity') }}</a>
+ </th>
</tr>
@foreach($users as $user)
<tr>
<small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
@endforeach
</td>
+ <td class="text-right text-muted">
+ @if($user->latestActivity)
+ <small title="{{ $user->latestActivity->created_at->format('Y-m-d H:i:s') }}">{{ $user->latestActivity->created_at->diffForHumans() }}</small>
+ @endif
+ </td>
</tr>
@endforeach
</table>
Route::get('chapters/{id}/export/pdf', 'ChapterExportApiController@exportPdf');
Route::get('chapters/{id}/export/plaintext', 'ChapterExportApiController@exportPlainText');
+Route::get('pages', 'PageApiController@list');
+Route::post('pages', 'PageApiController@create');
+Route::get('pages/{id}', 'PageApiController@read');
+Route::put('pages/{id}', 'PageApiController@update');
+Route::delete('pages/{id}', 'PageApiController@delete');
+
+Route::get('pages/{id}/export/html', 'PageExportApiController@exportHtml');
+Route::get('pages/{id}/export/pdf', 'PageExportApiController@exportPdf');
+Route::get('pages/{id}/export/plaintext', 'PageExportApiController@exportPlainText');
+
Route::get('shelves', 'BookshelfApiController@list');
Route::post('shelves', 'BookshelfApiController@create');
Route::get('shelves/{id}', 'BookshelfApiController@read');
Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages');
Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail');
+ // Recycle Bin
+ Route::get('/recycle-bin', 'RecycleBinController@index');
+ Route::post('/recycle-bin/empty', 'RecycleBinController@empty');
+ Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy');
+ Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy');
+ Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore');
+ Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore');
+
// Audit Log
Route::get('/audit', 'AuditLogController@index');
Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
// Roles
- Route::get('/roles', 'PermissionController@listRoles');
- Route::get('/roles/new', 'PermissionController@createRole');
- Route::post('/roles/new', 'PermissionController@storeRole');
- Route::get('/roles/delete/{id}', 'PermissionController@showDeleteRole');
- Route::delete('/roles/delete/{id}', 'PermissionController@deleteRole');
- Route::get('/roles/{id}', 'PermissionController@editRole');
- Route::put('/roles/{id}', 'PermissionController@updateRole');
+ Route::get('/roles', 'RoleController@list');
+ Route::get('/roles/new', 'RoleController@create');
+ Route::post('/roles/new', 'RoleController@store');
+ Route::get('/roles/delete/{id}', 'RoleController@showDelete');
+ Route::delete('/roles/delete/{id}', 'RoleController@delete');
+ Route::get('/roles/{id}', 'RoleController@edit');
+ Route::put('/roles/{id}', 'RoleController@update');
});
});
<?php namespace Tests;
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
class ActivityTrackingTest extends BrowserKitTest
{
<?php namespace Tests\Api;
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
use Tests\TestCase;
class ApiListingTest extends TestCase
<?php namespace Tests\Api;
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
use Tests\TestCase;
class BooksApiTest extends TestCase
<?php namespace Tests\Api;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
use Tests\TestCase;
class ChaptersApiTest extends TestCase
--- /dev/null
+<?php namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PagesApiTest extends TestCase
+{
+ use TestsApi;
+
+ protected $baseEndpoint = '/api/pages';
+
+ public function test_index_endpoint_returns_expected_page()
+ {
+ $this->actingAsApiEditor();
+ $firstPage = Page::query()->orderBy('id', 'asc')->first();
+
+ $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id');
+ $resp->assertJson(['data' => [
+ [
+ 'id' => $firstPage->id,
+ 'name' => $firstPage->name,
+ 'slug' => $firstPage->slug,
+ 'book_id' => $firstPage->book->id,
+ 'priority' => $firstPage->priority,
+ ]
+ ]]);
+ }
+
+ public function test_create_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::query()->first();
+ $details = [
+ 'name' => 'My API page',
+ 'book_id' => $book->id,
+ 'html' => '<p>My new page content</p>',
+ 'tags' => [
+ [
+ 'name' => 'tagname',
+ 'value' => 'tagvalue',
+ ]
+ ]
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ unset($details['html']);
+ $resp->assertStatus(200);
+ $newItem = Page::query()->orderByDesc('id')->where('name', '=', $details['name'])->first();
+ $resp->assertJson(array_merge($details, ['id' => $newItem->id, 'slug' => $newItem->slug]));
+ $this->assertDatabaseHas('tags', [
+ 'entity_id' => $newItem->id,
+ 'entity_type' => $newItem->getMorphClass(),
+ 'name' => 'tagname',
+ 'value' => 'tagvalue',
+ ]);
+ $resp->assertSeeText('My new page content');
+ $resp->assertJsonMissing(['book' => []]);
+ $this->assertActivityExists('page_create', $newItem);
+ }
+
+ public function test_page_name_needed_to_create()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::query()->first();
+ $details = [
+ 'book_id' => $book->id,
+ 'html' => '<p>A page created via the API</p>',
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(422);
+ $resp->assertJson($this->validationResponse([
+ "name" => ["The name field is required."]
+ ]));
+ }
+
+ public function test_book_id_or_chapter_id_needed_to_create()
+ {
+ $this->actingAsApiEditor();
+ $details = [
+ 'name' => 'My api page',
+ 'html' => '<p>A page created via the API</p>',
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertStatus(422);
+ $resp->assertJson($this->validationResponse([
+ "book_id" => ["The book id field is required when chapter id is not present."],
+ "chapter_id" => ["The chapter id field is required when book id is not present."]
+ ]));
+
+ $chapter = Chapter::visible()->first();
+ $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['chapter_id' => $chapter->id]));
+ $resp->assertStatus(200);
+
+ $book = Book::visible()->first();
+ $resp = $this->postJson($this->baseEndpoint, array_merge($details, ['book_id' => $book->id]));
+ $resp->assertStatus(200);
+ }
+
+ public function test_markdown_can_be_provided_for_create()
+ {
+ $this->actingAsApiEditor();
+ $book = Book::visible()->first();
+ $details = [
+ 'book_id' => $book->id,
+ 'name' => 'My api page',
+ 'markdown' => "# A new API page \n[link](https://example.com)",
+ ];
+
+ $resp = $this->postJson($this->baseEndpoint, $details);
+ $resp->assertJson(['markdown' => $details['markdown']]);
+
+ $respHtml = $resp->json('html');
+ $this->assertStringContainsString('new API page</h1>', $respHtml);
+ $this->assertStringContainsString('link</a>', $respHtml);
+ $this->assertStringContainsString('href="https://example.com"', $respHtml);
+ }
+
+ public function test_read_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+
+ $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ 'id' => $page->id,
+ 'slug' => $page->slug,
+ 'created_by' => [
+ 'name' => $page->createdBy->name,
+ ],
+ 'book_id' => $page->book_id,
+ 'updated_by' => [
+ 'name' => $page->createdBy->name,
+ ],
+ ]);
+ }
+
+ public function test_read_endpoint_provides_rendered_html()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+ $page->html = "<p>testing</p><script>alert('danger')</script><h1>Hello</h1>";
+ $page->save();
+
+ $resp = $this->getJson($this->baseEndpoint . "/{$page->id}");
+ $html = $resp->json('html');
+ $this->assertStringNotContainsString('script', $html);
+ $this->assertStringContainsString('Hello', $html);
+ $this->assertStringContainsString('testing', $html);
+ }
+
+ public function test_update_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+ $details = [
+ 'name' => 'My updated API page',
+ 'html' => '<p>A page created via the API</p>',
+ 'tags' => [
+ [
+ 'name' => 'freshtag',
+ 'value' => 'freshtagval',
+ ]
+ ],
+ ];
+
+ $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+ $page->refresh();
+
+ $resp->assertStatus(200);
+ unset($details['html']);
+ $resp->assertJson(array_merge($details, [
+ 'id' => $page->id, 'slug' => $page->slug, 'book_id' => $page->book_id
+ ]));
+ $this->assertActivityExists('page_update', $page);
+ }
+
+ public function test_providing_new_chapter_id_on_update_will_move_page()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+ $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+ $details = [
+ 'name' => 'My updated API page',
+ 'chapter_id' => $chapter->id,
+ 'html' => '<p>A page created via the API</p>',
+ ];
+
+ $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+ $resp->assertStatus(200);
+ $resp->assertJson([
+ 'chapter_id' => $chapter->id,
+ 'book_id' => $chapter->book_id,
+ ]);
+ }
+
+ public function test_providing_move_via_update_requires_page_create_permission_on_new_parent()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+ $chapter = Chapter::visible()->where('book_id', '!=', $page->book_id)->first();
+ $this->setEntityRestrictions($chapter, ['view'], [$this->getEditor()->roles()->first()]);
+ $details = [
+ 'name' => 'My updated API page',
+ 'chapter_id' => $chapter->id,
+ 'html' => '<p>A page created via the API</p>',
+ ];
+
+ $resp = $this->putJson($this->baseEndpoint . "/{$page->id}", $details);
+ $resp->assertStatus(403);
+ }
+
+ public function test_delete_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+ $resp = $this->deleteJson($this->baseEndpoint . "/{$page->id}");
+
+ $resp->assertStatus(204);
+ $this->assertActivityExists('page_delete', $page);
+ }
+
+ public function test_export_html_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/html");
+ $resp->assertStatus(200);
+ $resp->assertSee($page->name);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.html"');
+ }
+
+ public function test_export_plain_text_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/plaintext");
+ $resp->assertStatus(200);
+ $resp->assertSee($page->name);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.txt"');
+ }
+
+ public function test_export_pdf_endpoint()
+ {
+ $this->actingAsApiEditor();
+ $page = Page::visible()->first();
+
+ $resp = $this->get($this->baseEndpoint . "/{$page->id}/export/pdf");
+ $resp->assertStatus(200);
+ $resp->assertHeader('Content-Disposition', 'attachment; filename="' . $page->slug . '.pdf"');
+ }
+}
\ No newline at end of file
<?php namespace Tests\Api;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
use Tests\TestCase;
class ShelvesApiTest extends TestCase
use BookStack\Actions\Activity;
use BookStack\Actions\ActivityService;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\UserRepo;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use Carbon\Carbon;
class AuditLogTest extends TestCase
{
+ /** @var ActivityService */
+ protected $activityService;
+
+ public function setUp(): void
+ {
+ parent::setUp();
+ $this->activityService = app(ActivityService::class);
+ }
public function test_only_accessible_with_right_permissions()
{
$admin = $this->getAdmin();
$this->actingAs($admin);
$page = Page::query()->first();
- app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+ $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$activity = Activity::query()->orderBy('id', 'desc')->first();
$resp = $this->get('settings/audit');
$resp->assertSeeText($page->name);
$resp->assertSeeText('page_create');
$resp->assertSeeText($activity->created_at->toDateTimeString());
- $resp->assertElementContains('.audit-log-user', $admin->name);
+ $resp->assertElementContains('.table-user-item', $admin->name);
}
public function test_shows_name_for_deleted_items()
$this->actingAs( $this->getAdmin());
$page = Page::query()->first();
$pageName = $page->name;
- app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+ $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
app(PageRepo::class)->destroy($page);
+ app(TrashCan::class)->empty();
$resp = $this->get('settings/audit');
$resp->assertSeeText('Deleted Item');
$viewer = $this->getViewer();
$this->actingAs($viewer);
$page = Page::query()->first();
- app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+ $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$this->actingAs($this->getAdmin());
app(UserRepo::class)->destroy($viewer);
{
$this->actingAs($this->getAdmin());
$page = Page::query()->first();
- app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+ $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$resp = $this->get('settings/audit');
$resp->assertSeeText($page->name);
{
$this->actingAs($this->getAdmin());
$page = Page::query()->first();
- app(ActivityService::class)->add($page, 'page_create', $page->book->id);
+ $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE);
$yesterday = (Carbon::now()->subDay()->format('Y-m-d'));
$tomorrow = (Carbon::now()->addDay()->format('Y-m-d'));
use BookStack\Auth\Role;
use BookStack\Auth\User;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Notifications\ResetPassword;
use BookStack\Settings\SettingService;
<?php namespace Tests;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use BookStack\Auth\Role;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Settings\SettingService;
protected function createEntityChainBelongingToUser($creatorUser, $updaterUser = false)
{
if ($updaterUser === false) $updaterUser = $creatorUser;
- $book = factory(\BookStack\Entities\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
- $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
- $page = factory(\BookStack\Entities\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]);
+ $book = factory(\BookStack\Entities\Models\Book::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id]);
+ $chapter = factory(\BookStack\Entities\Models\Chapter::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id]);
+ $page = factory(\BookStack\Entities\Models\Page::class)->create(['created_by' => $creatorUser->id, 'updated_by' => $updaterUser->id, 'book_id' => $book->id, 'chapter_id' => $chapter->id]);
$restrictionService = $this->app[PermissionService::class];
$restrictionService->buildJointPermissionsForEntity($book);
return [
<?php namespace Tests;
+use BookStack\Actions\ActivityType;
use BookStack\Actions\Comment;
use BookStack\Actions\CommentRepo;
use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Page;
use BookStack\Auth\User;
use BookStack\Entities\Repos\PageRepo;
use Symfony\Component\Console\Exception\RuntimeException;
{
$this->asEditor();
$page = Page::first();
- \Activity::add($page, 'page_update', $page->book->id);
+ \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
$this->assertDatabaseHas('activities', [
- 'key' => 'page_update',
+ 'type' => 'page_update',
'entity_id' => $page->id,
'user_id' => $this->getEditor()->id
]);
$this->assertDatabaseMissing('activities', [
- 'key' => 'page_update'
+ 'type' => 'page_update'
]);
}
<?php namespace Tests\Entity;
use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
use BookStack\Uploads\Image;
use Illuminate\Support\Str;
use Tests\TestCase;
public function test_shelf_delete()
{
- $shelf = Bookshelf::first();
- $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
- $resp->assertSeeText('Delete Bookshelf');
- $resp->assertSee("action=\"{$shelf->getUrl()}\"");
-
- $resp = $this->delete($shelf->getUrl());
- $resp->assertRedirect('/shelves');
- $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
- $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
- $this->assertSessionHas('success');
+ $shelf = Bookshelf::query()->whereHas('books')->first();
+ $this->assertNull($shelf->deleted_at);
+ $bookCount = $shelf->books()->count();
+
+ $deleteViewReq = $this->asEditor()->get($shelf->getUrl('/delete'));
+ $deleteViewReq->assertSeeText('Are you sure you want to delete this bookshelf?');
+
+ $deleteReq = $this->delete($shelf->getUrl());
+ $deleteReq->assertRedirect(url('/shelves'));
+ $this->assertActivityExists('bookshelf_delete', $shelf);
+
+ $shelf->refresh();
+ $this->assertNotNull($shelf->deleted_at);
+
+ $this->assertTrue($shelf->books()->count() === $bookCount);
+ $this->assertTrue($shelf->deletions()->count() === 1);
+
+ $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+ $redirectReq->assertNotificationContains('Bookshelf Successfully Deleted');
}
public function test_shelf_copy_permissions()
--- /dev/null
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Book;
+use Tests\TestCase;
+
+class BookTest extends TestCase
+{
+ public function test_book_delete()
+ {
+ $book = Book::query()->whereHas('pages')->whereHas('chapters')->first();
+ $this->assertNull($book->deleted_at);
+ $pageCount = $book->pages()->count();
+ $chapterCount = $book->chapters()->count();
+
+ $deleteViewReq = $this->asEditor()->get($book->getUrl('/delete'));
+ $deleteViewReq->assertSeeText('Are you sure you want to delete this book?');
+
+ $deleteReq = $this->delete($book->getUrl());
+ $deleteReq->assertRedirect(url('/books'));
+ $this->assertActivityExists('book_delete', $book);
+
+ $book->refresh();
+ $this->assertNotNull($book->deleted_at);
+
+ $this->assertTrue($book->pages()->count() === 0);
+ $this->assertTrue($book->chapters()->count() === 0);
+ $this->assertTrue($book->pages()->withTrashed()->count() === $pageCount);
+ $this->assertTrue($book->chapters()->withTrashed()->count() === $chapterCount);
+ $this->assertTrue($book->deletions()->count() === 1);
+
+ $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+ $redirectReq->assertNotificationContains('Book Successfully Deleted');
+ }
+}
\ No newline at end of file
--- /dev/null
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Chapter;
+use Tests\TestCase;
+
+class ChapterTest extends TestCase
+{
+ public function test_chapter_delete()
+ {
+ $chapter = Chapter::query()->whereHas('pages')->first();
+ $this->assertNull($chapter->deleted_at);
+ $pageCount = $chapter->pages()->count();
+
+ $deleteViewReq = $this->asEditor()->get($chapter->getUrl('/delete'));
+ $deleteViewReq->assertSeeText('Are you sure you want to delete this chapter?');
+
+ $deleteReq = $this->delete($chapter->getUrl());
+ $deleteReq->assertRedirect($chapter->getParent()->getUrl());
+ $this->assertActivityExists('chapter_delete', $chapter);
+
+ $chapter->refresh();
+ $this->assertNotNull($chapter->deleted_at);
+
+ $this->assertTrue($chapter->pages()->count() === 0);
+ $this->assertTrue($chapter->pages()->withTrashed()->count() === $pageCount);
+ $this->assertTrue($chapter->deletions()->count() === 1);
+
+ $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+ $redirectReq->assertNotificationContains('Chapter Successfully Deleted');
+ }
+}
\ No newline at end of file
<?php namespace Tests\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use Tests\BrowserKitTest;
class CommentSettingTest extends BrowserKitTest
<?php namespace Tests\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Actions\Comment;
use Tests\TestCase;
<?php namespace Tests\Entity;
use BookStack\Actions\Tag;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use Tests\TestCase;
class EntitySearchTest extends TestCase
<?php namespace Tests\Entity;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Repos\PageRepo;
use Carbon\Carbon;
-use Illuminate\Support\Facades\DB;
use Tests\BrowserKitTest;
class EntityTest extends BrowserKitTest
// Test Creation
$book = $this->bookCreation();
$chapter = $this->chapterCreation($book);
- $page = $this->pageCreation($chapter);
+ $this->pageCreation($chapter);
// Test Updating
- $book = $this->bookUpdate($book);
-
- // Test Deletion
- $this->bookDelete($book);
- }
-
- public function bookDelete(Book $book)
- {
- $this->asAdmin()
- ->visit($book->getUrl())
- // Check link works correctly
- ->click('Delete')
- ->seePageIs($book->getUrl() . '/delete')
- // Ensure the book name is show to user
- ->see($book->name)
- ->press('Confirm')
- ->seePageIs('/books')
- ->notSeeInDatabase('books', ['id' => $book->id]);
+ $this->bookUpdate($book);
}
public function bookUpdate(Book $book)
->seePageIs($chapter->getUrl());
}
- public function test_page_delete_removes_entity_from_its_activity()
- {
- $page = Page::query()->first();
-
- $this->asEditor()->put($page->getUrl(), [
- 'name' => 'My updated page',
- 'html' => '<p>updated content</p>',
- ]);
- $page->refresh();
-
- $this->seeInDatabase('activities', [
- 'entity_id' => $page->id,
- 'entity_type' => $page->getMorphClass(),
- ]);
-
- $resp = $this->delete($page->getUrl());
- $resp->assertResponseStatus(302);
-
- $this->dontSeeInDatabase('activities', [
- 'entity_id' => $page->id,
- 'entity_type' => $page->getMorphClass(),
- ]);
-
- $this->seeInDatabase('activities', [
- 'extra' => 'My updated page',
- 'entity_id' => 0,
- 'entity_type' => '',
- ]);
- }
-
}
<?php namespace Tests\Entity;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
$resp->assertSee('<img src="data:image/svg+xml;base64');
}
+ public function test_page_image_containment_works_on_multiple_images_within_a_single_line()
+ {
+ $page = Page::first();
+ Storage::disk('local')->makeDirectory('uploads/images/gallery');
+ Storage::disk('local')->put('uploads/images/gallery/svg_test.svg', '<svg></svg>');
+ Storage::disk('local')->put('uploads/images/gallery/svg_test2.svg', '<svg></svg>');
+ $page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg" class="a"><img src="http://localhost/uploads/images/gallery/svg_test2.svg" class="b">';
+ $page->save();
+
+ $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+ Storage::disk('local')->delete('uploads/images/gallery/svg_test.svg');
+ Storage::disk('local')->delete('uploads/images/gallery/svg_test2.svg');
+
+ $resp->assertDontSee('http://localhost/uploads/images/gallery/svg_test');
+ }
+
public function test_page_export_contained_html_image_fetches_only_run_when_url_points_to_image_upload_folder()
{
$page = Page::first();
$page->html = '<img src="http://localhost/uploads/images/gallery/svg_test.svg"/>'
- ."\n".'<img src="http://localhost/uploads/svg_test.svg"/>'
- ."\n".'<img src="/uploads/svg_test.svg"/>';
+ .'<img src="http://localhost/uploads/svg_test.svg"/>'
+ .'<img src="/uploads/svg_test.svg"/>';
$storageDisk = Storage::disk('local');
$storageDisk->makeDirectory('uploads/images/gallery');
$storageDisk->put('uploads/images/gallery/svg_test.svg', '<svg>good</svg>');
$resp->assertSee('src="/uploads/svg_test.svg"');
}
-}
\ No newline at end of file
+}
public function setUp(): void
{
parent::setUp();
- $this->page = \BookStack\Entities\Page::first();
+ $this->page = \BookStack\Entities\Models\Page::first();
}
protected function setMarkdownEditor()
<?php namespace Tests\Entity;
-use BookStack\Entities\Managers\PageContent;
-use BookStack\Entities\Page;
+use BookStack\Entities\Tools\PageContent;
+use BookStack\Entities\Models\Page;
use Tests\TestCase;
class PageContentTest extends TestCase
<?php namespace Tests\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use Tests\BrowserKitTest;
public function setUp(): void
{
parent::setUp();
- $this->page = \BookStack\Entities\Page::first();
+ $this->page = \BookStack\Entities\Models\Page::first();
$this->pageRepo = app(PageRepo::class);
}
public function test_alert_message_shows_if_someone_else_editing()
{
- $nonEditedPage = \BookStack\Entities\Page::take(10)->get()->last();
+ $nonEditedPage = \BookStack\Entities\Models\Page::take(10)->get()->last();
$addedContent = '<p>test message content</p>';
$this->asAdmin()->visit($this->page->getUrl('/edit'))
->dontSeeInField('html', $addedContent);
public function test_draft_pages_show_on_homepage()
{
- $book = \BookStack\Entities\Book::first();
+ $book = \BookStack\Entities\Models\Book::first();
$this->asAdmin()->visit('/')
->dontSeeInElement('#recent-drafts', 'New Page')
->visit($book->getUrl() . '/create-page')
public function test_draft_pages_not_visible_by_others()
{
- $book = \BookStack\Entities\Book::first();
+ $book = \BookStack\Entities\Models\Book::first();
$chapter = $book->chapters->first();
$newUser = $this->getEditor();
<?php namespace Tests\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use Tests\TestCase;
<?php namespace Tests\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use Tests\TestCase;
class PageTemplateTest extends TestCase
--- /dev/null
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class PageTest extends TestCase
+{
+ public function test_page_delete()
+ {
+ $page = Page::query()->first();
+ $this->assertNull($page->deleted_at);
+
+ $deleteViewReq = $this->asEditor()->get($page->getUrl('/delete'));
+ $deleteViewReq->assertSeeText('Are you sure you want to delete this page?');
+
+ $deleteReq = $this->delete($page->getUrl());
+ $deleteReq->assertRedirect($page->getParent()->getUrl());
+ $this->assertActivityExists('page_delete', $page);
+
+ $page->refresh();
+ $this->assertNotNull($page->deleted_at);
+ $this->assertTrue($page->deletions()->count() === 1);
+
+ $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location'));
+ $redirectReq->assertNotificationContains('Page Successfully Deleted');
+ }
+}
\ No newline at end of file
<?php namespace Tests\Entity;
-use BookStack\Entities\SearchOptions;
+use BookStack\Entities\Tools\SearchOptions;
use Tests\TestCase;
class SearchOptionsTest extends TestCase
<?php namespace Tests\Entity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use Tests\TestCase;
$movePageResp = $this->actingAs($this->getEditor())->put($page->getUrl('/move'), [
'entity_selection' => 'book:' . $newBook->id
]);
- $page = Page::find($page->id);
+ $page->refresh();
$movePageResp->assertRedirect($page->getUrl());
$this->assertTrue($page->book->id == $newBook->id, 'Page parent is now the new book');
<?php namespace Tests\Entity;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
use BookStack\Actions\Tag;
-use BookStack\Entities\Entity;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
use BookStack\Auth\Permissions\PermissionService;
use Tests\BrowserKitTest;
<?php namespace Tests;
-use BookStack\Entities\Book;
+use BookStack\Entities\Models\Book;
use Illuminate\Support\Facades\Log;
class ErrorTest extends TestCase
<?php namespace Tests;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
class HomepageTest extends TestCase
{
<?php namespace Tests\Permissions;
-use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
use BookStack\Auth\User;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use Tests\BrowserKitTest;
class RestrictionsTest extends BrowserKitTest
public function test_bookshelf_update_restriction()
{
- $shelf = BookShelf::first();
+ $shelf = Bookshelf::first();
$this->actingAs($this->user)
->visit($shelf->getUrl('/edit'))
<?php namespace Tests\Permissions;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Page;
+use BookStack\Actions\Comment;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
use BookStack\Auth\Role;
+use BookStack\Uploads\Image;
use Laravel\BrowserKitTesting\HttpException;
use Tests\BrowserKitTest;
public function test_cannot_delete_admin_role()
{
- $adminRole = \BookStack\Auth\Role::getRole('admin');
+ $adminRole = Role::getRole('admin');
$deletePageUrl = '/settings/roles/delete/' . $adminRole->id;
$this->asAdmin()->visit($deletePageUrl)
->press('Confirm')
public function test_restrictions_manage_all_permission()
{
- $page = \BookStack\Entities\Page::take(1)->get()->first();
+ $page = Page::take(1)->get()->first();
$this->actingAs($this->user)->visit($page->getUrl())
->dontSee('Permissions')
->visit($page->getUrl() . '/permissions')
public function test_restrictions_manage_own_permission()
{
- $otherUsersPage = \BookStack\Entities\Page::first();
+ $otherUsersPage = Page::first();
$content = $this->createEntityChainBelongingToUser($this->user);
// Check can't restrict other's content
$this->actingAs($this->user)->visit($otherUsersPage->getUrl())
public function test_bookshelves_edit_all_permission()
{
- $otherShelf = \BookStack\Entities\Bookshelf::first();
+ $otherShelf = Bookshelf::first();
$this->checkAccessPermission('bookshelf-update-all', [
$otherShelf->getUrl('/edit')
], [
public function test_bookshelves_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['bookshelf-update-all']);
- $otherShelf = \BookStack\Entities\Bookshelf::first();
+ $otherShelf = Bookshelf::first();
$ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
$ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
$this->regenEntityPermissions($ownShelf);
public function test_bookshelves_delete_all_permission()
{
$this->giveUserPermissions($this->user, ['bookshelf-update-all']);
- $otherShelf = \BookStack\Entities\Bookshelf::first();
+ $otherShelf = Bookshelf::first();
$this->checkAccessPermission('bookshelf-delete-all', [
$otherShelf->getUrl('/delete')
], [
public function test_books_edit_own_permission()
{
- $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+ $otherBook = Book::take(1)->get()->first();
$ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
$this->checkAccessPermission('book-update-own', [
$ownBook->getUrl() . '/edit'
public function test_books_edit_all_permission()
{
- $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+ $otherBook = Book::take(1)->get()->first();
$this->checkAccessPermission('book-update-all', [
$otherBook->getUrl() . '/edit'
], [
public function test_books_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['book-update-all']);
- $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+ $otherBook = Book::take(1)->get()->first();
$ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
$this->checkAccessPermission('book-delete-own', [
$ownBook->getUrl() . '/delete'
public function test_books_delete_all_permission()
{
$this->giveUserPermissions($this->user, ['book-update-all']);
- $otherBook = \BookStack\Entities\Book::take(1)->get()->first();
+ $otherBook = Book::take(1)->get()->first();
$this->checkAccessPermission('book-delete-all', [
$otherBook->getUrl() . '/delete'
], [
public function test_chapter_create_own_permissions()
{
- $book = \BookStack\Entities\Book::take(1)->get()->first();
+ $book = Book::take(1)->get()->first();
$ownBook = $this->createEntityChainBelongingToUser($this->user)['book'];
$this->checkAccessPermission('chapter-create-own', [
$ownBook->getUrl('/create-chapter')
public function test_chapter_create_all_permissions()
{
- $book = \BookStack\Entities\Book::take(1)->get()->first();
+ $book = Book::take(1)->get()->first();
$this->checkAccessPermission('chapter-create-all', [
$book->getUrl('/create-chapter')
], [
public function test_chapter_edit_own_permission()
{
- $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+ $otherChapter = Chapter::take(1)->get()->first();
$ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
$this->checkAccessPermission('chapter-update-own', [
$ownChapter->getUrl() . '/edit'
public function test_chapter_edit_all_permission()
{
- $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+ $otherChapter = Chapter::take(1)->get()->first();
$this->checkAccessPermission('chapter-update-all', [
$otherChapter->getUrl() . '/edit'
], [
public function test_chapter_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['chapter-update-all']);
- $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+ $otherChapter = Chapter::take(1)->get()->first();
$ownChapter = $this->createEntityChainBelongingToUser($this->user)['chapter'];
$this->checkAccessPermission('chapter-delete-own', [
$ownChapter->getUrl() . '/delete'
public function test_chapter_delete_all_permission()
{
$this->giveUserPermissions($this->user, ['chapter-update-all']);
- $otherChapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+ $otherChapter = Chapter::take(1)->get()->first();
$this->checkAccessPermission('chapter-delete-all', [
$otherChapter->getUrl() . '/delete'
], [
public function test_page_create_own_permissions()
{
- $book = \BookStack\Entities\Book::first();
- $chapter = \BookStack\Entities\Chapter::first();
+ $book = Book::first();
+ $chapter = Chapter::first();
$entities = $this->createEntityChainBelongingToUser($this->user);
$ownBook = $entities['book'];
foreach ($accessUrls as $index => $url) {
$this->actingAs($this->user)->visit($url);
- $expectedUrl = \BookStack\Entities\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+ $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
$this->seePageIs($expectedUrl);
}
public function test_page_create_all_permissions()
{
- $book = \BookStack\Entities\Book::take(1)->get()->first();
- $chapter = \BookStack\Entities\Chapter::take(1)->get()->first();
+ $book = Book::take(1)->get()->first();
+ $chapter = Chapter::take(1)->get()->first();
$baseUrl = $book->getUrl() . '/page';
$createUrl = $book->getUrl('/create-page');
foreach ($accessUrls as $index => $url) {
$this->actingAs($this->user)->visit($url);
- $expectedUrl = \BookStack\Entities\Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
+ $expectedUrl = Page::where('draft', '=', true)->orderBy('id', 'desc')->first()->getUrl();
$this->seePageIs($expectedUrl);
}
public function test_page_edit_own_permission()
{
- $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+ $otherPage = Page::take(1)->get()->first();
$ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
$this->checkAccessPermission('page-update-own', [
$ownPage->getUrl() . '/edit'
public function test_page_edit_all_permission()
{
- $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+ $otherPage = Page::take(1)->get()->first();
$this->checkAccessPermission('page-update-all', [
$otherPage->getUrl() . '/edit'
], [
public function test_page_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['page-update-all']);
- $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+ $otherPage = Page::take(1)->get()->first();
$ownPage = $this->createEntityChainBelongingToUser($this->user)['page'];
$this->checkAccessPermission('page-delete-own', [
$ownPage->getUrl() . '/delete'
public function test_page_delete_all_permission()
{
$this->giveUserPermissions($this->user, ['page-update-all']);
- $otherPage = \BookStack\Entities\Page::take(1)->get()->first();
+ $otherPage = Page::take(1)->get()->first();
$this->checkAccessPermission('page-delete-all', [
$otherPage->getUrl() . '/delete'
], [
public function test_public_role_visible_in_user_edit_screen()
{
- $user = \BookStack\Auth\User::first();
+ $user = User::first();
$adminRole = Role::getSystemRole('admin');
$publicRole = Role::getSystemRole('public');
$this->asAdmin()->visit('/settings/users/' . $user->id)
public function test_image_delete_own_permission()
{
$this->giveUserPermissions($this->user, ['image-update-all']);
- $page = \BookStack\Entities\Page::first();
- $image = factory(\BookStack\Uploads\Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
+ $page = Page::first();
+ $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $this->user->id, 'updated_by' => $this->user->id]);
$this->actingAs($this->user)->json('delete', '/images/' . $image->id)
->seeStatusCode(403);
{
$this->giveUserPermissions($this->user, ['image-update-all']);
$admin = $this->getAdmin();
- $page = \BookStack\Entities\Page::first();
- $image = factory(\BookStack\Uploads\Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
+ $page = Page::first();
+ $image = factory(Image::class)->create(['uploaded_to' => $page->id, 'created_by' => $admin->id, 'updated_by' => $admin->id]);
$this->actingAs($this->user)->json('delete', '/images/' . $image->id)
->seeStatusCode(403);
{
// To cover issue fixed in f99c8ff99aee9beb8c692f36d4b84dc6e651e50a.
$page = Page::first();
- $viewerRole = \BookStack\Auth\Role::getRole('viewer');
+ $viewerRole = Role::getRole('viewer');
$viewer = $this->getViewer();
$this->actingAs($viewer)->visit($page->getUrl())->assertResponseStatus(200);
{
$admin = $this->getAdmin();
// Book links
- $book = factory(\BookStack\Entities\Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
+ $book = factory(Book::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id]);
$this->updateEntityPermissions($book);
$this->actingAs($this->getViewer())->visit($book->getUrl())
->dontSee('Create a new page')
->dontSee('Add a chapter');
// Chapter links
- $chapter = factory(\BookStack\Entities\Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
+ $chapter = factory(Chapter::class)->create(['created_by' => $admin->id, 'updated_by' => $admin->id, 'book_id' => $book->id]);
$this->updateEntityPermissions($chapter);
$this->actingAs($this->getViewer())->visit($chapter->getUrl())
->dontSee('Create a new page')
}
private function addComment($page) {
- $comment = factory(\BookStack\Actions\Comment::class)->make();
+ $comment = factory(Comment::class)->make();
$url = "/comment/$page->id";
$request = [
'text' => $comment->text,
}
private function updateComment($commentId) {
- $comment = factory(\BookStack\Actions\Comment::class)->make();
+ $comment = factory(Comment::class)->make();
$url = "/comment/$commentId";
$request = [
'text' => $comment->text,
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Auth\Role;
use BookStack\Auth\User;
-use BookStack\Entities\Book;
-use BookStack\Entities\Chapter;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
class PublicActionTest extends BrowserKitTest
{
--- /dev/null
+<?php namespace Tests;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Page;
+use DB;
+use Illuminate\Support\Carbon;
+
+class RecycleBinTest extends TestCase
+{
+ public function test_recycle_bin_routes_permissions()
+ {
+ $page = Page::query()->first();
+ $editor = $this->getEditor();
+ $this->actingAs($editor)->delete($page->getUrl());
+ $deletion = Deletion::query()->firstOrFail();
+
+ $routes = [
+ 'GET:/settings/recycle-bin',
+ 'POST:/settings/recycle-bin/empty',
+ "GET:/settings/recycle-bin/{$deletion->id}/destroy",
+ "GET:/settings/recycle-bin/{$deletion->id}/restore",
+ "POST:/settings/recycle-bin/{$deletion->id}/restore",
+ "DELETE:/settings/recycle-bin/{$deletion->id}",
+ ];
+
+ foreach($routes as $route) {
+ [$method, $url] = explode(':', $route);
+ $resp = $this->call($method, $url);
+ $this->assertPermissionError($resp);
+ }
+
+ $this->giveUserPermissions($editor, ['restrictions-manage-all']);
+
+ foreach($routes as $route) {
+ [$method, $url] = explode(':', $route);
+ $resp = $this->call($method, $url);
+ $this->assertPermissionError($resp);
+ }
+
+ $this->giveUserPermissions($editor, ['settings-manage']);
+
+ foreach($routes as $route) {
+ DB::beginTransaction();
+ [$method, $url] = explode(':', $route);
+ $resp = $this->call($method, $url);
+ $this->assertNotPermissionError($resp);
+ DB::rollBack();
+ }
+
+ }
+
+ public function test_recycle_bin_view()
+ {
+ $page = Page::query()->first();
+ $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
+ $editor = $this->getEditor();
+ $this->actingAs($editor)->delete($page->getUrl());
+ $this->actingAs($editor)->delete($book->getUrl());
+
+ $viewReq = $this->asAdmin()->get('/settings/recycle-bin');
+ $viewReq->assertElementContains('table.table', $page->name);
+ $viewReq->assertElementContains('table.table', $editor->name);
+ $viewReq->assertElementContains('table.table', $book->name);
+ $viewReq->assertElementContains('table.table', $book->pages_count . ' Pages');
+ $viewReq->assertElementContains('table.table', $book->chapters_count . ' Chapters');
+ }
+
+ public function test_recycle_bin_empty()
+ {
+ $page = Page::query()->first();
+ $book = Book::query()->where('id' , '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+ $editor = $this->getEditor();
+ $this->actingAs($editor)->delete($page->getUrl());
+ $this->actingAs($editor)->delete($book->getUrl());
+
+ $this->assertTrue(Deletion::query()->count() === 2);
+ $emptyReq = $this->asAdmin()->post('/settings/recycle-bin/empty');
+ $emptyReq->assertRedirect('/settings/recycle-bin');
+
+ $this->assertTrue(Deletion::query()->count() === 0);
+ $this->assertDatabaseMissing('books', ['id' => $book->id]);
+ $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+ $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+ $itemCount = 2 + $book->pages->count() + $book->chapters->count();
+ $redirectReq = $this->get('/settings/recycle-bin');
+ $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+ }
+
+ public function test_entity_restore()
+ {
+ $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+ $this->asEditor()->delete($book->getUrl());
+ $deletion = Deletion::query()->firstOrFail();
+
+ $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+ $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count());
+
+ $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore");
+ $restoreReq->assertRedirect('/settings/recycle-bin');
+ $this->assertTrue(Deletion::query()->count() === 0);
+
+ $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+ $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count());
+
+ $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+ $redirectReq = $this->get('/settings/recycle-bin');
+ $redirectReq->assertNotificationContains('Restored '.$itemCount.' total items from the recycle bin');
+ }
+
+ public function test_permanent_delete()
+ {
+ $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+ $this->asEditor()->delete($book->getUrl());
+ $deletion = Deletion::query()->firstOrFail();
+
+ $deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+ $deleteReq->assertRedirect('/settings/recycle-bin');
+ $this->assertTrue(Deletion::query()->count() === 0);
+
+ $this->assertDatabaseMissing('books', ['id' => $book->id]);
+ $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]);
+ $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]);
+
+ $itemCount = 1 + $book->pages->count() + $book->chapters->count();
+ $redirectReq = $this->get('/settings/recycle-bin');
+ $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin');
+ }
+
+ public function test_permanent_entity_delete_updates_existing_activity_with_entity_name()
+ {
+ $page = Page::query()->firstOrFail();
+ $this->asEditor()->delete($page->getUrl());
+ $deletion = $page->deletions()->firstOrFail();
+
+ $this->assertDatabaseHas('activities', [
+ 'type' => 'page_delete',
+ 'entity_id' => $page->id,
+ 'entity_type' => $page->getMorphClass(),
+ ]);
+
+ $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}");
+
+ $this->assertDatabaseMissing('activities', [
+ 'type' => 'page_delete',
+ 'entity_id' => $page->id,
+ 'entity_type' => $page->getMorphClass(),
+ ]);
+
+ $this->assertDatabaseHas('activities', [
+ 'type' => 'page_delete',
+ 'entity_id' => null,
+ 'entity_type' => null,
+ 'detail' => $page->name,
+ ]);
+ }
+
+ public function test_auto_clear_functionality_works()
+ {
+ config()->set('app.recycle_bin_lifetime', 5);
+ $page = Page::query()->firstOrFail();
+ $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+ $this->asEditor()->delete($page->getUrl());
+ $this->assertDatabaseHas('pages', ['id' => $page->id]);
+ $this->assertEquals(1, Deletion::query()->count());
+
+ Carbon::setTestNow(Carbon::now()->addDays(6));
+ $this->asEditor()->delete($otherPage->getUrl());
+ $this->assertEquals(1, Deletion::query()->count());
+
+ $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ }
+
+ public function test_auto_clear_functionality_with_negative_time_keeps_forever()
+ {
+ config()->set('app.recycle_bin_lifetime', -1);
+ $page = Page::query()->firstOrFail();
+ $otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
+
+ $this->asEditor()->delete($page->getUrl());
+ $this->assertEquals(1, Deletion::query()->count());
+
+ Carbon::setTestNow(Carbon::now()->addDays(6000));
+ $this->asEditor()->delete($otherPage->getUrl());
+ $this->assertEquals(2, Deletion::query()->count());
+
+ $this->assertDatabaseHas('pages', ['id' => $page->id]);
+ }
+
+ public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
+ {
+ config()->set('app.recycle_bin_lifetime', 0);
+ $page = Page::query()->firstOrFail();
+
+ $this->asEditor()->delete($page->getUrl());
+ $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+ $this->assertEquals(0, Deletion::query()->count());
+ }
+
+ public function test_restore_flow_when_restoring_nested_delete_first()
+ {
+ $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
+ $chapter = $book->chapters->first();
+ $this->asEditor()->delete($chapter->getUrl());
+ $this->asEditor()->delete($book->getUrl());
+
+ $bookDeletion = $book->deletions()->first();
+ $chapterDeletion = $chapter->deletions()->first();
+
+ $chapterRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+ $chapterRestoreView->assertStatus(200);
+ $chapterRestoreView->assertSeeText($chapter->name);
+
+ $chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
+ $chapterRestore->assertRedirect("/settings/recycle-bin");
+ $this->assertDatabaseMissing("deletions", ["id" => $chapterDeletion->id]);
+
+ $chapter->refresh();
+ $this->assertNotNull($chapter->deleted_at);
+
+ $bookRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$bookDeletion->id}/restore");
+ $bookRestoreView->assertStatus(200);
+ $bookRestoreView->assertSeeText($chapter->name);
+
+ $this->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
+ $chapter->refresh();
+ $this->assertNull($chapter->deleted_at);
+ }
+}
\ No newline at end of file
<?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\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Settings\SettingService;
use BookStack\Uploads\HttpFetcher;
+use Illuminate\Http\Response;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Log;
use Mockery;
use Monolog\Handler\TestHandler;
use Monolog\Logger;
use Throwable;
+use Illuminate\Foundation\Testing\Assert as PHPUnit;
trait SharedTestHelpers
{
*/
protected function assertPermissionError($response)
{
- if ($response instanceof BrowserKitTest) {
- $response = \Illuminate\Foundation\Testing\TestResponse::fromBaseResponse($response->response);
- }
+ PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response contains a permission error.");
+ }
- $response->assertRedirect('/');
- $this->assertSessionHas('error');
- $error = session()->pull('error');
- $this->assertStringStartsWith('You do not have permission to access', $error);
+ /**
+ * Assert a permission error has occurred.
+ */
+ protected function assertNotPermissionError($response)
+ {
+ PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response does not contain a permission error.");
+ }
+
+ /**
+ * Check if the given response is a permission error.
+ */
+ private function isPermissionError($response): bool
+ {
+ return $response->status() === 302
+ && $response->headers->get('Location') === url('/')
+ && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0;
}
/**
<?php namespace Tests;
-use BookStack\Entities\Entity;
+use BookStack\Entities\Models\Entity;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
* Assert that an activity entry exists of the given key.
* Checks the activity belongs to the given entity if provided.
*/
- protected function assertActivityExists(string $key, Entity $entity = null)
+ protected function assertActivityExists(string $type, Entity $entity = null)
{
- $detailsToCheck = ['key' => $key];
+ $detailsToCheck = ['type' => $type];
if ($entity) {
$detailsToCheck['entity_type'] = $entity->getMorphClass();
/**
* Get the DOM Crawler for the response content.
- * @return Crawler
*/
- protected function crawler()
+ protected function crawler(): Crawler
{
if (!is_object($this->crawlerInstance)) {
$this->crawlerInstance = new Crawler($this->getContent());
/**
* Assert the response contains the specified element.
- * @param string $selector
* @return $this
*/
public function assertElementExists(string $selector)
/**
* Assert the response does not contain the specified element.
- * @param string $selector
* @return $this
*/
public function assertElementNotExists(string $selector)
/**
* Assert the response includes a specific element containing the given text.
- * @param string $selector
- * @param string $text
* @return $this
*/
public function assertElementContains(string $selector, string $text)
/**
* Assert the response does not include a specific element containing the given text.
- * @param string $selector
- * @param string $text
* @return $this
*/
public function assertElementNotContains(string $selector, string $text)
return $this;
}
+ /**
+ * Assert there's a notification within the view containing the given text.
+ * @return $this
+ */
+ public function assertNotificationContains(string $text)
+ {
+ return $this->assertElementContains('[notification]', $text);
+ }
+
/**
* Get the escaped text pattern for the constraint.
- * @param string $text
* @return string
*/
- protected function getEscapedPattern($text)
+ protected function getEscapedPattern(string $text)
{
$rawPattern = preg_quote($text, '/');
$escapedPattern = preg_quote(e($text), '/');
<?php namespace Tests\Uploads;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Attachment;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Uploads\AttachmentService;
use Illuminate\Http\UploadedFile;
'name' => $fileName
]);
- $this->call('DELETE', $page->getUrl());
+ app(PageRepo::class)->destroy($page);
+ app(TrashCan::class)->empty();
$this->assertDatabaseMissing('attachments', [
'name' => $fileName
<?php namespace Tests\Uploads;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Uploads\Image;
use Tests\TestCase;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Image;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use BookStack\Uploads\ImageService;
use Illuminate\Support\Str;
use Tests\TestCase;
<?php namespace Tests\Uploads;
-use BookStack\Entities\Page;
+use BookStack\Entities\Models\Page;
use Illuminate\Http\UploadedFile;
trait UsesImages
-<?php namespace Test\User;
+<?php namespace Tests\User;
+use BookStack\Actions\ActivityType;
use BookStack\Api\ApiToken;
use Carbon\Carbon;
use Tests\TestCase;
$this->assertTrue(strlen($secret) === 32);
$this->assertSessionHas('success');
+ $this->assertActivityExists(ActivityType::API_TOKEN_CREATE);
}
public function test_create_with_no_expiry_sets_expiry_hundred_years_away()
$this->assertDatabaseHas('api_tokens', array_merge($updateData, ['id' => $token->id]));
$this->assertSessionHas('success');
+ $this->assertActivityExists(ActivityType::API_TOKEN_UPDATE);
}
public function test_token_update_with_blank_expiry_sets_to_hundred_years_away()
$resp = $this->delete($tokenUrl);
$resp->assertRedirect($editor->getEditUrl('#api_tokens'));
$this->assertDatabaseMissing('api_tokens', ['id' => $token->id]);
+ $this->assertActivityExists(ActivityType::API_TOKEN_DELETE);
}
public function test_user_manage_can_delete_token_without_api_permission_themselves()
-<?php namespace Test\User;
+<?php namespace Tests\User;
use Tests\TestCase;
-<?php namespace Test\User;
+<?php namespace Tests\User;
use Activity;
+use BookStack\Actions\ActivityType;
use BookStack\Auth\User;
-use BookStack\Entities\Bookshelf;
+use BookStack\Entities\Models\Bookshelf;
use Tests\BrowserKitTest;
class UserProfileTest extends BrowserKitTest
$newUser = $this->getNewBlankUser();
$this->actingAs($newUser);
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
- Activity::add($entities['book'], 'book_update', $entities['book']->id);
- Activity::add($entities['page'], 'page_create', $entities['book']->id);
+ Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+ Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
$this->asAdmin()->visit('/user/' . $newUser->id)
->seeInElement('#recent-user-activity', 'updated book')
$newUser = $this->getNewBlankUser();
$this->actingAs($newUser);
$entities = $this->createEntityChainBelongingToUser($newUser, $newUser);
- Activity::add($entities['book'], 'book_update', $entities['book']->id);
- Activity::add($entities['page'], 'page_create', $entities['book']->id);
+ Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE);
+ Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE);
$this->asAdmin()->visit('/')->clickInElement('#recent-activity', $newUser->name)
->seePageIs('/user/' . $newUser->id)