Merge branch 'master' into docker-tests

This commit is contained in:
Dan Brown
2021-03-21 16:49:22 +00:00
313 changed files with 8876 additions and 2872 deletions

View File

@ -6,6 +6,7 @@ use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Str;
/**
@ -23,7 +24,7 @@ class Activity extends Model
/**
* Get the entity for this activity.
*/
public function entity()
public function entity(): MorphTo
{
if ($this->entity_type === '') {
$this->entity_type = null;

View File

@ -78,7 +78,7 @@ class ActivityService
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
@ -131,7 +131,7 @@ class ActivityService
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

@ -48,4 +48,4 @@ class ActivityType
const AUTH_PASSWORD_RESET_UPDATE = 'auth_password_reset_update';
const AUTH_LOGIN = 'auth_login';
const AUTH_REGISTER = 'auth_register';
}
}

View File

@ -26,7 +26,9 @@ class TagRepo
*/
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
$query = $this->tag->newQuery()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@ -45,7 +47,9 @@ class TagRepo
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
$query = $this->tag->newQuery()
->select('*', DB::raw('count(*) as count'))
->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');

View File

@ -65,7 +65,7 @@ class ViewService
{
$skipCount = $count * $page;
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
->filterRestrictedEntityRelations($this->view->newQuery(), 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');

View File

@ -142,5 +142,4 @@ class ApiDocsGenerator
];
});
}
}
}

View File

@ -163,4 +163,4 @@ class ApiTokenGuard implements Guard
{
$this->user = null;
}
}
}

View File

@ -299,5 +299,4 @@ class ExternalBaseSessionGuard implements StatefulGuard
return $this;
}
}

View File

@ -5,14 +5,12 @@ namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
@ -23,13 +21,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/**
* LdapSessionGuard constructor.
*/
public function __construct($name,
public function __construct(
$name,
UserProvider $provider,
Session $session,
LdapService $ldapService,
RegistrationService $registrationService
)
{
) {
$this->ldapService = $ldapService;
parent::__construct($name, $provider, $session, $registrationService);
}
@ -119,5 +117,4 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
return $this->registrationService->registerUser($details, null, false);
}
}

View File

@ -34,5 +34,4 @@ class Saml2SessionGuard extends ExternalBaseSessionGuard
{
return false;
}
}

View File

@ -31,6 +31,14 @@ class Ldap
return ldap_set_option($ldapConnection, $option, $value);
}
/**
* Start TLS on the given LDAP connection.
*/
public function startTls($ldapConnection): bool
{
return ldap_start_tls($ldapConnection);
}
/**
* Set the version number for the given ldap connection.
* @param $ldapConnection

View File

@ -85,9 +85,9 @@ class LdapService extends ExternalAuthService
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
];
@ -187,8 +187,8 @@ class LdapService extends ExternalAuthService
throw new LdapException(trans('errors.ldap_extension_not_installed'));
}
// Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
// the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
// Disable certificate verification.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_insecure']) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
@ -205,6 +205,14 @@ class LdapService extends ExternalAuthService
$this->ldap->setVersion($ldapConnection, $this->config['version']);
}
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
$started = $this->ldap->startTls($ldapConnection);
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
}
$this->ldapConnection = $ldapConnection;
return $this->ldapConnection;
}

View File

@ -6,6 +6,8 @@ use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;
class RegistrationService
@ -71,6 +73,7 @@ class RegistrationService
}
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
@ -83,7 +86,6 @@ class RegistrationService
$message = trans('auth.email_confirm_send_error');
throw new UserRegistrationException($message, '/register/confirm');
}
}
return $newUser;
@ -109,5 +111,4 @@ class RegistrationService
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
}
}
}
}

View File

@ -6,6 +6,8 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Support\Str;
use OneLogin\Saml2\Auth;
@ -375,6 +377,7 @@ class Saml2Service extends ExternalAuthService
auth()->login($user);
Activity::add(ActivityType::AUTH_LOGIN, "saml2; {$user->logDescriptor()}");
Theme::dispatch(ThemeEvents::AUTH_LOGIN, 'saml2', $user);
return $user;
}
}

View File

@ -2,21 +2,23 @@
use BookStack\Actions\ActivityType;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\UserRepo;
use BookStack\Auth\User;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService
{
protected $userRepo;
protected $socialite;
protected $socialAccount;
@ -25,14 +27,11 @@ class SocialAuthService
/**
* SocialAuthService constructor.
*/
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
public function __construct(Socialite $socialite)
{
$this->userRepo = $userRepo;
$this->socialite = $socialite;
$this->socialAccount = $socialAccount;
}
/**
* Start the social login path.
* @throws SocialDriverNotConfigured
@ -60,11 +59,11 @@ class SocialAuthService
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
{
// Check social account has not already been used
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver]), '/login');
if (SocialAccount::query()->where('driver_id', '=', $socialUser->getId())->exists()) {
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount' => $socialDriver]), '/login');
}
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
if (User::query()->where('email', '=', $socialUser->getEmail())->exists()) {
$email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
}
@ -91,7 +90,7 @@ class SocialAuthService
$socialId = $socialUser->getId();
// Get any attached social accounts or users
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$socialAccount = SocialAccount::query()->where('driver_id', '=', $socialId)->first();
$isLoggedIn = auth()->check();
$currentUser = user();
$titleCaseDriver = Str::title($socialDriver);
@ -101,14 +100,15 @@ class SocialAuthService
if (!$isLoggedIn && $socialAccount !== null) {
auth()->login($socialAccount->user);
Activity::add(ActivityType::AUTH_LOGIN, $socialAccount);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $socialAccount->user);
return redirect()->intended('/');
}
// When a user is logged in but the social account does not exist,
// Create the social account and attach it to the user & redirect to the profile page.
if ($isLoggedIn && $socialAccount === null) {
$this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount);
$account = $this->newSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
@ -130,7 +130,7 @@ class SocialAuthService
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
}
throw new SocialSignInAccountNotUsed($message, '/login');
}
@ -207,21 +207,19 @@ class SocialAuthService
/**
* Fill and return a SocialAccount from the given driver name and SocialUser.
*/
public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
public function newSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
{
$this->socialAccount->fill([
'driver' => $socialDriver,
return new SocialAccount([
'driver' => $socialDriver,
'driver_id' => $socialUser->getId(),
'avatar' => $socialUser->getAvatar()
'avatar' => $socialUser->getAvatar()
]);
return $this->socialAccount;
}
/**
* Detach a social account from a user.
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function detachSocialAccount(string $socialDriver)
public function detachSocialAccount(string $socialDriver): void
{
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
}
@ -242,4 +240,20 @@ class SocialAuthService
return $driver;
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler)
{
$this->validSocialDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
}
}

View File

@ -1,25 +1,33 @@
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Page;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Collection;
use Throwable;
class PermissionService
{
/**
* @var ?array
*/
protected $userRoles = null;
protected $currentAction;
protected $isAdminUser;
protected $userRoles = false;
protected $currentUserModel = false;
/**
* @var ?User
*/
protected $currentUserModel = null;
/**
* @var Connection
@ -27,47 +35,20 @@ class PermissionService
protected $db;
/**
* @var JointPermission
* @var array
*/
protected $jointPermission;
/**
* @var Role
*/
protected $role;
/**
* @var EntityPermission
*/
protected $entityPermission;
/**
* @var EntityProvider
*/
protected $entityProvider;
protected $entityCache;
/**
* PermissionService constructor.
*/
public function __construct(
JointPermission $jointPermission,
Permissions\EntityPermission $entityPermission,
Role $role,
Connection $db,
EntityProvider $entityProvider
) {
public function __construct(Connection $db)
{
$this->db = $db;
$this->jointPermission = $jointPermission;
$this->entityPermission = $entityPermission;
$this->role = $role;
$this->entityProvider = $entityProvider;
}
/**
* Set the database connection
* @param Connection $connection
*/
public function setConnection(Connection $connection)
{
@ -76,81 +57,63 @@ class PermissionService
/**
* Prepare the local entity cache and ensure it's empty
* @param \BookStack\Entities\Models\Entity[] $entities
* @param Entity[] $entities
*/
protected function readyEntityCache($entities = [])
protected function readyEntityCache(array $entities = [])
{
$this->entityCache = [];
foreach ($entities as $entity) {
$type = $entity->getType();
if (!isset($this->entityCache[$type])) {
$this->entityCache[$type] = collect();
$class = get_class($entity);
if (!isset($this->entityCache[$class])) {
$this->entityCache[$class] = collect();
}
$this->entityCache[$type]->put($entity->id, $entity);
$this->entityCache[$class]->put($entity->id, $entity);
}
}
/**
* Get a book via ID, Checks local cache
* @param $bookId
* @return Book
*/
protected function getBook($bookId)
protected function getBook(int $bookId): ?Book
{
if (isset($this->entityCache['book']) && $this->entityCache['book']->has($bookId)) {
return $this->entityCache['book']->get($bookId);
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
return $this->entityCache[Book::class]->get($bookId);
}
$book = $this->entityProvider->book->find($bookId);
if ($book === null) {
$book = false;
}
return $book;
return Book::query()->withTrashed()->find($bookId);
}
/**
* Get a chapter via ID, Checks local cache
* @param $chapterId
* @return \BookStack\Entities\Models\Book
*/
protected function getChapter($chapterId)
protected function getChapter(int $chapterId): ?Chapter
{
if (isset($this->entityCache['chapter']) && $this->entityCache['chapter']->has($chapterId)) {
return $this->entityCache['chapter']->get($chapterId);
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
return $this->entityCache[Chapter::class]->get($chapterId);
}
$chapter = $this->entityProvider->chapter->find($chapterId);
if ($chapter === null) {
$chapter = false;
}
return $chapter;
return Chapter::query()
->withTrashed()
->find($chapterId);
}
/**
* Get the roles for the current user;
* @return array|bool
* Get the roles for the current logged in user.
*/
protected function getRoles()
protected function getCurrentUserRoles(): array
{
if ($this->userRoles !== false) {
if (!is_null($this->userRoles)) {
return $this->userRoles;
}
$roles = [];
if (auth()->guest()) {
$roles[] = $this->role->getSystemRole('public')->id;
return $roles;
$this->userRoles = [Role::getSystemRole('public')->id];
} else {
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
}
foreach ($this->currentUser()->roles as $role) {
$roles[] = $role->id;
}
return $roles;
return $this->userRoles;
}
/**
@ -158,59 +121,57 @@ class PermissionService
*/
public function buildJointPermissions()
{
$this->jointPermission->truncate();
JointPermission::query()->truncate();
$this->readyEntityCache();
// Get all roles (Should be the most limited dimension)
$roles = $this->role->with('permissions')->get()->all();
$roles = Role::query()->with('permissions')->get()->all();
// Chunk through all books
$this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
$this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
}
/**
* Get a query for fetching a book with it's children.
* @return QueryBuilder
*/
protected function bookFetchQuery()
protected function bookFetchQuery(): Builder
{
return $this->entityProvider->book->withTrashed()->newQuery()
->select(['id', 'restricted', 'owned_by'])->with(['chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
}, 'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
}]);
return Book::query()->withTrashed()
->select(['id', 'restricted', 'owned_by'])->with([
'chapters' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
},
'pages' => function ($query) {
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
}
]);
}
/**
* @param Collection $shelves
* @param array $roles
* @param bool $deleteOld
* @throws \Throwable
* Build joint permissions for the given shelf and role combinations.
* @throws Throwable
*/
protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
{
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($shelves->all());
}
$this->createManyJointPermissions($shelves, $roles);
$this->createManyJointPermissions($shelves->all(), $roles);
}
/**
* Build joint permissions for an array of books
* @param Collection $books
* @param array $roles
* @param bool $deleteOld
* Build joint permissions for the given book and role combinations.
* @throws Throwable
*/
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
{
$entities = clone $books;
@ -227,55 +188,53 @@ class PermissionService
if ($deleteOld) {
$this->deleteManyJointPermissionsForEntities($entities->all());
}
$this->createManyJointPermissions($entities, $roles);
$this->createManyJointPermissions($entities->all(), $roles);
}
/**
* Rebuild the entity jointPermissions for a particular entity.
* @param \BookStack\Entities\Models\Entity $entity
* @throws \Throwable
* @throws Throwable
*/
public function buildJointPermissionsForEntity(Entity $entity)
{
$entities = [$entity];
if ($entity->isA('book')) {
if ($entity instanceof Book) {
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
$this->buildJointPermissionsForBooks($books, $this->role->newQuery()->get(), true);
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
return;
}
/** @var BookChild $entity */
if ($entity->book) {
$entities[] = $entity->book;
}
if ($entity->isA('page') && $entity->chapter_id) {
if ($entity instanceof Page && $entity->chapter_id) {
$entities[] = $entity->chapter;
}
if ($entity->isA('chapter')) {
if ($entity instanceof Chapter) {
foreach ($entity->pages as $page) {
$entities[] = $page;
}
}
$this->buildJointPermissionsForEntities(collect($entities));
$this->buildJointPermissionsForEntities($entities);
}
/**
* Rebuild the entity jointPermissions for a collection of entities.
* @param Collection $entities
* @throws \Throwable
* @throws Throwable
*/
public function buildJointPermissionsForEntities(Collection $entities)
public function buildJointPermissionsForEntities(array $entities)
{
$roles = $this->role->newQuery()->get();
$this->deleteManyJointPermissionsForEntities($entities->all());
$roles = Role::query()->get()->values()->all();
$this->deleteManyJointPermissionsForEntities($entities);
$this->createManyJointPermissions($entities, $roles);
}
/**
* Build the entity jointPermissions for a particular role.
* @param Role $role
*/
public function buildJointPermissionForRole(Role $role)
{
@ -288,7 +247,7 @@ class PermissionService
});
// Chunk through all bookshelves
$this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'owned_by'])
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
$this->buildJointPermissionsForShelves($shelves, $roles);
});
@ -296,7 +255,6 @@ class PermissionService
/**
* Delete the entity jointPermissions attached to a particular role.
* @param Role $role
*/
public function deleteJointPermissionsForRole(Role $role)
{
@ -312,13 +270,13 @@ class PermissionService
$roleIds = array_map(function ($role) {
return $role->id;
}, $roles);
$this->jointPermission->newQuery()->whereIn('role_id', $roleIds)->delete();
JointPermission::query()->whereIn('role_id', $roleIds)->delete();
}
/**
* Delete the entity jointPermissions for a particular entity.
* @param Entity $entity
* @throws \Throwable
* @throws Throwable
*/
public function deleteJointPermissionsForEntity(Entity $entity)
{
@ -327,10 +285,10 @@ class PermissionService
/**
* Delete all of the entity jointPermissions for a list of entities.
* @param \BookStack\Entities\Models\Entity[] $entities
* @throws \Throwable
* @param Entity[] $entities
* @throws Throwable
*/
protected function deleteManyJointPermissionsForEntities($entities)
protected function deleteManyJointPermissionsForEntities(array $entities)
{
if (count($entities) === 0) {
return;
@ -352,19 +310,19 @@ class PermissionService
}
/**
* Create & Save entity jointPermissions for many entities and jointPermissions.
* @param Collection $entities
* @param array $roles
* @throws \Throwable
* Create & Save entity jointPermissions for many entities and roles.
* @param Entity[] $entities
* @param Role[] $roles
* @throws Throwable
*/
protected function createManyJointPermissions($entities, $roles)
protected function createManyJointPermissions(array $entities, array $roles)
{
$this->readyEntityCache($entities);
$jointPermissions = [];
// Fetch Entity Permissions and create a mapping of entity restricted statuses
$entityRestrictedMap = [];
$permissionFetch = $this->entityPermission->newQuery();
$permissionFetch = EntityPermission::query();
foreach ($entities as $entity) {
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
$permissionFetch->orWhere(function ($query) use ($entity) {
@ -408,16 +366,14 @@ class PermissionService
/**
* Get the actions related to an entity.
* @param \BookStack\Entities\Models\Entity $entity
* @return array
*/
protected function getActions(Entity $entity)
protected function getActions(Entity $entity): array
{
$baseActions = ['view', 'update', 'delete'];
if ($entity->isA('chapter') || $entity->isA('book')) {
if ($entity instanceof Chapter || $entity instanceof Book) {
$baseActions[] = 'page-create';
}
if ($entity->isA('book')) {
if ($entity instanceof Book) {
$baseActions[] = 'chapter-create';
}
return $baseActions;
@ -426,14 +382,8 @@ class PermissionService
/**
* Create entity permission data for an entity and role
* for a particular action.
* @param Entity $entity
* @param Role $role
* @param string $action
* @param array $permissionMap
* @param array $rolePermissionMap
* @return array
*/
protected function createJointPermissionData(Entity $entity, Role $role, $action, $permissionMap, $rolePermissionMap)
protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
{
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
@ -450,7 +400,7 @@ class PermissionService
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
}
if ($entity->isA('book') || $entity->isA('bookshelf')) {
if ($entity instanceof Book || $entity instanceof Bookshelf) {
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
}
@ -460,7 +410,7 @@ class PermissionService
$hasPermissiveAccessToParents = !$book->restricted;
// For pages with a chapter, Check if explicit permissions are set on the Chapter
if ($entity->isA('page') && $entity->chapter_id !== 0 && $entity->chapter_id !== '0') {
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
$chapter = $this->getChapter($entity->chapter_id);
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
if ($chapter->restricted) {
@ -479,38 +429,27 @@ class PermissionService
/**
* Check for an active restriction in an entity map.
* @param $entityMap
* @param Entity $entity
* @param Role $role
* @param $action
* @return bool
*/
protected function mapHasActiveRestriction($entityMap, Entity $entity, Role $role, $action)
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
{
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
return isset($entityMap[$key]) ? $entityMap[$key] : false;
return $entityMap[$key] ?? false;
}
/**
* Create an array of data with the information of an entity jointPermissions.
* Used to build data for bulk insertion.
* @param \BookStack\Entities\Models\Entity $entity
* @param Role $role
* @param $action
* @param $permissionAll
* @param $permissionOwn
* @return array
*/
protected function createJointPermissionDataArray(Entity $entity, Role $role, $action, $permissionAll, $permissionOwn)
protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
{
return [
'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entity->getMorphClass(),
'action' => $action,
'has_permission' => $permissionAll,
'role_id' => $role->getRawAttribute('id'),
'entity_id' => $entity->getRawAttribute('id'),
'entity_type' => $entity->getMorphClass(),
'action' => $action,
'has_permission' => $permissionAll,
'has_permission_own' => $permissionOwn,
'owned_by' => $entity->getRawAttribute('owned_by')
'owned_by' => $entity->getRawAttribute('owned_by'),
];
}
@ -524,55 +463,47 @@ class PermissionService
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
$action = end($explodedPermission);
$this->currentAction = $action;
$user = $this->currentUser();
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
// Handle non entity specific jointPermissions
if (in_array($explodedPermission[0], $nonJointPermissions)) {
$allPermission = $this->currentUser() && $this->currentUser()->can($permission . '-all');
$ownPermission = $this->currentUser() && $this->currentUser()->can($permission . '-own');
$this->currentAction = 'view';
$allPermission = $user && $user->can($permission . '-all');
$ownPermission = $user && $user->can($permission . '-own');
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
$isOwner = $this->currentUser() && $this->currentUser()->id === $ownable->$ownerField;
$isOwner = $user && $user->id === $ownable->$ownerField;
return ($allPermission || ($isOwner && $ownPermission));
}
// Handle abnormal create jointPermissions
if ($action === 'create') {
$this->currentAction = $permission;
$action = $permission;
}
$q = $this->entityRestrictionQuery($baseQuery)->count() > 0;
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
$this->clean();
return $q;
return $hasAccess;
}
/**
* Checks if a user has the given permission for any items in the system.
* Can be passed an entity instance to filter on a specific type.
* @param string $permission
* @param string $entityClass
* @return bool
*/
public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null)
public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
{
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
$userId = $this->currentUser()->id;
$permissionQuery = $this->db->table('joint_permissions')
$permissionQuery = JointPermission::query()
->where('action', '=', $permission)
->whereIn('role_id', $userRoleIds)
->where(function ($query) use ($userId) {
$query->where('has_permission', '=', 1)
->orWhere(function ($query2) use ($userId) {
$query2->where('has_permission_own', '=', 1)
->where('owned_by', '=', $userId);
});
->where(function (Builder $query) use ($userId) {
$this->addJointHasPermissionCheck($query, $userId);
});
if (!is_null($entityClass)) {
$entityInstance = app()->make($entityClass);
$entityInstance = app($entityClass);
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
}
@ -581,46 +512,22 @@ class PermissionService
return $hasPermission;
}
/**
* Check if an entity has restrictions set on itself or its
* parent tree.
* @param \BookStack\Entities\Models\Entity $entity
* @param $action
* @return bool|mixed
*/
public function checkIfRestrictionsSet(Entity $entity, $action)
{
$this->currentAction = $action;
if ($entity->isA('page')) {
return $entity->restricted || ($entity->chapter && $entity->chapter->restricted) || $entity->book->restricted;
} elseif ($entity->isA('chapter')) {
return $entity->restricted || $entity->book->restricted;
} elseif ($entity->isA('book')) {
return $entity->restricted;
}
}
/**
* The general query filter to remove all entities
* that the current user does not have access to.
* @param $query
* @return mixed
*/
protected function entityRestrictionQuery($query)
protected function entityRestrictionQuery(Builder $query, string $action): Builder
{
$q = $query->where(function ($parentQuery) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $this->currentAction)
->where(function ($query) {
$query->where('has_permission', '=', true)
->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
$q = $query->where(function ($parentQuery) use ($action) {
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $action)
->where(function (Builder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
$this->clean();
return $q;
}
@ -634,14 +541,10 @@ class PermissionService
$this->clean();
return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
$permissionQuery->whereIn('role_id', $this->getRoles())
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
->where('action', '=', $ability)
->where(function (Builder $query) {
$query->where('has_permission', '=', true)
->orWhere(function (Builder $query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
@ -651,7 +554,7 @@ class PermissionService
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function enforceDraftVisiblityOnQuery(Builder $query): Builder
public function enforceDraftVisibilityOnQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
@ -663,109 +566,89 @@ class PermissionService
}
/**
* Add restrictions for a generic entity
* @param string $entityType
* @param Builder|\BookStack\Entities\Models\Entity $query
* @param string $action
* @return Builder
* Add restrictions for a generic entity.
*/
public function enforceEntityRestrictions($entityType, $query, $action = 'view')
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
{
if (strtolower($entityType) === 'page') {
if ($entity instanceof Page) {
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false)
->orWhere(function ($query) {
$query->where('draft', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
});
$this->enforceDraftVisibilityOnQuery($query);
}
$this->currentAction = $action;
return $this->entityRestrictionQuery($query);
return $this->entityRestrictionQuery($query, $action);
}
/**
* Filter items that have entities set as a polymorphic relation.
* @param $query
* @param string $tableName
* @param string $entityIdColumn
* @param string $entityTypeColumn
* @param string $action
* @return QueryBuilder
*/
public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
public function filterRestrictedEntityRelations(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view'): Builder
{
$this->currentAction = $action;
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
$q = $query->where(function ($query) use ($tableDetails) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails) {
$q = $query->where(function ($query) use ($tableDetails, $action) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->whereRaw('joint_permissions.entity_type=' . $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
->where('action', '=', $this->currentAction)
->whereIn('role_id', $this->getRoles())
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
->where('action', '=', $action)
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
});
$this->clean();
return $q;
}
/**
* Add conditions to a query to filter the selection to related entities
* where permissions are granted.
* @param $entityType
* @param $query
* @param $tableName
* @param $entityIdColumn
* @return mixed
* where view permissions are granted.
*/
public function filterRelatedEntity($entityType, $query, $tableName, $entityIdColumn)
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
{
$this->currentAction = 'view';
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
$morphClass = app($entityClass)->getMorphClass();
$pageMorphClass = $this->entityProvider->get($entityType)->getMorphClass();
$q = $query->where(function ($query) use ($tableDetails, $pageMorphClass) {
$query->where(function ($query) use (&$tableDetails, $pageMorphClass) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $pageMorphClass) {
$q = $query->where(function ($query) use ($tableDetails, $morphClass) {
$query->where(function ($query) use (&$tableDetails, $morphClass) {
$query->whereExists(function ($permissionQuery) use (&$tableDetails, $morphClass) {
$permissionQuery->select('id')->from('joint_permissions')
->whereRaw('joint_permissions.entity_id=' . $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where('entity_type', '=', $pageMorphClass)
->where('action', '=', $this->currentAction)
->whereIn('role_id', $this->getRoles())
->where(function ($query) {
$query->where('has_permission', '=', true)->orWhere(function ($query) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $this->currentUser()->id);
});
->where('entity_type', '=', $morphClass)
->where('action', '=', 'view')
->whereIn('role_id', $this->getCurrentUserRoles())
->where(function (QueryBuilder $query) {
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
});
});
})->orWhere($tableDetails['entityIdColumn'], '=', 0);
});
$this->clean();
return $q;
}
/**
* Get the current user
* @return \BookStack\Auth\User
* Add the query for checking the given user id has permission
* within the join_permissions table.
* @param QueryBuilder|Builder $query
*/
private function currentUser()
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
{
if ($this->currentUserModel === false) {
$query->where('has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
$query->where('has_permission_own', '=', true)
->where('owned_by', '=', $userIdToCheck);
});
}
/**
* Get the current user
*/
private function currentUser(): User
{
if (is_null($this->currentUserModel)) {
$this->currentUserModel = user();
}
@ -775,10 +658,9 @@ class PermissionService
/**
* Clean the cached user elements.
*/
private function clean()
private function clean(): void
{
$this->currentUserModel = false;
$this->userRoles = false;
$this->isAdminUser = null;
$this->currentUserModel = null;
$this->userRoles = null;
}
}

View File

@ -1,7 +1,9 @@
<?php namespace BookStack\Auth;
use BookStack\Api\ApiToken;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Interfaces\Loggable;
use BookStack\Interfaces\Sluggable;
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
@ -22,6 +24,7 @@ use Illuminate\Support\Collection;
* Class User
* @property string $id
* @property string $name
* @property string $slug
* @property string $email
* @property string $password
* @property Carbon $created_at
@ -30,8 +33,9 @@ use Illuminate\Support\Collection;
* @property int $image_id
* @property string $external_auth_id
* @property string $system_name
* @property Collection $roles
*/
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable
class User extends Model implements AuthenticatableContract, CanResetPasswordContract, Loggable, Sluggable
{
use Authenticatable, CanResetPassword, Notifiable;
@ -72,23 +76,21 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Returns the default public user.
* @return User
*/
public static function getDefault()
public static function getDefault(): User
{
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::where('system_name', '=', 'public')->first();
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
/**
* Check if the user is the default public user.
* @return bool
*/
public function isDefault()
public function isDefault(): bool
{
return $this->system_name === 'public';
}
@ -115,12 +117,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Check if the user has a role.
* @param $role
* @return mixed
*/
public function hasSystemRole($role)
public function hasSystemRole(string $roleSystemName): bool
{
return $this->roles->pluck('system_name')->contains($role);
return $this->roles->pluck('system_name')->contains($roleSystemName);
}
/**
@ -184,9 +184,8 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Get the social account associated with this user.
* @return HasMany
*/
public function socialAccounts()
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
@ -207,11 +206,9 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Returns the user's avatar,
* @param int $size
* @return string
* Returns a URL to the user's avatar
*/
public function getAvatar($size = 50)
public function getAvatar(int $size = 50): string
{
$default = url('/user_avatar.png');
$imageId = $this->image_id;
@ -229,9 +226,8 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Get the avatar for the user.
* @return BelongsTo
*/
public function avatar()
public function avatar(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
@ -271,15 +267,13 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function getProfileUrl(): string
{
return url('/user/' . $this->id);
return url('/user/' . $this->slug);
}
/**
* Get a shortened version of the user's name.
* @param int $chars
* @return string
*/
public function getShortName($chars = 8)
public function getShortName(int $chars = 8): string
{
if (mb_strlen($this->name) <= $chars) {
return $this->name;
@ -310,4 +304,13 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{
return "({$this->id}) {$this->name}";
}
/**
* @inheritDoc
*/
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}
}

View File

@ -45,6 +45,14 @@ class UserRepo
return User::query()->findOrFail($id);
}
/**
* Get a user by their slug.
*/
public function getBySlug(string $slug): User
{
return User::query()->where('slug', '=', $slug)->firstOrFail();
}
/**
* Get all the users with their permissions.
*/
@ -159,7 +167,13 @@ class UserRepo
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
];
return User::query()->forceCreate($details);
$user = new User();
$user->forceFill($details);
$user->refreshSlug();
$user->save();
return $user;
}
/**

View File

@ -19,13 +19,6 @@ return [
// private configuration variables so should remain disabled in public.
'debug' => env('APP_DEBUG', false),
// Set the default view type for various lists. Can be overridden by user preferences.
// These will be used for public viewers and users that have not set a preference.
'views' => [
'books' => env('APP_VIEWS_BOOKS', 'list'),
'bookshelves' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
],
// The number of revisions to keep in the database.
// Once this limit is reached older revisions will be deleted.
// If set to false then a limit will not be enforced.
@ -63,7 +56,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'id', 'it', 'ja', 'ko', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
// Application Fallback Locale
'fallback_locale' => 'en',
@ -122,6 +115,7 @@ return [
BookStack\Providers\TranslationServiceProvider::class,
// BookStack custom service providers
BookStack\Providers\ThemeServiceProvider::class,
BookStack\Providers\AuthServiceProvider::class,
BookStack\Providers\AppServiceProvider::class,
BookStack\Providers\BroadcastServiceProvider::class,
@ -190,10 +184,10 @@ return [
// Custom BookStack
'Activity' => BookStack\Facades\Activity::class,
'Setting' => BookStack\Facades\Setting::class,
'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::class,
'Theme' => BookStack\Facades\Theme::class,
],

View File

@ -132,6 +132,7 @@ return [
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
'start_tls' => env('LDAP_START_TLS', false),
],
];

View File

@ -59,7 +59,7 @@ return [
// The session cookie path determines the path for which the cookie will
// be regarded as available. Typically, this will be the root path of
// your application but you are free to change this when necessary.
'path' => '/',
'path' => '/' . (explode('/', env('APP_URL', ''), 4)[3] ?? ''),
// Session Cookie Domain
// Here you may change the domain of the cookie used to identify a session

View File

@ -24,4 +24,12 @@ return [
'app-custom-head' => false,
'registration-enabled' => false,
// User-level default settings
'user' => [
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' =>env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
],
];

View File

@ -4,6 +4,7 @@ namespace BookStack\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
use Illuminate\Support\Facades\DB;
class UpdateUrl extends Command
{
@ -60,22 +61,50 @@ class UpdateUrl extends Command
"attachments" => ["path"],
"pages" => ["html", "text", "markdown"],
"images" => ["url"],
"settings" => ["value"],
"comments" => ["html", "text"],
];
foreach ($columnsToUpdateByTable as $table => $columns) {
foreach ($columns as $column) {
$changeCount = $this->db->table($table)->update([
$column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
]);
$changeCount = $this->replaceValueInTable($table, $column, $oldUrl, $newUrl);
$this->info("Updated {$changeCount} rows in {$table}->{$column}");
}
}
$jsonColumnsToUpdateByTable = [
"settings" => ["value"],
];
foreach ($jsonColumnsToUpdateByTable as $table => $columns) {
foreach ($columns as $column) {
$oldJson = trim(json_encode($oldUrl), '"');
$newJson = trim(json_encode($newUrl), '"');
$changeCount = $this->replaceValueInTable($table, $column, $oldJson, $newJson);
$this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}");
}
}
$this->info("URL update procedure complete.");
$this->info('============================================================================');
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
$this->info('============================================================================');
return 0;
}
/**
* Perform a find+replace operations in the provided table and column.
* Returns the count of rows changed.
*/
protected function replaceValueInTable(string $table, string $column, string $oldUrl, string $newUrl): int
{
$oldQuoted = $this->db->getPdo()->quote($oldUrl);
$newQuoted = $this->db->getPdo()->quote($newUrl);
return $this->db->table($table)->update([
$column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})")
]);
}
/**
* Warn the user of the dangers of this operation.
* Returns a boolean indicating if they've accepted the warnings.

View File

@ -1,8 +1,5 @@
<?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;
@ -49,7 +46,7 @@ abstract class BookChild extends Entity
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages as $page) {
foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}

View File

@ -9,6 +9,7 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Entities\Tools\SearchIndex;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Facades\Permissions;
use BookStack\Interfaces\Sluggable;
use BookStack\Model;
use BookStack\Traits\HasCreatorAndUpdater;
use BookStack\Traits\HasOwner;
@ -37,7 +38,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model
abstract class Entity extends Model implements Sluggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
@ -289,11 +290,11 @@ abstract class Entity extends Model
}
/**
* Generate and set a new URL slug for this model.
* @inheritdoc
*/
public function refreshSlug(): string
{
$this->slug = (new SlugGenerator)->generate($this);
$this->slug = app(SlugGenerator::class)->generate($this);
return $this->slug;
}
}

View File

@ -40,7 +40,7 @@ class Page extends BookChild
*/
public function scopeVisible(Builder $query): Builder
{
$query = Permissions::enforceDraftVisiblityOnQuery($query);
$query = Permissions::enforceDraftVisibilityOnQuery($query);
return parent::scopeVisible($query);
}

View File

@ -177,25 +177,24 @@ class PageRepo
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
$oldMarkdown = $page->markdown;
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
// Update with new details
$page->revision_count++;
if (setting('app-editor') !== 'markdown') {
$page->markdown = '';
}
$page->save();
// Remove all update drafts for this user & page.
$this->getUserDraftQuery($page)->delete();
// Save a revision after updating
$summary = $input['summary'] ?? null;
if ($oldHtml !== $input['html'] || $oldName !== $input['name'] || $summary !== null) {
$summary = trim($input['summary'] ?? "");
$htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml;
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
$this->savePageRevision($page, $summary);
}
@ -224,10 +223,6 @@ class PageRepo
{
$revision = new PageRevision($page->getAttributes());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
}
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@ -290,7 +285,13 @@ class PageRepo
$page->fill($revision->toArray());
$content = new PageContent($page);
$content->setNewHTML($revision->html);
if (!empty($revision->markdown)) {
$content->setNewMarkdown($revision->markdown);
} else {
$content->setNewHTML($revision->html);
}
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();

View File

@ -13,4 +13,4 @@ class CustomStrikeThroughExtension implements ExtensionInterface
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
$environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
}
}
}

View File

@ -21,4 +21,4 @@ class CustomStrikethroughRenderer implements InlineRendererInterface
return new HtmlElement('s', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
}
}
}

View File

@ -2,6 +2,8 @@
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use DOMDocument;
use DOMNodeList;
use DOMXPath;
@ -53,6 +55,7 @@ class PageContent
$environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
return $converter->convertToHtml($markdown);
}

View File

@ -137,5 +137,4 @@ class SearchOptions
return $string;
}
}
}

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Entities\Tools;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Connection;
@ -178,7 +179,7 @@ class SearchRunner
}
}
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
return $this->permissionService->enforceEntityRestrictions($entity, $entitySelect, $action);
}
/**
@ -270,24 +271,29 @@ class SearchRunner
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
{
if (!is_numeric($input) && $input !== 'me') {
return;
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('created_by', '=', $user->id);
}
if ($input === 'me') {
$input = user()->id;
}
$query->where('created_by', '=', $input);
}
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
{
if (!is_numeric($input) && $input !== 'me') {
return;
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('updated_by', '=', $user->id);
}
if ($input === 'me') {
$input = user()->id;
}
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input)
{
$userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
if ($user) {
$query->where('owned_by', '=', $user->id);
}
$query->where('updated_by', '=', $input);
}
protected function filterInName(EloquentBuilder $query, Entity $model, $input)

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\BookChild;
use BookStack\Interfaces\Sluggable;
use Illuminate\Support\Str;
class SlugGenerator
@ -10,11 +11,11 @@ class SlugGenerator
* 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(Entity $entity): string
public function generate(Sluggable $model): string
{
$slug = $this->formatNameAsSlug($entity->name);
while ($this->slugInUse($slug, $entity)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
$slug = $this->formatNameAsSlug($model->name);
while ($this->slugInUse($slug, $model)) {
$slug .= '-' . Str::random(3);
}
return $slug;
}
@ -35,16 +36,16 @@ class SlugGenerator
* Check if a slug is already in-use for this
* type of model within the same parent.
*/
protected function slugInUse(string $slug, Entity $entity): bool
protected function slugInUse(string $slug, Sluggable $model): bool
{
$query = $entity->newQuery()->where('slug', '=', $slug);
$query = $model->newQuery()->where('slug', '=', $slug);
if ($entity instanceof BookChild) {
$query->where('book_id', '=', $entity->book_id);
if ($model instanceof BookChild) {
$query->where('book_id', '=', $model->book_id);
}
if ($entity->id) {
$query->where('id', '!=', $entity->id);
if ($model->id) {
$query->where('id', '!=', $model->id);
}
return $query->count() > 0;

View File

@ -273,11 +273,11 @@ class TrashCan
$count++;
};
if ($entity->isA('chapter') || $entity->isA('book')) {
if ($entity instanceof Chapter || $entity instanceof Book) {
$entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
}
if ($entity->isA('book')) {
if ($entity instanceof Book) {
$entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
}
@ -286,19 +286,20 @@ class TrashCan
/**
* Destroy the given entity.
* @throws Exception
*/
protected function destroyEntity(Entity $entity): int
{
if ($entity->isA('page')) {
if ($entity instanceof Page) {
return $this->destroyPage($entity);
}
if ($entity->isA('chapter')) {
if ($entity instanceof Chapter) {
return $this->destroyChapter($entity);
}
if ($entity->isA('book')) {
if ($entity instanceof Book) {
return $this->destroyBook($entity);
}
if ($entity->isA('shelf')) {
if ($entity instanceof Bookshelf) {
return $this->destroyShelf($entity);
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Exceptions;
class ApiAuthException extends UnauthorizedException {
class ApiAuthException extends UnauthorizedException
{
}
}

View File

@ -3,9 +3,7 @@
namespace BookStack\Exceptions;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -16,36 +14,42 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Handler extends ExceptionHandler
{
/**
* A list of the exception types that should not be reported.
* A list of the exception types that are not reported.
*
* @var array
*/
protected $dontReport = [
AuthorizationException::class,
HttpException::class,
ModelNotFoundException::class,
ValidationException::class,
NotFoundException::class,
];
/**
* Report or log an exception.
* This is a great spot to send exceptions to Sentry, Bugsnag, etc.
* A list of the inputs that are never flashed for validation exceptions.
*
* @var array
*/
protected $dontFlash = [
'password',
'password_confirmation',
];
/**
* Report or log an exception.
*
* @param Exception $exception
* @return void
*
* @param \Exception $e
* @return mixed
* @throws Exception
*/
public function report(Exception $e)
public function report(Exception $exception)
{
return parent::report($e);
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @param \Illuminate\Http\Request $request
* @param \Exception $e
* @param Exception $e
* @return \Illuminate\Http\Response
*/
public function render($request, Exception $e)
@ -117,11 +121,8 @@ class Handler extends ExceptionHandler
/**
* Check the exception chain to compare against the original exception type.
* @param Exception $e
* @param $type
* @return bool
*/
protected function isExceptionType(Exception $e, $type)
protected function isExceptionType(Exception $e, string $type): bool
{
do {
if (is_a($e, $type)) {
@ -133,10 +134,8 @@ class Handler extends ExceptionHandler
/**
* Get original exception message.
* @param Exception $e
* @return string
*/
protected function getOriginalMessage(Exception $e)
protected function getOriginalMessage(Exception $e): string
{
do {
$message = $e->getMessage();

View File

@ -22,4 +22,4 @@ class JsonDebugException extends Exception
{
return response()->json($this->data);
}
}
}

View File

@ -5,7 +5,6 @@ class NotFoundException extends PrettyException
/**
* NotFoundException constructor.
* @param string $message
*/
public function __construct($message = 'Item not found')
{

View File

@ -14,4 +14,4 @@ class UnauthorizedException extends Exception
{
parent::__construct($message, $code);
}
}
}

View File

@ -2,7 +2,7 @@
use Illuminate\Support\Facades\Facade;
class Setting extends Facade
class Theme extends Facade
{
/**
* Get the registered name of the component.
@ -11,6 +11,6 @@ class Setting extends Facade
*/
protected static function getFacadeAccessor()
{
return 'setting';
return 'theme';
}
}

View File

@ -27,4 +27,4 @@ abstract class ApiController extends Controller
{
return $this->rules;
}
}
}

View File

@ -25,5 +25,4 @@ class ApiDocsController extends ApiController
$docs = ApiDocsGenerator::generateConsideringCache();
return response()->json($docs);
}
}

View File

@ -91,4 +91,4 @@ class BookApiController extends ApiController
$this->bookRepo->destroy($book);
return response('', 204);
}
}
}

View File

@ -112,4 +112,4 @@ class BookshelfApiController extends ApiController
$this->bookshelfRepo->destroy($shelf);
return response('', 204);
}
}
}

View File

@ -20,6 +20,7 @@ class AuditLogController extends Controller
'sort' => $request->get('sort', 'created_at'),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
];
$query = Activity::query()
@ -34,6 +35,9 @@ class AuditLogController extends Controller
if ($listDetails['event']) {
$query->where('type', '=', $listDetails['event']);
}
if ($listDetails['user']) {
$query->where('user_id', '=', $listDetails['user']);
}
if ($listDetails['date_from']) {
$query->where('created_at', '>=', $listDetails['date_from']);

View File

@ -2,12 +2,15 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\EmailConfirmationService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -80,6 +83,8 @@ class ConfirmEmailController extends Controller
$user->save();
auth()->login($user);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
$this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);

View File

@ -7,7 +7,9 @@ use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
use BookStack\Theming\ThemeEvents;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
@ -150,6 +152,7 @@ class LoginController extends Controller
}
}
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
return redirect()->intended($this->redirectPath());
}
@ -195,5 +198,4 @@ class LoginController extends Controller
return redirect('/login');
}
}

View File

@ -2,11 +2,14 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\User;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
use BookStack\Theming\ThemeEvents;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@ -93,6 +96,8 @@ class RegisterController extends Controller
try {
$user = $this->registrationService->registerUser($userData);
auth()->login($user);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
} catch (UserRegistrationException $exception) {
if ($exception->getMessage()) {
$this->showErrorNotification($exception->getMessage());
@ -117,5 +122,4 @@ class RegisterController extends Controller
'password' => Hash::make($data['password']),
]);
}
}

View File

@ -82,5 +82,4 @@ class Saml2Controller extends Controller
return redirect()->intended();
}
}

View File

@ -2,16 +2,17 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use BookStack\Theming\ThemeEvents;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialUser;
@ -31,12 +32,11 @@ class SocialController extends Controller
$this->registrationService = $registrationService;
}
/**
* Redirect to the relevant social site.
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
* @throws SocialDriverNotConfigured
*/
public function getSocialLogin(string $socialDriver)
public function login(string $socialDriver)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
@ -47,7 +47,7 @@ class SocialController extends Controller
* @throws SocialDriverNotConfigured
* @throws UserRegistrationException
*/
public function socialRegister(string $socialDriver)
public function register(string $socialDriver)
{
$this->registrationService->ensureRegistrationAllowed();
session()->put('social-callback', 'register');
@ -60,7 +60,7 @@ class SocialController extends Controller
* @throws SocialDriverNotConfigured
* @throws UserRegistrationException
*/
public function socialCallback(Request $request, string $socialDriver)
public function callback(Request $request, string $socialDriver)
{
if (!session()->has('social-callback')) {
throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
@ -99,7 +99,7 @@ class SocialController extends Controller
/**
* Detach a social account from a user.
*/
public function detachSocialAccount(string $socialDriver)
public function detach(string $socialDriver)
{
$this->socialAuthService->detachSocialAccount($socialDriver);
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
@ -113,7 +113,7 @@ class SocialController extends Controller
protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance
@ -130,6 +130,8 @@ class SocialController extends Controller
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
auth()->login($user);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $socialDriver, $user);
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
$this->showSuccessNotification(trans('auth.register_success'));
return redirect('/');

View File

@ -2,11 +2,14 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Facades\Theme;
use BookStack\Http\Controllers\Controller;
use BookStack\Theming\ThemeEvents;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -68,6 +71,8 @@ class UserInviteController extends Controller
$user->save();
auth()->login($user);
Theme::dispatch(ThemeEvents::AUTH_LOGIN, auth()->getDefaultDriver(), $user);
$this->logActivity(ActivityType::AUTH_LOGIN, $user);
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);

View File

@ -30,7 +30,7 @@ class BookController extends Controller
*/
public function index()
{
$view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
$view = setting()->getForCurrentUser('books_view_type');
$sort = setting()->getForCurrentUser('books_sort', 'name');
$order = setting()->getForCurrentUser('books_sort_order', 'asc');

View File

@ -32,7 +32,7 @@ class BookshelfController extends Controller
*/
public function index()
{
$view = setting()->getForCurrentUser('bookshelves_view_type', config('app.views.bookshelves', 'grid'));
$view = setting()->getForCurrentUser('bookshelves_view_type');
$sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
$sortOptions = [
@ -103,7 +103,7 @@ class BookshelfController extends Controller
Views::add($shelf);
$this->entityContextManager->setShelfContext($shelf->id);
$view = setting()->getForCurrentUser('bookshelf_view_type', config('app.views.books'));
$view = setting()->getForCurrentUser('bookshelf_view_type');
$this->setPageTitle($shelf->getShortName());
return view('shelves.show', [

View File

@ -159,6 +159,6 @@ abstract class Controller extends BaseController
*/
protected function getImageValidationRules(): string
{
return 'image_extension|no_double_extension|mimes:jpeg,png,gif,webp';
return 'image_extension|mimes:jpeg,png,gif,webp';
}
}

View File

@ -56,7 +56,7 @@ class HomeController extends Controller
// Add required list ordering & sorting for books & shelves views.
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
$key = $homepageOption;
$view = setting()->getForCurrentUser($key . '_view_type', config('app.views.' . $key));
$view = setting()->getForCurrentUser($key . '_view_type');
$sort = setting()->getForCurrentUser($key . '_sort', 'name');
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc');

View File

@ -1,9 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Actions\ViewService;
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;

View File

@ -140,5 +140,4 @@ class UserApiTokenController extends Controller
$token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail();
return [$user, $token];
}
}

View File

@ -5,10 +5,13 @@ use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
@ -61,7 +64,7 @@ class UserController extends Controller
/**
* Store a newly created user in storage.
* @throws UserUpdateException
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function store(Request $request)
{
@ -90,6 +93,7 @@ class UserController extends Controller
$user->external_auth_id = $request->get('external_auth_id');
}
$user->refreshSlug();
$user->save();
if ($sendInvite) {
@ -132,8 +136,8 @@ class UserController extends Controller
/**
* Update the specified user in storage.
* @throws UserUpdateException
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \Illuminate\Validation\ValidationException
* @throws ImageUploadException
* @throws ValidationException
*/
public function update(Request $request, int $id)
{
@ -157,6 +161,11 @@ class UserController extends Controller
$user->email = $request->get('email');
}
// Refresh the slug if the user's name has changed
if ($user->isDirty('name')) {
$user->refreshSlug();
}
// Role updates
if (userCan('users-manage') && $request->filled('roles')) {
$roles = $request->get('roles');
@ -216,7 +225,7 @@ class UserController extends Controller
/**
* Remove the specified user from storage.
* @throws \Exception
* @throws Exception
*/
public function destroy(Request $request, int $id)
{
@ -243,25 +252,6 @@ class UserController extends Controller
return redirect('/settings/users');
}
/**
* Show the user profile page
*/
public function showProfilePage($id)
{
$user = $this->userRepo->getById($id);
$userActivity = $this->userRepo->getActivity($user);
$recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5);
$assetCounts = $this->userRepo->getAssetCounts($user);
return view('users.profile', [
'user' => $user,
'activity' => $userActivity,
'recentlyCreated' => $recentlyCreated,
'assetCounts' => $assetCounts
]);
}
/**
* Update the user's preferred book-list display setting.
*/

View File

@ -0,0 +1,25 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
{
/**
* Show the user profile page
*/
public function show(UserRepo $repo, string $slug)
{
$user = $repo->getBySlug($slug);
$userActivity = $repo->getActivity($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
return view('users.profile', [
'user' => $user,
'activity' => $userActivity,
'recentlyCreated' => $recentlyCreated,
'assetCounts' => $assetCounts
]);
}
}

View File

@ -28,6 +28,7 @@ class Kernel extends HttpKernel
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class,
],
'api' => [

View File

@ -33,4 +33,4 @@ trait ChecksForEmailConfirmation
return false;
}
}
}

View File

@ -18,6 +18,8 @@ class Localization
protected $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',
@ -26,13 +28,15 @@ class Localization
'es_AR' => 'es_AR',
'fr' => 'fr_FR',
'he' => 'he_IL',
'id' => 'id_ID',
'it' => 'it_IT',
'ja' => 'ja',
'ko' => 'ko_KR',
'lv' => 'lv_LV',
'nl' => 'nl_NL',
'nb' => 'nb_NO',
'pl' => 'pl_PL',
'pt' => 'pl_PT',
'pt' => 'pt_PT',
'pt_BR' => 'pt_BR',
'ru' => 'ru',
'sk' => 'sk_SK',

View File

@ -0,0 +1,29 @@
<?php
namespace BookStack\Http\Middleware;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Closure;
class RunThemeActions
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
$earlyResponse = Theme::dispatch(ThemeEvents::WEB_MIDDLEWARE_BEFORE, $request);
if (!is_null($earlyResponse)) {
return $earlyResponse;
}
$response = $next($request);
$response = Theme::dispatch(ThemeEvents::WEB_MIDDLEWARE_AFTER, $request, $response) ?? $response;
return $response;
}
}

View File

@ -14,5 +14,4 @@ class ThrottleApiRequests extends Middleware
{
return (int) config('api.requests_per_minute');
}
}
}

View File

@ -8,4 +8,4 @@ interface Loggable
* Get the string descriptor for this item.
*/
public function logDescriptor(): string;
}
}

View File

@ -0,0 +1,23 @@
<?php namespace BookStack\Interfaces;
use Illuminate\Database\Eloquent\Builder;
/**
* Interface Sluggable
*
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
* @method Builder newQuery
*/
interface Sluggable
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@ -1,6 +1,7 @@
<?php namespace BookStack\Providers;
use Blade;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\BreadcrumbsViewComposer;
@ -12,6 +13,7 @@ use Illuminate\Contracts\Cache\Repository;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Laravel\Socialite\Contracts\Factory as SocialiteFactory;
use Schema;
use URL;
@ -62,5 +64,9 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(SettingService::class, function ($app) {
return new SettingService($app->make(Setting::class), $app->make(Repository::class));
});
$this->app->singleton(SocialAuthService::class, function($app) {
return new SocialAuthService($app->make(SocialiteFactory::class));
});
}
}

View File

@ -5,7 +5,7 @@ namespace BookStack\Providers;
use BookStack\Actions\ActivityService;
use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Settings\SettingService;
use BookStack\Theming\ThemeService;
use BookStack\Uploads\ImageService;
use Illuminate\Support\ServiceProvider;
@ -36,10 +36,6 @@ class CustomFacadeProvider extends ServiceProvider
return $this->app->make(ViewService::class);
});
$this->app->singleton('setting', function () {
return $this->app->make(SettingService::class);
});
$this->app->singleton('images', function () {
return $this->app->make(ImageService::class);
});
@ -47,5 +43,9 @@ class CustomFacadeProvider extends ServiceProvider
$this->app->singleton('permissions', function () {
return $this->app->make(PermissionService::class);
});
$this->app->singleton('theme', function () {
return $this->app->make(ThemeService::class);
});
}
}

View File

@ -18,11 +18,6 @@ class CustomValidationServiceProvider extends ServiceProvider
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;

View File

@ -0,0 +1,34 @@
<?php
namespace BookStack\Providers;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
{
$this->app->singleton(ThemeService::class, function ($app) {
return new ThemeService;
});
}
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
{
$themeService = $this->app->make(ThemeService::class);
$themeService->readThemeActions();
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
}
}

View File

@ -17,5 +17,4 @@ class TranslationServiceProvider extends BaseProvider
return new FileLoader($app['files'], $app['path.lang']);
});
}
}
}

View File

@ -29,9 +29,9 @@ class SettingService
* Gets a setting from the database,
* If not found, Returns default, Which is false by default.
*/
public function get(string $key, $default = false)
public function get(string $key, $default = null)
{
if ($default === false) {
if (is_null($default)) {
$default = config('setting-defaults.' . $key, false);
}
@ -57,8 +57,12 @@ class SettingService
/**
* Get a user-specific setting from the database or cache.
*/
public function getUser(User $user, string $key, $default = false)
public function getUser(User $user, string $key, $default = null)
{
if (is_null($default)) {
$default = config('setting-defaults.user.' . $key, false);
}
if ($user->isDefault()) {
return $this->getFromSession($key, $default);
}
@ -68,7 +72,7 @@ class SettingService
/**
* Get a value for the current logged-in user.
*/
public function getForCurrentUser(string $key, $default = false)
public function getForCurrentUser(string $key, $default = null)
{
return $this->getUser(user(), $key, $default);
}
@ -172,7 +176,7 @@ class SettingService
*/
protected function formatArrayValue(array $value): string
{
$values = collect($value)->values()->filter(function(array $item) {
$values = collect($value)->values()->filter(function (array $item) {
return count(array_filter($item)) > 0;
});
return json_encode($values);

View File

@ -0,0 +1,73 @@
<?php namespace BookStack\Theming;
/**
* The ThemeEvents used within BookStack.
*
* This file details the events that BookStack may fire via the custom
* theme system, including event names, parameters and expected return types.
*
* This system is regarded as semi-stable.
* We'll look to fix issues with it or migrate old event types but
* events and their signatures may change in new versions of BookStack.
* We'd advise testing any usage of these events upon upgrade.
*/
class ThemeEvents
{
/**
* Application boot-up.
* After main services are registered.
* @param \BookStack\Application $app
*/
const APP_BOOT = 'app_boot';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those
* that depend on the current session user (Localization for example).
* Provides the original request to use.
* Return values, if provided, will be used as a new response to use.
* @param \Illuminate\Http\Request $request
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_BEFORE = 'web_middleware_before';
/**
* Web after middleware action.
* Runs after the request is handled but before the response is sent.
* Provides both the original request and the currently resolved response.
* Return values, if provided, will be used as a new response to use.
* @param \Illuminate\Http\Request $request
* @returns \Illuminate\Http\Response|null
*/
const WEB_MIDDLEWARE_AFTER = 'web_middleware_after';
/**
* Auth login event.
* Runs right after a user is logged-in to the application by any authentication
* system as a standard app user. This includes a user becoming logged in
* after registration. This is not emitted upon API usage.
* @param string $authSystem
* @param \BookStack\Auth\User $user
*/
const AUTH_LOGIN = 'auth_login';
/**
* Auth register event.
* Runs right after a user is newly registered to the application by any authentication
* system as a standard app user. This includes auto-registration systems used
* by LDAP, SAML and social systems. It only includes self-registrations.
* @param string $authSystem
* @param \BookStack\Auth\User $user
*/
const AUTH_REGISTER = 'auth_register';
/**
* Commonmark environment configure.
* Provides the commonmark library environment for customization
* before its used to render markdown content.
* If the listener returns a non-null value, that will be used as an environment instead.
* @param \League\CommonMark\ConfigurableEnvironmentInterface $environment
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
*/
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
}

View File

@ -0,0 +1,61 @@
<?php namespace BookStack\Theming;
use BookStack\Auth\Access\SocialAuthService;
class ThemeService
{
protected $listeners = [];
/**
* Listen to a given custom theme event,
* setting up the action to be ran when the event occurs.
*/
public function listen(string $event, callable $action)
{
if (!isset($this->listeners[$event])) {
$this->listeners[$event] = [];
}
$this->listeners[$event][] = $action;
}
/**
* Dispatch the given event name.
* Runs any registered listeners for that event name,
* passing all additional variables to the listener action.
*
* If a callback returns a non-null value, this method will
* stop and return that value itself.
* @return mixed
*/
public function dispatch(string $event, ...$args)
{
foreach ($this->listeners[$event] ?? [] as $action) {
$result = call_user_func_array($action, $args);
if (!is_null($result)) {
return $result;
}
}
return null;
}
/**
* Read any actions from the set theme path if the 'functions.php' file exists.
*/
public function readThemeActions()
{
$themeActionsFile = theme_path('functions.php');
if (file_exists($themeActionsFile)) {
require $themeActionsFile;
}
}
/**
* @see SocialAuthService::addSocialDriver
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler)
{
$socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler);
}
}

View File

@ -24,5 +24,4 @@ trait HasCreatorAndUpdater
{
return $this->belongsTo(User::class, 'updated_by');
}
}

View File

@ -15,5 +15,4 @@ trait HasOwner
{
return $this->belongsTo(User::class, 'owned_by');
}
}

View File

@ -27,4 +27,4 @@ class FileLoader extends BaseLoader
return $this->loadNamespaced($locale, $group, $namespace);
}
}
}

View File

@ -70,8 +70,7 @@ class ImageRepo
int $uploadedTo = null,
string $search = null,
callable $whereClause = null
): array
{
): array {
$imageQuery = $this->image->newQuery()->where('type', '=', strtolower($type));
if ($uploadedTo !== null) {
@ -83,7 +82,7 @@ class ImageRepo
}
// Filter by page access
$imageQuery = $this->restrictionService->filterRelatedEntity('page', $imageQuery, 'images', 'uploaded_to');
$imageQuery = $this->restrictionService->filterRelatedEntity(Page::class, $imageQuery, 'images', 'uploaded_to');
if ($whereClause !== null) {
$imageQuery = $imageQuery->where($whereClause);
@ -102,8 +101,7 @@ class ImageRepo
int $pageSize = 24,
int $uploadedTo = null,
string $search = null
): array
{
): array {
$contextPage = $this->page->findOrFail($uploadedTo);
$parentFilter = null;

View File

@ -139,7 +139,7 @@ class ImageService
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('.', $nameParts);
$name = implode('-', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {

View File

@ -97,5 +97,4 @@ class UserAvatars
return $url;
}
}
}

View File

@ -79,9 +79,9 @@ function userCanOnAny(string $permission, string $entityClass = null): bool
/**
* Helper to access system settings.
* @return bool|string|SettingService
* @return mixed|SettingService
*/
function setting(string $key = null, $default = false)
function setting(string $key = null, $default = null)
{
$settingService = resolve(SettingService::class);